//! Admin vocabulary + term management. Reads require `ViewInternal`; writes `EditCatalogue`. use auth::{Authorized, EditCatalogue, ViewInternal}; use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, 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 mut tx = state .db .pool() .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let vocab = db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; tx.commit() .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, AuditActor::User(auth.user.id.to_uuid()), &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(), }), )) } /// 409 body: how many catalogue objects still reference the entity. #[derive(Serialize, ToSchema)] pub(crate) struct InUseView { pub count: i64, } #[derive(Deserialize, ToSchema)] pub(crate) struct UpdateTermRequest { pub external_uri: Option, pub labels: Vec, } #[utoipa::path( patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}", request_body = UpdateTermRequest, params( ("id" = String, Path, description = "Vocabulary id (UUID)"), ("term_id" = String, Path, description = "Term id (UUID)") ), responses( (status = 204), (status = 401), (status = 403), (status = 404) ) )] pub(crate) async fn update_term( auth: Authorized, State(state): State, Path((id, term_id)): Path<(String, String)>, Json(req): Json, ) -> Result { let vocabulary_id = id .parse::() .map_err(|_| StatusCode::NOT_FOUND)?; let term_id = term_id .parse::() .map_err(|_| StatusCode::NOT_FOUND)?; let labels: Vec = req .labels .into_iter() .map(|l| LocalizedLabel { lang: l.lang, label: l.label, }) .collect(); let mut tx = state .db .pool() .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existed = db::vocab::update_term( &mut tx, AuditActor::User(auth.user.id.to_uuid()), vocabulary_id, term_id, req.external_uri.as_deref(), &labels, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if existed { tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } else { let _ = tx.rollback().await; Err(StatusCode::NOT_FOUND) } } #[utoipa::path( delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}", params( ("id" = String, Path, description = "Vocabulary id (UUID)"), ("term_id" = String, Path, description = "Term id (UUID)") ), responses( (status = 204), (status = 401), (status = 403), (status = 404), (status = 409, body = InUseView, description = "Referenced by catalogue objects") ) )] pub(crate) async fn delete_term( auth: Authorized, State(state): State, Path((id, term_id)): Path<(String, String)>, ) -> Response { let (Ok(vocab_id), Ok(term_id)) = (id.parse::(), term_id.parse::()) else { return StatusCode::NOT_FOUND.into_response(); }; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; let outcome = db::vocab::delete_term( &mut tx, AuditActor::User(auth.user.id.to_uuid()), vocab_id, term_id, ) .await; match outcome { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => { let _ = tx.rollback().await; (StatusCode::CONFLICT, Json(InUseView { count })).into_response() } Ok(db::DeleteOutcome::NotFound) => { let _ = tx.rollback().await; StatusCode::NOT_FOUND.into_response() } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } #[derive(Deserialize, ToSchema)] pub(crate) struct RenameVocabularyRequest { pub key: String, } #[utoipa::path( patch, path = "/api/admin/vocabularies/{id}", request_body = RenameVocabularyRequest, params(("id" = String, Path, description = "Vocabulary id (UUID)")), responses( (status = 204), (status = 401), (status = 403), (status = 404), (status = 409, description = "Key already in use") ) )] pub(crate) async fn rename_vocabulary( auth: Authorized, State(state): State, Path(id): Path, Json(req): Json, ) -> Result { let id = id .parse::() .map_err(|_| StatusCode::NOT_FOUND)?; let mut tx = state .db .pool() .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existed = db::vocab::rename_vocabulary( &mut tx, AuditActor::User(auth.user.id.to_uuid()), id, &req.key, ) .await .map_err(|err| { if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { StatusCode::CONFLICT } else { StatusCode::INTERNAL_SERVER_ERROR } })?; if existed { tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } else { let _ = tx.rollback().await; Err(StatusCode::NOT_FOUND) } } #[utoipa::path( delete, path = "/api/admin/vocabularies/{id}", params(("id" = String, Path, description = "Vocabulary id (UUID)")), responses( (status = 204), (status = 401), (status = 403), (status = 404), (status = 409, body = InUseView, description = "Has terms or is bound by a field") ) )] pub(crate) async fn delete_vocabulary( auth: Authorized, State(state): State, Path(id): Path, ) -> Response { let Ok(id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response(); }; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => { let _ = tx.rollback().await; (StatusCode::CONFLICT, Json(InUseView { count })).into_response() } Ok(db::DeleteOutcome::NotFound) => { let _ = tx.rollback().await; StatusCode::NOT_FOUND.into_response() } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } pub(crate) fn routes() -> Router { Router::new() .route( "/api/admin/vocabularies", get(list_vocabularies).post(create_vocabulary), ) .route( "/api/admin/vocabularies/{id}", axum::routing::patch(rename_vocabulary).delete(delete_vocabulary), ) .route( "/api/admin/vocabularies/{id}/terms", get(list_terms).post(add_term), ) .route( "/api/admin/vocabularies/{id}/terms/{term_id}", axum::routing::patch(update_term).delete(delete_term), ) }