From 01abd5cbbcaa51e6a3f39d56296779a8a0024065 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 22:33:44 +0200 Subject: [PATCH] feat(api): admin authority management (create + list by kind) Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin_authorities.rs | 133 ++++++++++++++++++++++++++++ crates/api/src/lib.rs | 2 + crates/api/src/openapi.rs | 10 ++- crates/api/tests/admin_catalog.rs | 83 +++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 crates/api/src/admin_authorities.rs diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs new file mode 100644 index 0000000..15f7cee --- /dev/null +++ b/crates/api/src/admin_authorities.rs @@ -0,0 +1,133 @@ +//! Admin authority-record management. Reads require `ViewInternal`; writes `EditCatalogue`. + +use auth::{Authorized, EditCatalogue, ViewInternal}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + routing::get, +}; +use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; +use crate::admin_objects::LabelView; +use crate::admin_vocab::{CreatedId, LabelInput}; + +#[derive(Serialize, ToSchema)] +pub(crate) struct AuthorityView { + pub id: String, + pub kind: String, + pub external_uri: Option, + pub labels: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub(crate) struct NewAuthorityRequest { + /// "person" | "organisation" | "place". + pub kind: String, + pub external_uri: Option, + pub labels: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct KindQuery { + kind: String, +} + +#[utoipa::path( + get, path = "/api/admin/authorities", + params(("kind" = String, Query, description = "person | organisation | place")), + responses( + (status = 200, body = [AuthorityView]), + (status = 401), + (status = 403), + (status = 422) + ) +)] +pub(crate) async fn list_authorities( + _auth: Authorized, + State(state): State, + Query(q): Query, +) -> Result>, StatusCode> { + let kind = AuthorityKind::from_db(&q.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; + + let authorities = db::authority::list_by_kind(state.db.pool(), kind) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + authorities + .into_iter() + .map(|authority| AuthorityView { + id: authority.id.to_string(), + kind: authority.kind.as_str().to_owned(), + external_uri: authority.external_uri, + labels: authority + .labels + .into_iter() + .map(|label| LabelView { + lang: label.lang, + label: label.label, + }) + .collect(), + }) + .collect(), + )) +} + +#[utoipa::path( + post, path = "/api/admin/authorities", + request_body = NewAuthorityRequest, + responses( + (status = 201, body = CreatedId), + (status = 401), + (status = 403), + (status = 422) + ) +)] +pub(crate) async fn create_authority( + _auth: Authorized, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let kind = AuthorityKind::from_db(&req.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; + + let new = NewAuthority { + kind, + external_uri: req.external_uri, + labels: req + .labels + .into_iter() + .map(|label| LocalizedLabel { + lang: label.lang, + label: label.label, + }) + .collect(), + }; + + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let id = db::authority::create_authority(&mut tx, &new) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() }))) +} + +pub(crate) fn routes() -> Router { + Router::new().route( + "/api/admin/authorities", + get(list_authorities).post(create_authority), + ) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 7ec2086..5ccde10 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,6 +1,7 @@ //! HTTP API: router, handlers, and OpenAPI document. mod admin; +mod admin_authorities; mod admin_objects; mod admin_vocab; mod health; @@ -45,6 +46,7 @@ pub fn build_app(state: AppState) -> Router { .merge(admin::routes()) .merge(admin_objects::routes()) .merge(admin_vocab::routes()) + .merge(admin_authorities::routes()) .layer(session_layer) .with_state(state) } diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 4a0a60d..2e55f50 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -1,7 +1,7 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; -use crate::{AppState, admin, admin_objects, admin_vocab, health, public}; +use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, health, public}; #[derive(OpenApi)] #[openapi( @@ -25,7 +25,9 @@ use crate::{AppState, admin, admin_objects, admin_vocab, health, public}; admin_vocab::list_vocabularies, admin_vocab::create_vocabulary, admin_vocab::list_terms, - admin_vocab::add_term + admin_vocab::add_term, + admin_authorities::list_authorities, + admin_authorities::create_authority ), components(schemas( health::Live, @@ -47,7 +49,9 @@ use crate::{AppState, admin, admin_objects, admin_vocab, health, public}; admin_vocab::NewTermRequest, admin_vocab::LabelInput, admin_vocab::TermView, - admin_vocab::CreatedId + admin_vocab::CreatedId, + admin_authorities::AuthorityView, + admin_authorities::NewAuthorityRequest )), info(title = "Collection Management System", version = "0.0.0") )] diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index 4cdfa0d..bb9d51a 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -176,3 +176,86 @@ async fn vocabulary_create_requires_auth(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + +async fn app2_get(app: &axum::Router, cookie: &str, uri: &str) -> StatusCode { + app.clone() + .oneshot( + Request::builder() + .uri(uri) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + .status() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn create_and_list_authorities_by_kind(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let created = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/authorities") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"kind":"person","external_uri":null,"labels":[{"lang":"en","label":"Ada Lovelace"}]}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + + // list by kind + let list = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/authorities?kind=person") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list.status(), StatusCode::OK); + + let json: serde_json::Value = + serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(json.as_array().unwrap().len(), 1); + assert_eq!(json[0]["kind"], "person"); + + // a different kind is empty + let places = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/authorities?kind=place") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let places_json: serde_json::Value = + serde_json::from_slice(&places.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert!(places_json.as_array().unwrap().is_empty()); + + // bad kind → 422 + let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await; + assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY); +}