Files
biggus-dickus/docs/plans/2026-06-02-publishing-public-api.md

30 KiB
Raw Permalink Blame History

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).

  • domainVisibility::transition_to / can_transition_to + an IllegalTransition error (the state machine).
  • dbset_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: domainVisibility 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:
    #[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.)

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<Visibility, IllegalTransition> {
        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:

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.

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:
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=<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):

use domain::{
    AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
    NewAuditEvent, ObjectId, ObjectInput, Visibility,
};

Add the visibility-eligible constant next to the existing ENTITY_TYPE const:

/// 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):

/// 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):

/// 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<Option<CatalogueObject>, 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<Vec<CatalogueObject>, 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<i64, sqlx::Error>
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=<url> cargo test -p db --test visibility → PASS (5 tests).

  • Step 5: Lint. cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings → clean.

  • Step 6: Commit.

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):
domain = { path = "../domain" }
uuid = { workspace = true }

Add to [dev-dependencies] (the handler tests seed objects through db repos, which need domain types):

domain = { path = "../domain" }
  • Step 2: Write the failing test crates/api/tests/public.rs:
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<Body>) -> 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=<url> cargo test -p api --test public → FAIL (public module / routes missing).

  • Step 4: Implement crates/api/src/public.rs:

//! 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<String>,
}

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<PublicView>,
    /// 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<i64>,
    offset: Option<i64>,
}

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<i64>, Query, description = "Max items (1..=200, default 50)"),
        ("offset" = Option<i64>, Query, description = "Items to skip (default 0)")
    ),
    responses((status = 200, body = PublicObjectPage))
)]
pub(crate) async fn list_objects(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Result<Json<PublicObjectPage>, 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<AppState>,
    Path(id): Path<String>,
) -> impl IntoResponse {
    let Ok(object_id) = id.parse::<ObjectId>() 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<AppState> {
    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:

mod health;
mod openapi;
mod public;
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:

use crate::{AppState, health, public};
#[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=<url> cargo test -p api --test public → PASS (5 tests). Re-run the existing health test too: DATABASE_URL=<url> cargo test -p api → all PASS.

  • Step 6: Full workspace check.

cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace

Expected: all green. (search tests need the MEILI env vars; the rest need DATABASE_URL.)

  • Step 7: Commit.
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 23. ✓
  • 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. <url>/<key> are the documented env values.

Type consistency: Visibility::{transition_to, can_transition_to} + IllegalTransition defined in Task 1 and consumed in Tasks 23; 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<Cap> 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.