From 5ea1febb9161328ebf9d3502569cd7f9796e5867 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 12:50:31 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20publishing=20=E2=80=94=20visibil?= =?UTF-8?q?ity=20transitions,=20PublicView,=20public=20read=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-02-publishing-public-api.md | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 docs/plans/2026-06-02-publishing-public-api.md diff --git a/docs/plans/2026-06-02-publishing-public-api.md b/docs/plans/2026-06-02-publishing-public-api.md new file mode 100644 index 0000000..448f235 --- /dev/null +++ b/docs/plans/2026-06-02-publishing-public-api.md @@ -0,0 +1,777 @@ +# Publishing: Visibility Transitions, PublicView & Public Read API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the publishing pillar on: a type-driven `Visibility` state machine (stepwise `draft↔internal↔public`), an audited `db` transition + public-only reads, and the first real domain HTTP surface — an unauthenticated, read-only **public API** (`/api/public/objects`) that serves only `public` records as a leak-proof `PublicView` projection. + +**Architecture:** Three layers, each testable in isolation (no auth needed — the public surface is unauthenticated by definition; the admin HTTP endpoint that *triggers* transitions waits for the auth phase, same "build capability now, wire surface later" pattern used for search). +- `domain` — `Visibility::transition_to` / `can_transition_to` + an `IllegalTransition` error (the state machine). +- `db` — `set_visibility` (validates via the domain machine, reuses `update_object`'s diff/audit path) + `public_object_by_id` / `list_public_objects` / `count_public_objects` (filter `visibility = 'public'` in SQL). +- `api` — a `PublicView` response DTO (carries only public-safe fields, so leaking an internal field is structurally impossible) + `/api/public/objects` (paginated list) and `/api/public/objects/{id}` (404 for missing **or** non-public, so non-public existence isn't revealed), registered in the OpenAPI doc. + +**Tech Stack:** Rust 2024, axum 0.8, sqlx 0.8, utoipa 5, serde, thiserror. Tests: `#[sqlx::test]` (db) and axum `oneshot` over `#[sqlx::test]` (api). + +## Design decisions (approved) +- **PublicView is core-only for MVP:** `id`, `object_number`, `object_name`, `brief_description`. **No flexible fields, no location/owner/recorder/dates.** Per-field publishability (which would let flexible fields surface selectively) is post-MVP; until then the projection type simply lacks the unsafe fields. +- **Stepwise transitions:** legal single steps are `draft↔internal` and `internal↔public` only. `draft→public` (and `public→draft`) in one jump is illegal. Setting visibility to its current value is an idempotent no-op (`Ok`). +- **Transitions land in `domain` + `db` only** this phase. The admin HTTP endpoint to invoke them arrives with auth (later phase). +- **Public-facing search is post-MVP** (arch spec §12) — this plan adds no public search endpoint; public list is a `db` query. +- **404, not 403,** for a non-public record on the public surface (don't leak existence). + +## Prerequisites +- Postgres for tests; pass `DATABASE_URL` inline. Pass transaction connections as `&mut tx` (NOT `&mut *tx`). +- `cargo +nightly fmt` (nightly). `cargo clippy --all-targets -- -D warnings` must stay clean. +- The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers. + +## File Structure +``` +crates/domain/src/object.rs + IllegalTransition, Visibility::{can_transition_to, transition_to}, tests +crates/domain/src/lib.rs + export IllegalTransition +crates/db/src/catalog.rs + VisibilityError, set_visibility, public_object_by_id, + list_public_objects, count_public_objects +crates/db/tests/visibility.rs (new) transition rules + audit + public-read filtering +crates/api/Cargo.toml + domain, uuid deps +crates/api/src/public.rs (new) PublicView, Pagination, PublicObjectPage, handlers, routes +crates/api/src/lib.rs + mod public; merge public::routes() +crates/api/src/openapi.rs + register public paths + schemas +crates/api/tests/public.rs (new) list/get handler tests (incl. leak + 404 assertions) +``` + +--- + +## Task 1: `domain` — `Visibility` state machine + +**Files:** modify `crates/domain/src/object.rs`, `crates/domain/src/lib.rs`. + +- [ ] **Step 1: Write the failing tests.** Add to the `#[cfg(test)] mod tests` in `crates/domain/src/object.rs`: +```rust + #[test] + fn stepwise_transitions_are_legal() { + use Visibility::*; + assert_eq!(Draft.transition_to(Internal), Ok(Internal)); + assert_eq!(Internal.transition_to(Public), Ok(Public)); + assert_eq!(Public.transition_to(Internal), Ok(Internal)); + assert_eq!(Internal.transition_to(Draft), Ok(Draft)); + } + + #[test] + fn skipping_a_step_is_illegal() { + use Visibility::*; + assert_eq!( + Draft.transition_to(Public), + Err(IllegalTransition { from: Draft, to: Public }) + ); + assert_eq!( + Public.transition_to(Draft), + Err(IllegalTransition { from: Public, to: Draft }) + ); + } + + #[test] + fn setting_to_current_value_is_a_noop_ok() { + for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] { + assert_eq!(v.transition_to(v), Ok(v)); + } + } +``` + +- [ ] **Step 2: Run to verify it fails.** `cargo test -p domain` → FAIL (`transition_to` / `IllegalTransition` missing). + +- [ ] **Step 3: Implement.** In `crates/domain/src/object.rs`, after the `impl Visibility` block (the existing one with `as_str`/`from_db`), add the transition API and the error type. (domain has no `thiserror` dependency — implement `Display`/`Error` by hand to keep the core dependency-free.) +```rust +impl Visibility { + /// Whether `self` may move directly to `target`. Legal single steps are + /// `draft↔internal` and `internal↔public`; `draft↔public` is not one step. + pub fn can_transition_to(self, target: Visibility) -> bool { + use Visibility::*; + matches!( + (self, target), + (Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal) + ) + } + + /// Validate a stepwise transition to `target`. Setting to the current value is an + /// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`]. + pub fn transition_to(self, target: Visibility) -> Result { + if self == target || self.can_transition_to(target) { + Ok(target) + } else { + Err(IllegalTransition { from: self, to: target }) + } + } +} + +/// An attempted visibility change the state machine forbids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IllegalTransition { + pub from: Visibility, + pub to: Visibility, +} + +impl std::fmt::Display for IllegalTransition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "illegal visibility transition: {} -> {}", + self.from.as_str(), + self.to.as_str() + ) + } +} + +impl std::error::Error for IllegalTransition {} +``` +In `crates/domain/src/lib.rs`, extend the object re-export: +```rust +pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; +``` + +- [ ] **Step 4: Run to verify it passes.** `cargo test -p domain` → PASS. + +- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean. + +- [ ] **Step 6: Commit.** +```bash +git add crates/domain +git commit -m "feat(domain): stepwise Visibility state machine (transition_to + IllegalTransition)" +``` + +--- + +## Task 2: `db` — audited visibility transition + public reads + +**Files:** modify `crates/db/src/catalog.rs`; create `crates/db/tests/visibility.rs`. + +- [ ] **Step 1: Write the failing tests** `crates/db/tests/visibility.rs`: +```rust +use db::{Db, audit, catalog}; +use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility}; +use sqlx::PgPool; + +fn object(number: &str, visibility: Visibility) -> ObjectInput { + ObjectInput { + object_number: number.into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility, + } +} + +#[sqlx::test] +async fn publish_steps_through_internal_and_audits(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft)) + .await + .unwrap(); + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal) + .await + .unwrap(); + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Public); + + // created + two visibility updates + let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap(); + assert_eq!(history.len(), 3); + assert_eq!(history[2].action, AuditAction::Updated); + let changed: Vec<&str> = history[2].changes.iter().map(|c| c.field.as_str()).collect(); + assert_eq!(changed, vec!["visibility"]); +} + +#[sqlx::test] +async fn skipping_a_step_is_rejected_and_unchanged(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap_err(); + tx.commit().await.unwrap(); + assert!(matches!( + err, + catalog::VisibilityError::Illegal(IllegalTransition { + from: Visibility::Draft, + to: Visibility::Public + }) + )); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Draft); // unchanged +} + +#[sqlx::test] +async fn set_visibility_on_missing_object_errors(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let err = catalog::set_visibility( + &mut tx, + AuditActor::System, + domain::ObjectId::new(), + Visibility::Internal, + ) + .await + .unwrap_err(); + tx.commit().await.unwrap(); + assert!(matches!(err, catalog::VisibilityError::ObjectNotFound)); +} + +#[sqlx::test] +async fn no_op_set_to_current_visibility_writes_no_audit(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft)) + .await + .unwrap(); + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap(); + assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing +} + +#[sqlx::test] +async fn public_reads_return_only_public_records(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let draft = catalog::create_object(&mut tx, AuditActor::System, &object("D-1", Visibility::Draft)) + .await + .unwrap(); + let pub_id = + catalog::create_object(&mut tx, AuditActor::System, &object("P-1", Visibility::Public)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // by-id: public visible, draft hidden + assert!(catalog::public_object_by_id(db.pool(), pub_id).await.unwrap().is_some()); + assert!(catalog::public_object_by_id(db.pool(), draft).await.unwrap().is_none()); + + // list + count: only the public one + let listed = catalog::list_public_objects(db.pool(), 50, 0).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, pub_id); + assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1); + + // paging: offset past the end yields nothing + assert!(catalog::list_public_objects(db.pool(), 50, 1).await.unwrap().is_empty()); +} +``` + +- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL= cargo test -p db --test visibility` → FAIL (`set_visibility` / `VisibilityError` / public readers missing). + +- [ ] **Step 3: Implement** in `crates/db/src/catalog.rs`. + + Extend the `domain` import (add `IllegalTransition`): +```rust +use domain::{ + AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition, + NewAuditEvent, ObjectId, ObjectInput, Visibility, +}; +``` + + Add the visibility-eligible constant next to the existing `ENTITY_TYPE` const: +```rust +/// The visibility value eligible for the public surface. +const PUBLIC_VISIBILITY: &str = "public"; +``` + + Add the error type and `set_visibility` (place after `update_object`, before `delete_object`): +```rust +/// Why changing an object's visibility failed. +#[derive(Debug, thiserror::Error)] +pub enum VisibilityError { + #[error("object not found")] + ObjectNotFound, + #[error(transparent)] + Illegal(#[from] IllegalTransition), + #[error(transparent)] + Db(#[from] sqlx::Error), +} + +/// Move an object to `target` visibility, enforcing the stepwise state machine, and +/// audit the change. Reuses [`update_object`]'s diff/audit path, so only `visibility` +/// appears in the audit entry — and setting to the current value is an idempotent no-op +/// (no row touch, no audit). Pass a transaction connection (`&mut tx`). +pub async fn set_visibility( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: ObjectId, + target: Visibility, +) -> Result<(), VisibilityError> { + let Some(object) = object_by_id(&mut *conn, id).await? else { + return Err(VisibilityError::ObjectNotFound); + }; + let new_visibility = object.visibility.transition_to(target)?; + + let mut input = object.to_input(); + input.visibility = new_visibility; + update_object(&mut *conn, actor, id, &input).await?; + Ok(()) +} +``` + + Add the public readers (place after `list_objects`): +```rust +/// Fetch one **public** object by id. Returns `None` if the object is missing **or** +/// not public — callers map both to 404 so non-public existence isn't revealed. +pub async fn public_object_by_id<'e, E>( + executor: E, + id: ObjectId, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2"); + let row = sqlx::query(&sql) + .bind(id.to_uuid()) + .bind(PUBLIC_VISIBILITY) + .fetch_optional(executor) + .await?; + row.map(map_object).transpose() +} + +/// List **public** objects ordered by object number, with `limit`/`offset` paging. +pub async fn list_public_objects<'e, E>( + executor: E, + limit: i64, + offset: i64, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \ + ORDER BY object_number LIMIT $2 OFFSET $3" + ); + let rows = sqlx::query(&sql) + .bind(PUBLIC_VISIBILITY) + .bind(limit) + .bind(offset) + .fetch_all(executor) + .await?; + rows.into_iter().map(map_object).collect() +} + +/// Count all public objects (for pagination totals). +pub async fn count_public_objects<'e, E>(executor: E) -> Result +where + E: sqlx::PgExecutor<'e>, +{ + let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1") + .bind(PUBLIC_VISIBILITY) + .fetch_one(executor) + .await?; + row.try_get("n") +} +``` + +- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL= cargo test -p db --test visibility` → PASS (5 tests). + +- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `DATABASE_URL= cargo clippy -p db --all-targets -- -D warnings` → clean. + +- [ ] **Step 6: Commit.** +```bash +git add crates/db +git commit -m "feat(db): audited stepwise set_visibility + public-only object readers" +``` + +--- + +## Task 3: `api` — public read API (`PublicView` + routes + OpenAPI) + +**Files:** modify `crates/api/Cargo.toml`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; create `crates/api/src/public.rs`, `crates/api/tests/public.rs`. + +- [ ] **Step 1: Cargo deps.** In `crates/api/Cargo.toml` `[dependencies]`, add `domain` and `uuid` (the projection consumes `domain::CatalogueObject`; the path handler parses a UUID): +```toml +domain = { path = "../domain" } +uuid = { workspace = true } +``` +Add to `[dev-dependencies]` (the handler tests seed objects through `db` repos, which need `domain` types): +```toml +domain = { path = "../domain" } +``` + +- [ ] **Step 2: Write the failing test** `crates/api/tests/public.rs`: +```rust +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] +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"); + // never-public fields must be structurally absent + 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] +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] +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] +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] +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()); +} +``` + +- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL= cargo test -p api --test public` → FAIL (`public` module / routes missing). + +- [ ] **Step 4: Implement** `crates/api/src/public.rs`: +```rust +//! 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)) +} +``` +NOTE: axum 0.8 path syntax is `{id}` (braces), matching the existing routes. `ObjectId: FromStr` exists (id macro). `state.db.pool()` returns the `&PgPool` (used by the health readiness handler too). + + In `crates/api/src/lib.rs`, declare the module and merge its routes: +```rust +mod health; +mod openapi; +mod public; +``` +```rust +pub fn build_app(state: AppState) -> Router { + Router::new() + .merge(health::routes()) + .merge(openapi::routes()) + .merge(public::routes()) + .with_state(state) +} +``` + + In `crates/api/src/openapi.rs`, register the public paths + schemas. Update the imports and the `#[openapi(...)]` attribute: +```rust +use crate::{AppState, health, public}; +``` +```rust +#[derive(OpenApi)] +#[openapi( + 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; +``` + +- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL= cargo test -p api --test public` → PASS (5 tests). Re-run the existing `health` test too: `DATABASE_URL= cargo test -p api` → all PASS. + +- [ ] **Step 6: Full workspace check.** +```bash +cargo +nightly fmt --check +DATABASE_URL= cargo clippy --workspace --all-targets -- -D warnings +DATABASE_URL= MEILI_URL= MEILI_MASTER_KEY= cargo test --workspace +``` +Expected: all green. (`search` tests need the MEILI env vars; the rest need `DATABASE_URL`.) + +- [ ] **Step 7: Commit.** +```bash +git add crates/api +git commit -m "feat(api): public read API (PublicView projection, paginated list + get, OpenAPI)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage (VISION "Publishing & public access" [MVP]; arch spec §7, §9, §14):** +- Record-level visibility draft/internal/public with a type-driven state machine → Task 1 (`transition_to`/`IllegalTransition`). ✓ +- Fixed never-public field set; public API serves only public records via `PublicView` → Task 3 (`PublicView` carries only safe fields; db filters `visibility='public'`). ✓ +- Public surface `/api/public/**`, unauthenticated, read-only, OpenAPI (utoipa) → Task 3. ✓ +- All SQL stays in `db`; `api` calls repos → Tasks 2–3. ✓ +- Audited writes (visibility change in the amendment history) → Task 2 reuses `update_object`'s audit. ✓ +- 404 (not 403) for non-public → Task 3 handler + test. ✓ + +**Placeholder scan:** none. ``/`` are the documented env values. + +**Type consistency:** `Visibility::{transition_to, can_transition_to}` + `IllegalTransition` defined in Task 1 and consumed in Tasks 2–3; `set_visibility`/`VisibilityError`/`public_object_by_id`/`list_public_objects`/`count_public_objects` defined in Task 2 and consumed by Task 3 handlers; `PublicView`/`PublicObjectPage`/`Pagination` defined and used consistently within Task 3; reuses existing `catalog::{create_object, object_by_id, update_object, OBJECT_COLUMNS, map_object}`, `audit::history_for`, `AppState`, `db.pool()`, and the axum `{id}` path convention. + +## Notes for follow-on plans +- **Admin transition endpoint + auth:** the HTTP surface to *invoke* `set_visibility` (publish/unpublish) is a privileged write — it lands with the auth phase via an `Authorized` extractor. `domain` may then add ergonomic `publish()`/`unpublish()` wrappers over `transition_to` (omitted now to avoid dead code). +- **Required-field completeness on publish:** `set_object_fields` defers required-completeness to "the publish gate" (see `catalog.rs` doc comment). A future gate should validate that all `required` field definitions are present before allowing `→ Public`. **File a gitea follow-up.** +- **On-write search sync:** when `set_visibility` / catalogue writes commit, the API/service layer should re-index (`index_object`) or drop from the index — relates to the Plan 6 deferred on-write sync. +- **Per-field publishability (post-MVP):** replaces the core-only `PublicView` with a registry-driven projection that can surface selected flexible fields. +- **Keyset pagination:** `list_public_objects` uses `LIMIT/OFFSET` (fine for MVP). Switch to keyset when collections grow (the same TODO already noted on `list_objects`). +- **Public-facing search (post-MVP):** the `search` crate already stores `visibility` as filterable; add a `with_filter("visibility = public")` variant when public search is built.