27caaa9787
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
484 lines
13 KiB
Rust
484 lines
13 KiB
Rust
//! 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<String>,
|
|
pub labels: Vec<LabelInput>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub(crate) struct TermView {
|
|
pub id: String,
|
|
pub external_uri: Option<String>,
|
|
pub labels: Vec<LabelView>,
|
|
}
|
|
|
|
#[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<ViewInternal>,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<VocabularyView>>, 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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Json(req): Json<NewVocabularyRequest>,
|
|
) -> Result<(StatusCode, Json<VocabularyView>), 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<ViewInternal>,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Vec<TermView>>, StatusCode> {
|
|
let vocab_id = id
|
|
.parse::<VocabularyId>()
|
|
.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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<NewTermRequest>,
|
|
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
|
let vocabulary_id = id
|
|
.parse::<VocabularyId>()
|
|
.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<String>,
|
|
pub labels: Vec<LabelInput>,
|
|
}
|
|
|
|
#[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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Path((id, term_id)): Path<(String, String)>,
|
|
Json(req): Json<UpdateTermRequest>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let vocabulary_id = id
|
|
.parse::<VocabularyId>()
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
let term_id = term_id
|
|
.parse::<TermId>()
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
|
|
let labels: Vec<LocalizedLabel> = 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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Path((id, term_id)): Path<(String, String)>,
|
|
) -> Response {
|
|
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
|
|
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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<RenameVocabularyRequest>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let id = id
|
|
.parse::<VocabularyId>()
|
|
.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<EditCatalogue>,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let Ok(id) = id.parse::<VocabularyId>() 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<AppState> {
|
|
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),
|
|
)
|
|
}
|