//! 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, 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(|err| { // A well-formed id for a missing vocabulary hits the FK constraint (23503). if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") { StatusCode::NOT_FOUND } else { tracing::error!(?err, "adding term"); 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), ) }