From aff6c429ce76a69426e7dba121e89badc9bf7de7 Mon Sep 17 00:00:00 2001 From: Ninjdai Date: Mon, 4 Aug 2025 13:15:15 +0200 Subject: [PATCH 1/3] feat: BNF API instead of openlibrary to expand the available book search --- Cargo.lock | 11 +++++ Cargo.toml | 1 + src/utils/bnf.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + 4 files changed, 121 insertions(+) create mode 100644 src/utils/bnf.rs diff --git a/Cargo.lock b/Cargo.lock index 5253f1f..31abc0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,7 @@ dependencies = [ "openssl", "password-hash", "pretty_env_logger", + "quick-xml", "rand_core 0.9.3", "reqwest", "sea-orm", @@ -2082,6 +2083,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.8" diff --git a/Cargo.toml b/Cargo.toml index 6d82748..208b10e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,5 @@ rand_core = { version = "0.9.3", features = ["os_rng"] } password-hash = { version = "0.5.0", features = ["getrandom"] } jsonwebtoken = "9.3.1" openssl = { version = "0.10.73", features = ["vendored"] } +quick-xml = { version = "0.38.1", features = ["serialize"] } diff --git a/src/utils/bnf.rs b/src/utils/bnf.rs new file mode 100644 index 0000000..782bf06 --- /dev/null +++ b/src/utils/bnf.rs @@ -0,0 +1,108 @@ +use reqwest::StatusCode; +use sea_orm::ActiveValue::{NotSet, Set}; +use serde::Deserialize; +use serde_json::Value; + +use crate::entities::book; + +#[derive(Deserialize)] +pub struct Response { + #[serde(rename = "numberOfRecords")] + records_number: u32, + #[serde(rename = "records")] + records: Vec +} + +#[derive(Deserialize)] +pub struct RecordListElement { + #[serde(rename = "record")] + record: Record +} + +#[derive(Deserialize)] +pub struct Record { + #[serde(rename = "recordData")] + record_data: RecordData +} + +#[derive(Deserialize)] +pub struct RecordData { + #[serde(rename = "record")] + record: RecordDataFields +} + +#[derive(Deserialize)] +pub struct RecordDataFields { + #[serde(rename = "datafield")] + datafields: Vec +} + +#[derive(Deserialize)] +pub struct DataField { + #[serde(rename = "@tag")] + tag: String, + #[serde(rename = "subfield")] + subfields: Vec +} + +#[derive(Deserialize, Clone)] +pub struct SubField { + #[serde(rename = "@code")] + code: String, + #[serde(rename = "$text")] + value: String +} + + +pub struct FetchedBook { + pub ean: String, + pub title: String, + pub author: String +} + +impl FetchedBook { + pub fn to_active_model(&self) -> book::ActiveModel { + book::ActiveModel { + id: NotSet, + ean: Set(self.ean.clone()), + title: Set(self.title.clone()), + author: Set(self.author.clone()) + } + } +} + +pub async fn fetch_book_by_ean(web_client: &reqwest::Client, ean: &String) -> Option { + let body = web_client.execute( + web_client.get(format!("https://catalogue.bnf.fr/api/SRU?version=1.2&operation=searchRetrieve&query=bib.isbn any \"{ean}\"")) + .build() + .expect("get request creation failed") + ).await.unwrap(); + match body.status() { + StatusCode::OK => { + let res = body.text().await.unwrap().replace("\n", ""); + log::debug!(target: "api", "BNF book fetch result: {res:#?}"); + let der: Result = quick_xml::de::from_str(&res); + match der { + Ok(v) => { + if v.records_number == 0 { + log::debug!(target: "api", "BNF returned 0 records for fetch"); + return None; + } + let data_dubfield = v.records.first().unwrap().record_data.record.record.datafields.iter().find(|d| d.tag == "200").unwrap().subfields.clone(); + Some(FetchedBook { + ean: ean.to_string(), + title: data_dubfield.iter().find(|p| p.code == "a").unwrap().value.clone(), + author: data_dubfield.iter().find(|p| p.code == "f").unwrap().value.clone(), + }) + }, + Err(e) => { + log::debug!(target: "api", "Error while deserializing: {e:?}"); + None + } + } + }, + _ => None + } +} + +//https://catalogue.bnf.fr/api/SRU?version=1.2&operation=searchRetrieve&query=bib.isbn%20any%20%{ean}%22 diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 8068292..4566cf7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod bnf; pub mod cli; pub mod events; pub mod open_library; From fc494d213589ded8ebc38962aec4f0826de8a087 Mon Sep 17 00:00:00 2001 From: Ninjdai Date: Mon, 4 Aug 2025 13:16:24 +0200 Subject: [PATCH 2/3] feat: get bal owner book instances endpoint --- src/main.rs | 1 + src/routes/bal.rs | 4 +-- src/routes/book.rs | 4 +-- src/routes/book_instance.rs | 51 ++++++++++++++++++++++++++++++++----- src/routes/owner.rs | 4 +-- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 34ea4f4..f91ebb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,6 +170,7 @@ async fn run_server(db: Arc) { .routes(routes!(routes::book_instance::update_book_instance)) .routes(routes!(routes::book_instance::sell_book_instance)) .routes(routes!(routes::book_instance::bulk_create_book_instance)) + .routes(routes!(routes::book_instance::get_bal_owner_book_instances)) // Owner API .routes(routes!(routes::owner::get_owner_by_id)) .routes(routes!(routes::owner::create_owner)) diff --git a/src/routes/bal.rs b/src/routes/bal.rs index ec3a43b..f7691f7 100644 --- a/src/routes/bal.rs +++ b/src/routes/bal.rs @@ -57,7 +57,7 @@ pub struct BalCreateParams { request_body = BalCreateParams, security(("jwt" = [])), responses( - (status = OK, body = bal::Model, description = "Successfully created BAL"), + (status = CREATED, body = bal::Model, description = "Successfully created BAL"), ), summary = "Create a new bal", description = "Create a new bal", @@ -80,7 +80,7 @@ pub async fn create_bal( log::error!(target: "api", "Error while inserting new bal: {:#?}", e); (StatusCode::BAD_REQUEST, Json(None)) }, - Ok(res) => (StatusCode::OK, Json(Some(res.try_into_model().expect("All fields should be set once the bal is saved")))) + Ok(res) => (StatusCode::CREATED, Json(Some(res.try_into_model().expect("All fields should be set once the bal is saved")))) } } diff --git a/src/routes/book.rs b/src/routes/book.rs index bff6126..eb43a97 100644 --- a/src/routes/book.rs +++ b/src/routes/book.rs @@ -101,7 +101,7 @@ pub struct BookCreateParams { request_body = BookCreateParams, security(("jwt" = [])), responses( - (status = OK, body = book::Model, description = "Successfully saved book data"), + (status = CREATED, body = book::Model, description = "Successfully saved book data"), (status = CONFLICT, body = book::Model, description = "A book with the same EAN already exists. Replies with the data of the already saved book."), ), summary = "Manually add book data to the database", @@ -129,6 +129,6 @@ pub async fn create_book( log::error!(target: "api", "Error while inserting new book: {:#?}", e); (StatusCode::BAD_REQUEST, Json(None)) }, - Ok(res) => (StatusCode::OK, Json(Some(res.try_into_model().expect("All fields should be set once the book is saved")))) + Ok(res) => (StatusCode::CREATED, Json(Some(res.try_into_model().expect("All fields should be set once the book is saved")))) } } diff --git a/src/routes/book_instance.rs b/src/routes/book_instance.rs index ef94481..93b740b 100644 --- a/src/routes/book_instance.rs +++ b/src/routes/book_instance.rs @@ -2,11 +2,11 @@ use std::sync::Arc; use axum::{extract::{Path, State}, Json}; use reqwest::{StatusCode}; -use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, EntityTrait, TryIntoModel}; +use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, EntityTrait, QueryFilter, TryIntoModel}; use serde::{Deserialize, Serialize}; use utoipa::IntoParams; -use crate::{entities::{book_instance, prelude::*}, routes::auth::Claims, utils::auth::{user_is_bal_owner, user_is_book_instance_owner}, AppState}; +use crate::{entities::{book_instance, prelude::*}, routes::auth::Claims, utils::auth::{user_is_bal_owner, user_is_book_instance_owner, user_is_owner_owner}, AppState}; #[derive(IntoParams)] @@ -59,7 +59,7 @@ pub struct BookInstanceCreateParams { request_body = BookInstanceCreateParams, security(("jwt" = [])), responses( - (status = OK, body = book_instance::Model, description = "Successfully created book instance"), + (status = CREATED, body = book_instance::Model, description = "Successfully created book instance"), (status = FORBIDDEN, description = "You don't own the specified book instance"), ), summary = "Create a new book instance", @@ -91,7 +91,7 @@ pub async fn create_book_instance( log::error!(target: "api", "Error while inserting new book instance: {:#?}", e); (StatusCode::BAD_REQUEST, Json(None)) }, - Ok(res) => (StatusCode::OK, Json(Some(res.try_into_model().expect("All fields should be set once the book instance is saved")))) + Ok(res) => (StatusCode::CREATED, Json(Some(res.try_into_model().expect("All fields should be set once the book instance is saved")))) } } @@ -216,7 +216,7 @@ pub async fn sell_book_instance( request_body = Vec, security(("jwt" = [])), responses( - (status = OK, description = "Successfully created book instances"), + (status = CREATED, description = "Successfully created book instances"), (status = FORBIDDEN, description = "You don't own at least one specified bal of the book instances sent"), ), summary = "Create new book instances in bulk", @@ -253,6 +253,45 @@ pub async fn bulk_create_book_instance( log::error!(target: "api", "Error while bulk inserting new book instances: {:#?}", e); StatusCode::INTERNAL_SERVER_ERROR }, - Ok(_) => StatusCode::OK + Ok(_) => StatusCode::CREATED + } +} + +#[derive(IntoParams)] +#[into_params(names("bal_id", "owner_id"), parameter_in = Path)] +#[allow(dead_code)] +struct BalOwnerByIdParams(u32, u32); + +#[axum::debug_handler] +#[utoipa::path( + get, + path = "/bal/{bal_id}/owner/{owner_id}/book_instances", + params(BalOwnerByIdParams), + security(("jwt" = [])), + responses( + (status = OK, body = Vec, description = "Found book instances in the database"), + (status = FORBIDDEN, description = "You do not own the specified owner"), + ), + summary = "Get books instances from an owner in a bal", + description = "Lists all book instances an owner has in a bal. WARNING: If the bal or owner don't exist, the endpoint will return 200/OK with an empty list", + tag = "book-instance-api", +)] +pub async fn get_bal_owner_book_instances( + State(state): State>, + claims: Claims, + Path((bal_id, owner_id)): Path<(u32, u32)>, +) -> (StatusCode, Json>) { + if !user_is_owner_owner(claims.user_id, owner_id, state.db_conn.as_ref()).await { + return (StatusCode::FORBIDDEN, Json(vec![])); + }// If a user owns an owner, it will own the bal the owner has books in, + // so checking for bal ownership is unnecessary + if let Ok(res) = BookInstance::find() + .filter(book_instance::Column::BalId.eq(bal_id)) + .filter(book_instance::Column::OwnerId.eq(owner_id)) + .all(state.db_conn.as_ref()).await + { + (StatusCode::OK, Json(res)) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, Json(vec![])) } } diff --git a/src/routes/owner.rs b/src/routes/owner.rs index d941c7e..2957af1 100644 --- a/src/routes/owner.rs +++ b/src/routes/owner.rs @@ -59,7 +59,7 @@ pub struct OwnerCreateParams { request_body = OwnerCreateParams, security(("jwt" = [])), responses( - (status = OK, body = owner::Model, description = "Successfully created owner"), + (status = CREATED, body = owner::Model, description = "Successfully created owner"), ), summary = "Create a new owner", description = "Create a new owner", @@ -88,7 +88,7 @@ pub async fn create_owner( Ok(res) => { let model = res.try_into_model().expect("All fields should be set once the owner is saved"); let _ = state.event_bus.send(Event::WebsocketBroadcast(WebsocketMessage::NewOwner(Arc::new(model.clone())))); - (StatusCode::OK, Json(Some(model))) + (StatusCode::CREATED, Json(Some(model))) } } } From 3320d1400ce83f7d9c507af6f72720792a5bc728 Mon Sep 17 00:00:00 2001 From: Ninjdai Date: Mon, 4 Aug 2025 13:17:05 +0200 Subject: [PATCH 3/3] replace OpenLibrary calls with BNF calls --- src/routes/book.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/book.rs b/src/routes/book.rs index eb43a97..b6328fc 100644 --- a/src/routes/book.rs +++ b/src/routes/book.rs @@ -6,7 +6,7 @@ use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, EntityT use serde::{Deserialize, Serialize}; use utoipa::IntoParams; -use crate::{entities::{book, prelude::{Book}}, utils::open_library}; +use crate::{entities::{book, prelude::Book}, utils::{bnf}}; use crate::AppState; @@ -72,7 +72,7 @@ pub async fn get_book_by_ean( if let Ok(Some(res)) = Book::find().filter(book::Column::Ean.eq(&ean)).one(state.db_conn.as_ref()).await { (StatusCode::OK, Json(Some(res))) } else { - let fetched_book = open_library::fetch_book_by_ean(&state.web_client, &ean).await; + let fetched_book = bnf::fetch_book_by_ean(&state.web_client, &ean).await; if let Some(book) = fetched_book { let res = Book::insert(book.to_active_model()).exec(state.db_conn.as_ref()).await.unwrap(); (StatusCode::OK, Json(Some(book::Model {