//! 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::Serialize; use utoipa::ToSchema; use crate::{AppState, pagination::Pagination}; /// 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, } /// 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()); // `items` and `total` come from two separate queries; under concurrent // publish/unpublish they can momentarily disagree by one — acceptable for a // public read surface. 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)) }