diff --git a/Cargo.lock b/Cargo.lock index 8c4b4df..c812fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,7 @@ version = "0.0.0" dependencies = [ "axum", "db", + "domain", "http-body-util", "serde", "serde_json", @@ -86,6 +87,7 @@ dependencies = [ "tokio", "tower", "utoipa", + "uuid", ] [[package]] diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 1b4b9c4..742ce8f 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -9,6 +9,8 @@ axum.workspace = true serde.workspace = true utoipa.workspace = true db = { path = "../db" } +domain = { path = "../domain" } +uuid.workspace = true [dev-dependencies] tokio.workspace = true @@ -16,3 +18,4 @@ tower.workspace = true http-body-util.workspace = true serde_json.workspace = true sqlx.workspace = true +domain = { path = "../domain" } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 10ce5c6..ab7c81b 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -2,6 +2,7 @@ mod health; mod openapi; +mod public; use axum::Router; use db::Db; @@ -20,5 +21,6 @@ pub fn build_app(state: AppState) -> Router { Router::new() .merge(health::routes()) .merge(openapi::routes()) + .merge(public::routes()) .with_state(state) } diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 6ef33fc..fcdc6d2 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -1,12 +1,17 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; -use crate::{AppState, health}; +use crate::{AppState, health, public}; #[derive(OpenApi)] #[openapi( - paths(health::live, health::ready), - components(schemas(health::Live, health::Ready)), + paths(health::live, health::ready, public::list_objects, public::get_object), + components(schemas( + health::Live, + health::Ready, + public::PublicView, + public::PublicObjectPage + )), info(title = "Collection Management System", version = "0.0.0") )] struct ApiDoc; diff --git a/crates/api/src/public.rs b/crates/api/src/public.rs new file mode 100644 index 0000000..185d62d --- /dev/null +++ b/crates/api/src/public.rs @@ -0,0 +1,135 @@ +//! Public, unauthenticated, read-only surface (`/api/public/**`). +//! +//! Serves only `public` records as a [`PublicView`] — a projection that carries +//! ONLY public-safe fields. The never-public set (location, owner, recorder, dates, +//! and any flexible fields) is excluded by construction: the type lacks those fields, +//! so leaking one here is impossible. Per-field publishability (to surface selected +//! flexible fields) is post-MVP. + +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use domain::{CatalogueObject, ObjectId}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; + +/// A catalogue object as exposed on the public surface (public-safe fields only). +#[derive(Serialize, ToSchema)] +pub(crate) struct PublicView { + /// Stable object id (UUID). + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, +} + +impl PublicView { + fn from_object(object: &CatalogueObject) -> Self { + PublicView { + id: object.id.to_string(), + object_number: object.object_number.clone(), + object_name: object.object_name.clone(), + brief_description: object.brief_description.clone(), + } + } +} + +/// A page of public objects. +#[derive(Serialize, ToSchema)] +pub(crate) struct PublicObjectPage { + pub items: Vec, + /// Total number of public objects (independent of paging). + pub total: i64, + pub limit: i64, + pub offset: i64, +} + +/// Pagination query parameters with sane defaults and a hard cap. +#[derive(Deserialize)] +pub(crate) struct Pagination { + limit: Option, + offset: Option, +} + +const DEFAULT_LIMIT: i64 = 50; +const MAX_LIMIT: i64 = 200; + +impl Pagination { + fn limit(&self) -> i64 { + self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) + } + + fn offset(&self) -> i64 { + self.offset.unwrap_or(0).max(0) + } +} + +/// List public objects (paginated). +#[utoipa::path( + get, + path = "/api/public/objects", + params( + ("limit" = Option, Query, description = "Max items (1..=200, default 50)"), + ("offset" = Option, Query, description = "Items to skip (default 0)") + ), + responses((status = 200, body = PublicObjectPage)) +)] +pub(crate) async fn list_objects( + State(state): State, + Query(page): Query, +) -> Result, StatusCode> { + let (limit, offset) = (page.limit(), page.offset()); + + let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let total = db::catalog::count_public_objects(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(PublicObjectPage { + items: objects.iter().map(PublicView::from_object).collect(), + total, + limit, + offset, + })) +} + +/// Get one public object by id. Returns 404 if missing OR not public. +#[utoipa::path( + get, + path = "/api/public/objects/{id}", + params(("id" = String, Path, description = "Object id (UUID)")), + responses( + (status = 200, body = PublicView), + (status = 404, description = "No public object with that id") + ) +)] +pub(crate) async fn get_object( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let Ok(object_id) = id.parse::() else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match db::catalog::public_object_by_id(state.db.pool(), object_id).await { + Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// Public routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new() + .route("/api/public/objects", get(list_objects)) + .route("/api/public/objects/{id}", get(get_object)) +} diff --git a/crates/api/tests/public.rs b/crates/api/tests/public.rs new file mode 100644 index 0000000..3aa8720 --- /dev/null +++ b/crates/api/tests/public.rs @@ -0,0 +1,166 @@ +use api::{AppState, build_app}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use db::catalog; +use domain::{AuditActor, ObjectInput, Visibility}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; // for `oneshot` + +fn state(pool: PgPool) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".to_string(), + } +} + +fn object(number: &str, name: &str, visibility: Visibility) -> ObjectInput { + ObjectInput { + object_number: number.into(), + object_name: name.into(), + number_of_objects: 1, + brief_description: Some("a description".into()), + current_location: Some("vault B".into()), // never-public; must NOT appear in output + current_owner: Some("the museum".into()), // never-public + recorder: None, + recording_date: None, + visibility, + } +} + +async fn body_json(resp: axum::http::Response) -> serde_json::Value { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn list_returns_only_public_as_public_view(pool: PgPool) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + catalog::create_object( + &mut tx, + AuditActor::System, + &object("D-1", "draft vase", Visibility::Draft), + ) + .await + .unwrap(); + catalog::create_object( + &mut tx, + AuditActor::System, + &object("P-1", "public vase", Visibility::Public), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app + .oneshot( + Request::builder() + .uri("/api/public/objects") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + assert_eq!(json["total"], 1); + assert_eq!(json["items"].as_array().unwrap().len(), 1); + let item = &json["items"][0]; + assert_eq!(item["object_number"], "P-1"); + assert_eq!(item["object_name"], "public vase"); + assert_eq!(item["brief_description"], "a description"); + assert!(item.get("current_location").is_none()); + assert!(item.get("current_owner").is_none()); + assert!(item.get("recorder").is_none()); + assert!(item.get("visibility").is_none()); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn get_public_object_returns_it(pool: PgPool) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &object("P-1", "public vase", Visibility::Public), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app + .oneshot( + Request::builder() + .uri(format!("/api/public/objects/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = body_json(resp).await; + assert_eq!(json["object_number"], "P-1"); + assert!(json.get("current_location").is_none()); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn get_non_public_object_is_404(pool: PgPool) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &object("D-1", "draft vase", Visibility::Draft), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app + .oneshot( + Request::builder() + .uri(format!("/api/public/objects/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); // not 403 — don't leak existence +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn get_missing_object_is_404(pool: PgPool) { + let app = build_app(state(pool)); + let resp = app + .oneshot( + Request::builder() + .uri(format!("/api/public/objects/{}", domain::ObjectId::new())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn openapi_lists_the_public_paths(pool: PgPool) { + let app = build_app(state(pool)); + let resp = app + .oneshot( + Request::builder() + .uri("/api-docs/openapi.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let json = body_json(resp).await; + assert!(json["paths"]["/api/public/objects"].is_object()); + assert!(json["paths"]["/api/public/objects/{id}"].is_object()); +}