merge: tier 3 typed-client (#3 #24 #29)

Decision #3 = Option A (utoipa::ToSchema allowed in domain, no I/O deps).
domain: ToSchema on Visibility/AuthorityKind + new DataType enum.
api: open-map fields (#24); enum value_types for visibility/data_type/kind (#29);
domain enums registered in OpenAPI; client regenerated. Frontend: dropped the
now-redundant fields/visibility casts. Wire format unchanged; schema diff additive.

domain 26 + api 41 + web 78 tests; bundle 145.5 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 20:25:54 +02:00
13 changed files with 77 additions and 17 deletions
-1
View File
@@ -32,7 +32,6 @@ pub(crate) struct UserView {
/// Desired visibility for a publish/unpublish request. /// Desired visibility for a publish/unpublish request.
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub(crate) struct VisibilityRequest { pub(crate) struct VisibilityRequest {
#[schema(value_type = String)]
pub visibility: Visibility, pub visibility: Visibility,
} }
+1
View File
@@ -20,6 +20,7 @@ use crate::{
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(crate) struct AuthorityView { pub(crate) struct AuthorityView {
pub id: String, pub id: String,
#[schema(value_type = domain::AuthorityKind)]
pub kind: String, pub kind: String,
pub external_uri: Option<String>, pub external_uri: Option<String>,
pub labels: Vec<LabelView>, pub labels: Vec<LabelView>,
+4 -2
View File
@@ -40,9 +40,10 @@ pub(crate) struct AdminObjectView {
/// `YYYY-MM-DD` or null. /// `YYYY-MM-DD` or null.
pub recording_date: Option<String>, pub recording_date: Option<String>,
/// "draft" | "internal" | "public". /// "draft" | "internal" | "public".
#[schema(value_type = domain::Visibility)]
pub visibility: String, pub visibility: String,
/// Flexible field values (key -> value). /// Flexible field values (key -> value).
#[schema(value_type = Object)] #[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
pub fields: serde_json::Value, pub fields: serde_json::Value,
} }
@@ -162,7 +163,6 @@ pub(crate) struct ObjectCreateRequest {
pub recorder: Option<String>, pub recorder: Option<String>,
pub recording_date: Option<String>, pub recording_date: Option<String>,
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint). /// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
#[schema(value_type = String)]
pub visibility: Visibility, pub visibility: Visibility,
} }
@@ -360,8 +360,10 @@ pub(crate) async fn delete_object(
pub(crate) struct FieldDefinitionView { pub(crate) struct FieldDefinitionView {
pub key: String, pub key: String,
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". /// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
#[schema(value_type = domain::DataType)]
pub data_type: String, pub data_type: String,
pub vocabulary_id: Option<String>, pub vocabulary_id: Option<String>,
#[schema(value_type = Option<domain::AuthorityKind>)]
pub authority_kind: Option<String>, pub authority_kind: Option<String>,
pub required: bool, pub required: bool,
pub group: Option<String>, pub group: Option<String>,
+4 -1
View File
@@ -59,7 +59,10 @@ use crate::{
admin_search::SearchHitView, admin_search::SearchHitView,
admin_search::SearchResultsView, admin_search::SearchResultsView,
admin_authorities::AuthorityView, admin_authorities::AuthorityView,
admin_authorities::NewAuthorityRequest admin_authorities::NewAuthorityRequest,
domain::Visibility,
domain::AuthorityKind,
domain::DataType
)), )),
info(title = "Collection Management System", version = "0.0.0") info(title = "Collection Management System", version = "0.0.0")
)] )]
+1
View File
@@ -9,3 +9,4 @@ uuid.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
time.workspace = true time.workspace = true
utoipa.workspace = true
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::{AuthorityId, LocalizedLabel};
/// NOTE: kept in sync by hand with the /// NOTE: kept in sync by hand with the
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in /// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places. /// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AuthorityKind { pub enum AuthorityKind {
Person, Person,
+31
View File
@@ -74,6 +74,23 @@ impl FieldType {
} }
} }
/// The stored `data_type` discriminant of a field definition — mirrors the strings from
/// [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
/// closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum DataType {
Text,
LocalizedText,
Integer,
Date,
Boolean,
Term,
Authority,
}
/// A registered flexible field, with its multilingual display labels. /// A registered flexible field, with its multilingual display labels.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldDefinition { pub struct FieldDefinition {
@@ -152,4 +169,18 @@ mod tests {
); );
assert_eq!(FieldType::from_parts("authority", Some(v), None), None); assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
} }
#[test]
fn data_type_serde_matches_kind_str() {
use serde_json::json;
assert_eq!(
serde_json::to_value(DataType::LocalizedText).unwrap(),
json!("localized_text")
);
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
assert_eq!(
serde_json::to_value(DataType::Authority).unwrap(),
json!("authority")
);
}
} }
+1 -1
View File
@@ -11,7 +11,7 @@ mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; pub use field_definition::{DataType, FieldDefinition, FieldType, NewFieldDefinition};
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId}; pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
pub use label::{LocalizedLabel, pick_label}; pub use label::{LocalizedLabel, pick_label};
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
+1 -1
View File
@@ -4,7 +4,7 @@ use time::{Date, OffsetDateTime};
use crate::ObjectId; use crate::ObjectId;
/// Publication state of a catalogue record. /// Publication state of a catalogue record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Visibility { pub enum Visibility {
/// Work in progress; not shown anywhere public. /// Work in progress; not shown anywhere public.
+30 -7
View File
@@ -326,7 +326,9 @@ export interface components {
current_location?: string | null; current_location?: string | null;
current_owner?: string | null; current_owner?: string | null;
/** @description Flexible field values (key -> value). */ /** @description Flexible field values (key -> value). */
fields: Record<string, never>; fields: {
[key: string]: unknown;
};
id: string; id: string;
/** Format: int32 */ /** Format: int32 */
number_of_objects: number; number_of_objects: number;
@@ -336,12 +338,21 @@ export interface components {
/** @description `YYYY-MM-DD` or null. */ /** @description `YYYY-MM-DD` or null. */
recording_date?: string | null; recording_date?: string | null;
/** @description "draft" | "internal" | "public". */ /** @description "draft" | "internal" | "public". */
visibility: string; visibility: components["schemas"]["Visibility"];
}; };
/**
* @description The kind of authority record.
*
* NOTE: kept in sync by hand with the
* `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
* `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
* @enum {string}
*/
AuthorityKind: "person" | "organisation" | "place";
AuthorityView: { AuthorityView: {
external_uri?: string | null; external_uri?: string | null;
id: string; id: string;
kind: string; kind: components["schemas"]["AuthorityKind"];
labels: components["schemas"]["LabelView"][]; labels: components["schemas"]["LabelView"][];
}; };
CreatedField: { CreatedField: {
@@ -354,11 +365,18 @@ export interface components {
CreatedObject: { CreatedObject: {
id: string; id: string;
}; };
/**
* @description The stored `data_type` discriminant of a field definition — mirrors the strings from
* [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
* closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
* @enum {string}
*/
DataType: "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority";
/** @description Field-definition descriptor for the UI to render forms. */ /** @description Field-definition descriptor for the UI to render forms. */
FieldDefinitionView: { FieldDefinitionView: {
authority_kind?: string | null; authority_kind?: null | components["schemas"]["AuthorityKind"];
/** @description "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". */ /** @description "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". */
data_type: string; data_type: components["schemas"]["DataType"];
group?: string | null; group?: string | null;
key: string; key: string;
labels: components["schemas"]["LabelView"][]; labels: components["schemas"]["LabelView"][];
@@ -419,7 +437,7 @@ export interface components {
recorder?: string | null; recorder?: string | null;
recording_date?: string | null; recording_date?: string | null;
/** @description "draft" | "internal" (public is rejected — publish via the visibility endpoint). */ /** @description "draft" | "internal" (public is rejected — publish via the visibility endpoint). */
visibility: string; visibility: components["schemas"]["Visibility"];
}; };
/** /**
* @description Inventory-minimum fields for update. Visibility is intentionally absent — it changes * @description Inventory-minimum fields for update. Visibility is intentionally absent — it changes
@@ -488,9 +506,14 @@ export interface components {
id: string; id: string;
role: string; role: string;
}; };
/**
* @description Publication state of a catalogue record.
* @enum {string}
*/
Visibility: "draft" | "internal" | "public";
/** @description Desired visibility for a publish/unpublish request. */ /** @description Desired visibility for a publish/unpublish request. */
VisibilityRequest: { VisibilityRequest: {
visibility: string; visibility: components["schemas"]["Visibility"];
}; };
VocabularyView: { VocabularyView: {
id: string; id: string;
+1 -1
View File
@@ -52,7 +52,7 @@ export function ObjectDetail() {
return byLang ?? byEnglish ?? key; return byLang ?? byEnglish ?? key;
}; };
const flexible = Object.entries(object.fields as Record<string, unknown>); const flexible = Object.entries(object.fields);
return ( return (
<div className="overflow-auto p-4"> <div className="overflow-auto p-4">
+1 -1
View File
@@ -34,7 +34,7 @@ export function ObjectEditForm() {
recording_date: object.recording_date ?? null, recording_date: object.recording_date ?? null,
}; };
const defaults = { core, fields: object.fields as Record<string, unknown> }; const defaults = { core, fields: object.fields };
const onSubmit = async (values: ObjectFormValues) => { const onSubmit = async (values: ObjectFormValues) => {
setError(null); setError(null);
+1 -1
View File
@@ -23,7 +23,7 @@ const STEPS: Visibility[] = ["draft", "internal", "public"];
export function PublishControl({ object }: { object: AdminObjectView }) { export function PublishControl({ object }: { object: AdminObjectView }) {
const { t } = useTranslation(); const { t } = useTranslation();
const current = object.visibility as Visibility; const current = object.visibility;
const { forward, back } = adjacentTransitions(current); const { forward, back } = adjacentTransitions(current);
const setVisibility = useSetVisibility(); const setVisibility = useSetVisibility();
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);