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

778 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
```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=<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<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.**
```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<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`:
```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<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:
```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=<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.**
```bash
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.**
```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 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.