diff --git a/Cargo.lock b/Cargo.lock index 8c4b4df..67e33f1 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", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 1b4b9c4..5eda5af 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -9,6 +9,7 @@ axum.workspace = true serde.workspace = true utoipa.workspace = true db = { path = "../db" } +domain = { path = "../domain" } [dev-dependencies] tokio.workspace = true 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..3c61db5 --- /dev/null +++ b/crates/api/src/public.rs @@ -0,0 +1,138 @@ +//! 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()); + + // `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)) +} diff --git a/crates/api/tests/public.rs b/crates/api/tests/public.rs new file mode 100644 index 0000000..a3a3c0e --- /dev/null +++ b/crates/api/tests/public.rs @@ -0,0 +1,177 @@ +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 non_public_objects_are_404(pool: PgPool) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let draft = catalog::create_object( + &mut tx, + AuditActor::System, + &object("D-1", "draft vase", Visibility::Draft), + ) + .await + .unwrap(); + let internal = catalog::create_object( + &mut tx, + AuditActor::System, + &object("I-1", "internal vase", Visibility::Internal), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // both non-public states are hidden behind a 404 — not 403 — so existence isn't leaked + let app = build_app(state(pool)); + for id in [draft, internal] { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/public/objects/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} + +#[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()); +} diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index e26bbe5..f09d243 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -2,8 +2,8 @@ //! on the caller's connection, so the change and its audit entry commit together. use domain::{ - AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId, - ObjectInput, Visibility, + AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition, + NewAuditEvent, ObjectId, ObjectInput, Visibility, }; use serde_json::{Value, json}; use sqlx::Row; @@ -13,6 +13,9 @@ use crate::{audit, authority, fields, vocab}; /// The entity_type recorded in the audit log for catalogue objects. const ENTITY_TYPE: &str = "object"; +/// The visibility value eligible for the public surface. +const PUBLIC_VISIBILITY: &str = Visibility::Public.as_str(); + const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \ brief_description, current_location, current_owner, recorder, recording_date, \ visibility, fields, created_at, updated_at"; @@ -93,6 +96,66 @@ where rows.into_iter().map(map_object).collect() } +/// 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. +/// +/// `limit` and `offset` must be non-negative (Postgres rejects a negative `LIMIT`); +/// the public API layer clamps them before calling. +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") +} + fn map_object(row: sqlx::postgres::PgRow) -> Result { let visibility_str: String = row.try_get("visibility")?; let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| { @@ -189,10 +252,25 @@ pub async fn update_object( return Ok(false); }; - let changes = update_changes(&old.to_input(), input); + apply_object_update(&mut *conn, actor, id, &old.to_input(), input).await?; + + Ok(true) +} + +/// Diff `old`→`new`, write the changed columns + an `updated` audit entry, both on +/// `conn`. A no-op (no field changed) touches neither the row's `updated_at` nor the +/// audit log. +async fn apply_object_update( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: ObjectId, + old: &ObjectInput, + new: &ObjectInput, +) -> Result<(), sqlx::Error> { + let changes = update_changes(old, new); + if changes.is_empty() { - // No-op: don't touch updated_at or the audit log. - return Ok(true); + return Ok(()); } sqlx::query( @@ -203,15 +281,15 @@ pub async fn update_object( WHERE id = $1", ) .bind(id.to_uuid()) - .bind(&input.object_number) - .bind(&input.object_name) - .bind(input.number_of_objects) - .bind(input.brief_description.as_deref()) - .bind(input.current_location.as_deref()) - .bind(input.current_owner.as_deref()) - .bind(input.recorder.as_deref()) - .bind(input.recording_date) - .bind(input.visibility.as_str()) + .bind(&new.object_number) + .bind(&new.object_name) + .bind(new.number_of_objects) + .bind(new.brief_description.as_deref()) + .bind(new.current_location.as_deref()) + .bind(new.current_owner.as_deref()) + .bind(new.recorder.as_deref()) + .bind(new.recording_date) + .bind(new.visibility.as_str()) .execute(&mut *conn) .await?; @@ -227,7 +305,43 @@ pub async fn update_object( ) .await?; - Ok(true) + Ok(()) +} + +/// 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. Uses the same diff/audit path as `update_object`, 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. +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 old_input = object.to_input(); + let mut new_input = old_input.clone(); + + new_input.visibility = new_visibility; + apply_object_update(&mut *conn, actor, id, &old_input, &new_input).await?; + + Ok(()) } /// Delete an object and record a `deleted` audit entry, both on `conn`. diff --git a/crates/db/tests/visibility.rs b/crates/db/tests/visibility.rs new file mode 100644 index 0000000..37a2d64 --- /dev/null +++ b/crates/db/tests/visibility.rs @@ -0,0 +1,186 @@ +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); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 3); // created + two visibility updates + 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(); + let internal = catalog::create_object( + &mut tx, + AuditActor::System, + &object("I-1", Visibility::Internal), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + 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() + ); + + 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); + + assert!( + catalog::list_public_objects(db.pool(), 50, 1) + .await + .unwrap() + .is_empty() + ); + + // internal records are excluded from public reads too (not just draft) + assert!( + catalog::public_object_by_id(db.pool(), internal) + .await + .unwrap() + .is_none() + ); +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 509f870..e3a4dbe 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -13,5 +13,5 @@ pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId}; pub use label::{LocalizedLabel, pick_label}; -pub use object::{CatalogueObject, ObjectInput, Visibility}; +pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; diff --git a/crates/domain/src/object.rs b/crates/domain/src/object.rs index b8d1f47..eb3ded6 100644 --- a/crates/domain/src/object.rs +++ b/crates/domain/src/object.rs @@ -17,7 +17,7 @@ pub enum Visibility { } impl Visibility { - pub fn as_str(&self) -> &'static str { + pub const fn as_str(&self) -> &'static str { match self { Visibility::Draft => "draft", Visibility::Internal => "internal", @@ -35,6 +35,52 @@ impl Visibility { } } +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 {} + /// The mutable inventory-minimum fields of a catalogue object. #[derive(Debug, Clone, PartialEq)] pub struct ObjectInput { @@ -107,4 +153,44 @@ mod tests { ); } } + + #[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 + }) + ); + // the Display message is the user-visible surface of the error + assert_eq!( + Draft.transition_to(Public).unwrap_err().to_string(), + "illegal visibility transition: draft -> public" + ); + } + + #[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)); + } + } }