From 7a18e0e9bfb99c2c00d67068e2ef55e09c7efde6 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 22:20:47 +0200 Subject: [PATCH] feat(api): admin vocabulary + term management GET/POST /api/admin/vocabularies and GET/POST /api/admin/vocabularies/{id}/terms; reads gated on ViewInternal, writes on EditCatalogue; labels round-trip verified. Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin_vocab.rs | 215 ++++++++++++++++++++++++++++++ crates/api/src/lib.rs | 2 + crates/api/src/openapi.rs | 16 ++- crates/api/tests/admin_catalog.rs | 177 ++++++++++++++++++++++++ crates/db/src/vocab.rs | 12 ++ 5 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 crates/api/src/admin_vocab.rs create mode 100644 crates/api/tests/admin_catalog.rs diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs new file mode 100644 index 0000000..2a02e77 --- /dev/null +++ b/crates/api/src/admin_vocab.rs @@ -0,0 +1,215 @@ +//! Admin vocabulary + term management. Reads require `ViewInternal`; writes `EditCatalogue`. + +use auth::{Authorized, EditCatalogue, ViewInternal}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::get, +}; +use domain::{LocalizedLabel, NewTerm, VocabularyId}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; +use crate::admin_objects::LabelView; + +#[derive(Serialize, ToSchema)] +pub(crate) struct VocabularyView { + pub id: String, + pub key: String, +} + +#[derive(Deserialize, ToSchema)] +pub(crate) struct NewVocabularyRequest { + pub key: String, +} + +#[derive(Deserialize, ToSchema)] +pub(crate) struct LabelInput { + pub lang: String, + pub label: String, +} + +#[derive(Deserialize, ToSchema)] +pub(crate) struct NewTermRequest { + pub external_uri: Option, + pub labels: Vec, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct TermView { + pub id: String, + pub external_uri: Option, + pub labels: Vec, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct CreatedId { + pub id: String, +} + +#[utoipa::path( + get, path = "/api/admin/vocabularies", + responses( + (status = 200, body = [VocabularyView]), + (status = 401), + (status = 403) + ) +)] +pub(crate) async fn list_vocabularies( + _auth: Authorized, + State(state): State, +) -> Result>, StatusCode> { + let vocabs = db::vocab::list_vocabularies(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + vocabs + .into_iter() + .map(|vocab| VocabularyView { + id: vocab.id.to_string(), + key: vocab.key, + }) + .collect(), + )) +} + +#[utoipa::path( + post, path = "/api/admin/vocabularies", + request_body = NewVocabularyRequest, + responses( + (status = 201, body = VocabularyView), + (status = 401), + (status = 403) + ) +)] +pub(crate) async fn create_vocabulary( + _auth: Authorized, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(( + StatusCode::CREATED, + Json(VocabularyView { + id: vocab.id.to_string(), + key: vocab.key, + }), + )) +} + +#[utoipa::path( + get, path = "/api/admin/vocabularies/{id}/terms", + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses( + (status = 200, body = [TermView]), + (status = 401), + (status = 403), + (status = 404) + ) +)] +pub(crate) async fn list_terms( + _auth: Authorized, + State(state): State, + Path(id): Path, +) -> Result>, StatusCode> { + let vocab_id = id + .parse::() + .map_err(|_| StatusCode::NOT_FOUND)?; + + let terms = db::vocab::list_terms(state.db.pool(), vocab_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + terms + .into_iter() + .map(|term| TermView { + id: term.id.to_string(), + external_uri: term.external_uri, + labels: term + .labels + .into_iter() + .map(|label| LabelView { + lang: label.lang, + label: label.label, + }) + .collect(), + }) + .collect(), + )) +} + +#[utoipa::path( + post, path = "/api/admin/vocabularies/{id}/terms", + request_body = NewTermRequest, + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses( + (status = 201, body = CreatedId), + (status = 401), + (status = 403), + (status = 404) + ) +)] +pub(crate) async fn add_term( + _auth: Authorized, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let vocabulary_id = id + .parse::() + .map_err(|_| StatusCode::NOT_FOUND)?; + + let new = NewTerm { + vocabulary_id, + 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 term_id = db::vocab::add_term(&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: term_id.to_string(), + }), + )) +} + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/api/admin/vocabularies", + get(list_vocabularies).post(create_vocabulary), + ) + .route( + "/api/admin/vocabularies/{id}/terms", + get(list_terms).post(add_term), + ) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 5656210..7ec2086 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -2,6 +2,7 @@ mod admin; mod admin_objects; +mod admin_vocab; mod health; mod openapi; mod pagination; @@ -43,6 +44,7 @@ pub fn build_app(state: AppState) -> Router { .merge(public::routes()) .merge(admin::routes()) .merge(admin_objects::routes()) + .merge(admin_vocab::routes()) .layer(session_layer) .with_state(state) } diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 1bfbd40..4a0a60d 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, health, public}; +use crate::{AppState, admin, admin_objects, admin_vocab, health, public}; #[derive(OpenApi)] #[openapi( @@ -21,7 +21,11 @@ use crate::{AppState, admin, admin_objects, health, public}; admin_objects::update_object, admin_objects::delete_object, admin_objects::list_field_definitions, - admin_objects::set_fields + admin_objects::set_fields, + admin_vocab::list_vocabularies, + admin_vocab::create_vocabulary, + admin_vocab::list_terms, + admin_vocab::add_term ), components(schemas( health::Live, @@ -37,7 +41,13 @@ use crate::{AppState, admin, admin_objects, health, public}; admin_objects::ObjectCreateRequest, admin_objects::ObjectUpdateRequest, admin_objects::CreatedObject, - admin_objects::FieldDefinitionView + admin_objects::FieldDefinitionView, + admin_vocab::VocabularyView, + admin_vocab::NewVocabularyRequest, + admin_vocab::NewTermRequest, + admin_vocab::LabelInput, + admin_vocab::TermView, + admin_vocab::CreatedId )), 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 new file mode 100644 index 0000000..6d65235 --- /dev/null +++ b/crates/api/tests/admin_catalog.rs @@ -0,0 +1,177 @@ +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::users; +use domain::{AuditActor, Email, NewUser, Role}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; + +fn state(pool: PgPool) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".into(), + cookie_secure: false, + } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth::hash_password(password).unwrap(), + role, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +fn login_request(email: &str, password: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"email":"{email}","password":"{password}"}}"# + ))) + .unwrap() +} + +fn session_cookie(resp: &axum::http::Response) -> String { + resp.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +async fn login(app: &axum::Router, email: &str, pw: &str) -> String { + let resp = app.clone().oneshot(login_request(email, pw)).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + session_cookie(&resp) +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn create_list_vocabulary_and_terms(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; + + // create a vocabulary + let created = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/vocabularies") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key":"colour"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + + let vocab: serde_json::Value = + serde_json::from_slice(&created.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vocab_id = vocab["id"].as_str().unwrap().to_owned(); + + // list vocabularies includes it + let list = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/vocabularies") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_json: serde_json::Value = + serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert!( + list_json + .as_array() + .unwrap() + .iter() + .any(|item| item["key"] == "colour") + ); + + // add a term with labels + let term = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/vocabularies/{vocab_id}/terms")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"external_uri":null,"labels":[{"lang":"en","label":"red"},{"lang":"sv","label":"röd"}]}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(term.status(), StatusCode::CREATED); + + // list terms shows it (with both labels) + let terms = app + .oneshot( + Request::builder() + .uri(format!("/api/admin/vocabularies/{vocab_id}/terms")) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let terms_json: serde_json::Value = + serde_json::from_slice(&terms.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let arr = terms_json.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["labels"].as_array().unwrap().len(), 2); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn vocabulary_create_requires_auth(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + let app = build_app(state(pool)); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/vocabularies") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key":"x"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs index 389278a..6806296 100644 --- a/crates/db/src/vocab.rs +++ b/crates/db/src/vocab.rs @@ -26,6 +26,18 @@ where }) } +/// List all vocabularies, ordered by key. +pub async fn list_vocabularies<'e, E>(executor: E) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let rows = sqlx::query("SELECT id, key FROM vocabulary ORDER BY key") + .fetch_all(executor) + .await?; + + rows.into_iter().map(map_vocabulary).collect() +} + /// Look up a vocabulary by its key. pub async fn vocabulary_by_key<'e, E>( executor: E,