This commit is contained in:
2025-11-06 12:26:29 +01:00
parent 4027ac66cb
commit a6f79dbc75
12 changed files with 344 additions and 199 deletions
+1
View File
@@ -0,0 +1 @@
pub mod responders;
+149
View File
@@ -0,0 +1,149 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use crate::error::{AppError, AppResult};
/// Helper trait to convert error-centric results into the application's error type.
pub trait IntoAppResult<T> {
fn into_app_result(self) -> AppResult<T>;
}
impl<T, E> IntoAppResult<T> for Result<T, E>
where
AppError: From<E>,
{
fn into_app_result(self) -> AppResult<T> {
self.map_err(AppError::from)
}
}
/// Extension helpers for optional values to map them into `AppResult`.
pub trait OptionAppResultExt<T> {
fn or_not_found(self) -> AppResult<T>;
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
}
impl<T> OptionAppResultExt<T> for Option<T> {
fn or_not_found(self) -> AppResult<T> {
self.ok_or_else(AppError::not_found)
}
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
self.ok_or_else(|| AppError::bad_request(message))
}
}
/// Provides helpers for statements returning number of affected rows.
pub trait RowsAffectedExt: Sized {
fn or_error(self, error: AppError) -> AppResult<usize>;
fn or_not_found(self) -> AppResult<usize> {
self.or_error(AppError::not_found())
}
}
impl RowsAffectedExt for usize {
fn or_error(self, error: AppError) -> AppResult<usize> {
if self == 0 {
Err(error)
} else {
Ok(self)
}
}
}
/// Wrapper providing a consistent JSON response with a status code.
pub struct JsonResponse<T> {
status: StatusCode,
payload: T,
}
impl<T> JsonResponse<T> {
pub fn new(status: StatusCode, payload: T) -> Self {
Self { status, payload }
}
pub fn ok(payload: T) -> Self {
Self::new(StatusCode::OK, payload)
}
pub fn created(payload: T) -> Self {
Self::new(StatusCode::CREATED, payload)
}
pub fn accepted(payload: T) -> Self {
Self::new(StatusCode::ACCEPTED, payload)
}
}
impl<T> From<T> for JsonResponse<T> {
fn from(value: T) -> Self {
Self::ok(value)
}
}
impl<T> IntoResponse for JsonResponse<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
(self.status, Json(self.payload)).into_response()
}
}
/// Helper for returning empty responses with a status code.
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
Ok(status)
}
/// Helper for returning `204 No Content`.
pub fn no_content() -> AppResult<StatusCode> {
empty(StatusCode::NO_CONTENT)
}
/// Helper for returning JSON payloads with `200 OK`.
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::ok(value))
}
/// Helper for returning JSON payloads with `201 Created`.
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::created(value))
}
/// Helper for returning JSON payloads with `202 Accepted`.
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::accepted(value))
}
/// Standard wrapper for paginated responses.
#[derive(Serialize)]
pub struct PaginatedResponse<T, M>
where
T: Serialize,
M: Serialize,
{
pub data: T,
pub meta: M,
}
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
where
T: Serialize,
M: Serialize,
{
let payload = PaginatedResponse { data, meta };
Ok(JsonResponse::ok(payload))
}
+1
View File
@@ -3,6 +3,7 @@ pub mod config;
pub mod db;
pub mod documents;
pub mod error;
pub mod http;
pub mod jobs;
pub mod models;
pub mod openapi;
+22 -19
View File
@@ -28,6 +28,7 @@ use crate::{
AuthenticatedUser,
},
error::{AppError, AppResult},
http::responders::{ok_json, JsonResponse},
models::{
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
UserSession,
@@ -224,7 +225,7 @@ pub async fn login(
pub async fn api_token_exchange(
State(state): State<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<Json<LoginResponse>> {
) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim();
if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be empty"));
@@ -289,7 +290,7 @@ pub async fn api_token_exchange(
},
};
Ok(Json(response))
ok_json(response)
}
#[utoipa::path(
@@ -306,7 +307,7 @@ pub async fn api_token_exchange(
pub async fn signup_start(
State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>,
) -> AppResult<Json<SignupStartResponse>> {
) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
@@ -334,10 +335,10 @@ pub async fn signup_start(
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?;
Ok(Json(SignupStartResponse {
ok_json(SignupStartResponse {
signup_token,
challenge,
}))
})
}
#[utoipa::path(
@@ -590,7 +591,7 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> {
) -> AppResult<JsonResponse<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?;
let token = bearer.token();
@@ -630,7 +631,7 @@ pub async fn list_tenants(
});
}
Ok(Json(TenantListResponse { tenants }))
ok_json(TenantListResponse { tenants })
}
#[utoipa::path(
@@ -642,7 +643,7 @@ pub async fn list_tenants(
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<Json<RegistrationChallengeResponse>> {
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = state
.passkeys
.as_ref()
@@ -651,7 +652,7 @@ pub async fn passkey_register_start(
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
Ok(Json(challenge))
ok_json(challenge)
}
#[utoipa::path(
@@ -665,7 +666,7 @@ pub async fn passkey_register_finish(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<Json<PasskeySummary>> {
) -> AppResult<JsonResponse<PasskeySummary>> {
let service = state
.passkeys
.as_ref()
@@ -688,7 +689,7 @@ pub async fn passkey_register_finish(
nickname,
)?;
Ok(Json(PasskeySummary::from(passkey)))
ok_json(PasskeySummary::from(passkey))
}
#[utoipa::path(
@@ -701,7 +702,7 @@ pub async fn passkey_register_finish(
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<Json<AuthenticationChallengeResponse>> {
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let service = state
.passkeys
.as_ref()
@@ -718,7 +719,7 @@ pub async fn passkey_login_start(
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
Ok(Json(challenge))
ok_json(challenge)
}
#[utoipa::path(
@@ -799,11 +800,12 @@ fn complete_login(
});
}
Ok(Json(TenantSelectionResponse {
let response = ok_json(TenantSelectionResponse {
access_token: selection_token,
tenants,
})
.into_response())
})?;
Ok(response.into_response())
}
fn magic_token_login(
@@ -925,7 +927,7 @@ fn issue_session(
.values(&new_session)
.execute(conn)?;
let mut response = Json(LoginResponse {
let json = ok_json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
@@ -933,8 +935,9 @@ fn issue_session(
id: tenant_id,
name: tenant_name,
},
})
.into_response();
})?;
let mut response = json.into_response();
response.headers_mut().insert(
SET_COOKIE,
+25 -28
View File
@@ -11,6 +11,9 @@ use crate::{
},
auth::TenantScopedConn,
error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
models::{ApiCapability, CapabilitySet},
schema::{
api_tokens,
@@ -97,7 +100,7 @@ pub async fn list_capability_sets(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CapabilitySetResponse>>> {
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
@@ -109,7 +112,7 @@ pub async fn list_capability_sets(
responses.push(to_response(set, capabilities));
}
Ok(Json(responses))
ok_json(responses)
}
#[utoipa::path(
@@ -120,12 +123,12 @@ pub async fn list_capability_sets(
)]
pub async fn list_capabilities(
TenantScopedConn { .. }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiCapability>>> {
) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
Ok(Json(capabilities))
ok_json(capabilities)
}
#[utoipa::path(
@@ -142,18 +145,15 @@ pub async fn get_capability_set(
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
) -> AppResult<Json<CapabilitySetResponse>> {
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
.into_app_result()?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities)))
ok_json(to_response(set, capabilities))
}
#[utoipa::path(
@@ -170,7 +170,7 @@ pub async fn create_capability_set(
..
}: TenantScopedConn,
Json(payload): Json<CreateCapabilitySetRequest>,
) -> AppResult<(StatusCode, Json<CapabilitySetResponse>)> {
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let original_caps = payload.capabilities;
let normalized_caps = normalize_capabilities(original_caps.clone())?;
if normalized_caps.is_empty() {
@@ -196,7 +196,7 @@ pub async fn create_capability_set(
let set = create_capability_set_record(&mut conn, tenant_id, &slug, original_caps)?;
let response = to_response(set, normalized_caps);
Ok((StatusCode::CREATED, Json(response)))
created_json(response)
}
#[utoipa::path(
@@ -215,15 +215,12 @@ pub async fn update_capability_set(
}: TenantScopedConn,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<Json<CapabilitySetResponse>> {
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
.into_app_result()?;
if set.is_system {
if payload.slug.is_some() || payload.capabilities.is_some() {
@@ -232,7 +229,7 @@ pub async fn update_capability_set(
));
}
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
return Ok(Json(to_response(set, capabilities)));
return ok_json(to_response(set, capabilities));
}
let set = conn.transaction::<CapabilitySet, AppError, _>(|conn| {
@@ -250,7 +247,7 @@ pub async fn update_capability_set(
.filter(cs_dsl::id.ne(working.id))
.first::<CapabilitySet>(conn)
.optional()
.map_err(AppError::from)?
.into_app_result()?
.is_some()
{
return Err(AppError::conflict("slug already exists"));
@@ -262,7 +259,7 @@ pub async fn update_capability_set(
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)
.map_err(AppError::from)?;
.into_app_result()?;
working.slug = normalized;
}
@@ -280,11 +277,11 @@ pub async fn update_capability_set(
capability_sets::table
.find(working.id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)
.into_app_result()
})?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities)))
ok_json(to_response(set, capabilities))
}
#[utoipa::path(
@@ -306,10 +303,7 @@ pub async fn delete_capability_set(
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
.into_app_result()?;
if set.is_system {
return Err(AppError::conflict(
@@ -339,9 +333,12 @@ pub async fn delete_capability_set(
));
}
diesel::delete(cs_dsl::capability_sets.find(set.id)).execute(&mut conn)?;
diesel::delete(cs_dsl::capability_sets.find(set.id))
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
#[derive(utoipa::OpenApi)]
+18 -17
View File
@@ -11,10 +11,10 @@ use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents},
utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name},
time::to_iso,
},
@@ -71,7 +71,7 @@ pub async fn list_correspondents(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc())
@@ -94,7 +94,7 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, total));
}
response.into_json()
ok_json(response)
}
#[utoipa::path(
@@ -111,7 +111,7 @@ pub async fn create_correspondent(
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
) -> AppResult<JsonResponse<CorrespondentSummary>> {
let name = normalize_name(&payload.name, || {
AppError::bad_request("name must not be empty")
})?;
@@ -140,9 +140,9 @@ pub async fn create_correspondent(
.find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
build_summary(correspondent, 0).into_json()
ok_json(build_summary(correspondent, 0))
}
#[utoipa::path(
@@ -161,12 +161,12 @@ pub async fn update_correspondent(
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
) -> AppResult<JsonResponse<CorrespondentSummary>> {
let existing: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name {
@@ -199,7 +199,7 @@ pub async fn update_correspondent(
if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return build_summary(existing.clone(), usage).into_json();
return ok_json(build_summary(existing.clone(), usage));
}
let mut changeset = CorrespondentChangeset::default();
@@ -217,15 +217,17 @@ pub async fn update_correspondent(
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json()
ok_json(build_summary(updated, usage))
}
#[utoipa::path(
@@ -255,15 +257,14 @@ pub async fn delete_correspondent(
));
}
let deleted = diesel::delete(
diesel::delete(
correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
no_content()
}
+41 -40
View File
@@ -33,6 +33,7 @@ use crate::documents::{
tags::{assign_tags as assign_tags_to_document, load_tags_for_documents},
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse};
use crate::jobs::{
enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT,
};
@@ -46,7 +47,7 @@ use crate::schema::{
};
use crate::state::AppState;
use crate::utils::{
db::{no_content, validate_bulk_ids, IntoJsonResponse},
db::validate_bulk_ids,
error::StorageResultExt,
http::inline_content_disposition,
json::{classify_nullable, NullableValue},
@@ -372,7 +373,7 @@ pub async fn list_documents(
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentResponse>>> {
) -> AppResult<JsonResponse<Vec<DocumentResponse>>> {
let DocumentListQuery {
folder_id,
include_descendants,
@@ -455,7 +456,7 @@ pub async fn list_documents(
})?;
if ids.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
quickwit_order = Some(ids.clone());
@@ -480,7 +481,7 @@ pub async fn list_documents(
let docs_set: HashSet<Uuid> = docs_without_tags.into_iter().collect();
if docs_set.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
let new_filter = match &filter_ids {
@@ -519,7 +520,7 @@ pub async fn list_documents(
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
let new_filter = match &filter_ids {
@@ -566,7 +567,7 @@ pub async fn list_documents(
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
let new_filter = match &filter_ids {
@@ -581,7 +582,7 @@ pub async fn list_documents(
if let Some(ref set) = filter_ids {
if set.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
let ids_vec: Vec<Uuid> = set.iter().copied().collect();
@@ -606,7 +607,7 @@ pub async fn list_documents(
};
if relevant_ids.is_empty() {
return Ok(Json(vec![]));
return ok_json(Vec::<DocumentResponse>::new());
}
let mut fetched: Vec<Document> = docs_query.load(&mut conn)?;
@@ -631,7 +632,7 @@ pub async fn list_documents(
let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
Ok(Json(response))
ok_json(response)
}
#[utoipa::path(
@@ -648,7 +649,7 @@ pub async fn check_document(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentCheckResponse>> {
) -> AppResult<JsonResponse<DocumentCheckResponse>> {
let checksum_raw = query.checksum.trim();
if checksum_raw.is_empty() {
return Err(AppError::bad_request("checksum must not be empty"));
@@ -672,7 +673,7 @@ pub async fn check_document(
.optional()?;
if let Some((document, version)) = record {
Ok(Json(DocumentCheckResponse {
ok_json(DocumentCheckResponse {
exists: true,
document_id: Some(document.id),
title: Some(document.title.clone()),
@@ -680,9 +681,9 @@ pub async fn check_document(
version_id: Some(version.id),
version_number: Some(version.version_number),
created_at: Some(to_iso(document.created_at)),
}))
})
} else {
Ok(Json(DocumentCheckResponse {
ok_json(DocumentCheckResponse {
exists: false,
document_id: None,
title: None,
@@ -690,7 +691,7 @@ pub async fn check_document(
version_id: None,
version_number: None,
created_at: None,
}))
})
}
}
@@ -710,7 +711,7 @@ pub async fn get_document(
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentDetailResponse>> {
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let doc: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -731,7 +732,7 @@ pub async fn get_document(
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse {
ok_json(DocumentDetailResponse {
document: to_document_response(
&state,
user_id,
@@ -740,7 +741,7 @@ pub async fn get_document(
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)),
)?,
}))
})
}
#[utoipa::path(
@@ -760,7 +761,7 @@ pub async fn upload_document(
tenant_id, user_id, ..
}: TenantScopedConn,
mut multipart: Multipart,
) -> AppResult<impl IntoResponse> {
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None;
@@ -938,7 +939,7 @@ pub async fn upload_document(
reused_existing = false,
"document upload succeeded",
);
(StatusCode::CREATED, Json(detail)).into_response()
created_json(detail)?
}
UploadOutcome::Reused(detail) => {
info!(
@@ -948,7 +949,7 @@ pub async fn upload_document(
reused_existing = true,
"document upload succeeded",
);
(StatusCode::OK, Json(detail)).into_response()
ok_json(detail)?
}
};
@@ -1012,7 +1013,7 @@ pub async fn reanalyze_selected_documents(
..
}: TenantScopedConn,
Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
) -> AppResult<JsonResponse<BulkReanalyzeResponse>> {
let BulkReanalyzeSelectionRequest {
mut document_ids,
force,
@@ -1053,7 +1054,7 @@ pub async fn reanalyze_selected_documents(
queued += 1;
}
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
accepted_json(BulkReanalyzeResponse { queued })
}
#[utoipa::path(
@@ -1071,7 +1072,7 @@ pub async fn list_document_assets(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentAssetResponse>>> {
) -> AppResult<JsonResponse<Vec<DocumentAssetResponse>>> {
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -1084,7 +1085,7 @@ pub async fn list_document_assets(
drop(conn);
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
Ok(Json(assets))
ok_json(assets)
}
#[utoipa::path(
@@ -1103,7 +1104,7 @@ pub async fn get_document_asset(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentAssetDetailResponse>> {
) -> AppResult<JsonResponse<DocumentAssetDetailResponse>> {
let asset: DocumentAsset = match document_assets::table
.find(asset_id)
.filter(document_assets::tenant_id.eq(tenant_id))
@@ -1168,7 +1169,7 @@ pub async fn get_document_asset(
return Err(AppError::not_found());
}
Ok(Json(to_asset_detail_response(asset, object_responses)))
ok_json(to_asset_detail_response(asset, object_responses))
}
fn presign_disposition_for_asset(
@@ -1193,7 +1194,7 @@ pub async fn list_document_versions(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentVersionResponse>>> {
) -> AppResult<JsonResponse<Vec<DocumentVersionResponse>>> {
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -1212,7 +1213,7 @@ pub async fn list_document_versions(
let versions: Vec<DocumentVersionResponse> =
versions.into_iter().map(to_version_response).collect();
Ok(Json(versions))
ok_json(versions)
}
#[utoipa::path(
@@ -1234,7 +1235,7 @@ pub async fn get_document_version(
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentVersionDetailResponse>> {
) -> AppResult<JsonResponse<DocumentVersionDetailResponse>> {
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -1256,11 +1257,11 @@ pub async fn get_document_version(
let download_path = build_download_path(&state, &document, user_id)?;
let version_core = to_version_response(version);
Ok(Json(DocumentVersionDetailResponse {
ok_json(DocumentVersionDetailResponse {
version: version_core,
assets,
download_path,
}))
})
}
#[utoipa::path(
@@ -1416,7 +1417,7 @@ pub async fn update_document(
..
}: TenantScopedConn,
Json(payload): Json<Value>,
) -> AppResult<Json<DocumentDetailResponse>> {
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let mut document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -1566,7 +1567,7 @@ pub async fn update_document(
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse {
ok_json(DocumentDetailResponse {
document: to_document_response(
&state,
user_id,
@@ -1575,7 +1576,7 @@ pub async fn update_document(
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)),
)?,
}))
})
}
#[utoipa::path(
@@ -1686,7 +1687,7 @@ pub async fn bulk_move_documents(
..
}: TenantScopedConn,
Json(payload): Json<BulkMoveRequest>,
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
) -> AppResult<JsonResponse<BulkMoveResponse>> {
let BulkMoveRequest {
mut document_ids,
folder_id,
@@ -1759,7 +1760,7 @@ pub async fn bulk_move_documents(
};
let body = BulkMoveResponse { updated };
Ok((StatusCode::OK, body.into_json()?))
ok_json(body)
}
#[utoipa::path(
@@ -1866,7 +1867,7 @@ pub async fn bulk_assign_correspondents(
..
}: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
) -> AppResult<JsonResponse<BulkCorrespondentResponse>> {
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
@@ -1947,7 +1948,7 @@ pub async fn bulk_assign_correspondents(
})?;
let body = BulkCorrespondentResponse { assigned, removed };
Ok((StatusCode::OK, body.into_json()?))
ok_json(body)
}
#[utoipa::path(
@@ -2052,7 +2053,7 @@ pub async fn bulk_update_tags(
..
}: TenantScopedConn,
Json(payload): Json<BulkTagRequest>,
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
) -> AppResult<JsonResponse<BulkTagResponse>> {
let BulkTagRequest {
mut document_ids,
mut tag_ids,
@@ -2126,7 +2127,7 @@ pub async fn bulk_update_tags(
}
};
Ok((StatusCode::OK, response.into_json()?))
ok_json(response)
}
#[utoipa::path(
+27 -20
View File
@@ -18,6 +18,9 @@ use crate::state::AppState;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
};
use super::documents::{hydrate_documents, DocumentResponse};
@@ -141,15 +144,15 @@ pub async fn get_folder(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderResponse>> {
) -> AppResult<JsonResponse<FolderResponse>> {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?;
Ok(Json(FolderResponse {
ok_json(FolderResponse {
folder: folder_to_info(folder),
}))
})
}
#[utoipa::path(
@@ -166,7 +169,7 @@ pub async fn ensure_folder_path(
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> {
) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
@@ -240,9 +243,9 @@ pub async fn ensure_folder_path(
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
})?;
Ok(Json(FolderResponse {
ok_json(FolderResponse {
folder: folder_to_info(target_folder),
}))
})
}
#[utoipa::path(
@@ -262,7 +265,7 @@ pub async fn create_folder(
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>,
) -> AppResult<(StatusCode, Json<FolderResponse>)> {
) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
@@ -331,14 +334,14 @@ pub async fn create_folder(
}
};
let response = Json(FolderResponse {
let response = FolderResponse {
folder: folder_to_info(folder),
});
};
if created {
Ok((StatusCode::CREATED, response))
created_json(response)
} else {
Ok((StatusCode::OK, response))
ok_json(response)
}
}
@@ -359,7 +362,7 @@ pub async fn list_folder_contents(
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> {
) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
@@ -427,11 +430,11 @@ pub async fn list_folder_contents(
Vec::new()
};
Ok(Json(FolderContentsResponse {
ok_json(FolderContentsResponse {
folder,
subfolders,
documents,
}))
})
}
#[utoipa::path(
@@ -446,7 +449,7 @@ pub async fn list_folder_tree(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<FolderTreeNode>>> {
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
@@ -499,7 +502,7 @@ pub async fn list_folder_tree(
.map(|root_id| build_node(*root_id, &node_map, &children_map))
.collect();
Ok(Json(tree))
ok_json(tree)
}
#[utoipa::path(
@@ -555,12 +558,14 @@ pub async fn delete_folder(
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
#[utoipa::path(
@@ -677,12 +682,14 @@ pub async fn update_folder(
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
fn folder_to_info(folder: Folder) -> FolderInfo {
+10 -9
View File
@@ -18,9 +18,10 @@ use crate::auth::{
TenantScopedConn,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
use crate::models::ApiToken;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::{db::no_content, time::to_iso};
use crate::utils::time::to_iso;
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse {
@@ -71,14 +72,14 @@ pub async fn list_passkeys(
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
) -> AppResult<Json<Vec<PasskeySummary>>> {
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(&mut conn, user_id)?;
Ok(Json(passkeys))
ok_json(passkeys)
}
#[utoipa::path(
@@ -94,10 +95,10 @@ pub async fn list_api_tokens(
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(api_token_to_response).collect();
Ok(Json(responses))
ok_json(responses)
}
#[utoipa::path(
@@ -115,7 +116,7 @@ pub async fn create_api_token(
..
}: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> {
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = match payload.expires_at {
Some(ref value) => Some(parse_timestamp(value)?),
None => None,
@@ -140,7 +141,7 @@ pub async fn create_api_token(
token_info,
};
Ok((StatusCode::CREATED, Json(response)))
created_json(response)
}
#[utoipa::path(
@@ -158,7 +159,7 @@ pub async fn regenerate_api_token(
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<Json<ApiTokenCreatedResponse>> {
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse {
@@ -166,7 +167,7 @@ pub async fn regenerate_api_token(
token_info,
};
Ok(Json(response))
ok_json(response)
}
#[utoipa::path(
+32 -32
View File
@@ -5,14 +5,16 @@ use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags};
use crate::utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{NewTag, Tag},
schema::{document_tags, tags},
utils::{
json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
},
};
#[derive(Deserialize, ToSchema)]
@@ -84,7 +86,7 @@ pub async fn list_tags(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc())
@@ -108,7 +110,7 @@ pub async fn list_tags(
})
.collect();
response.into_json()
ok_json(response)
}
#[utoipa::path(
@@ -125,7 +127,7 @@ pub async fn create_tag(
..
}: TenantScopedConn,
Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> {
) -> AppResult<JsonResponse<TagCatalogEntry>> {
let label = normalize_name(&payload.label, || {
AppError::bad_request("label must not be empty")
})?;
@@ -155,15 +157,14 @@ pub async fn create_tag(
.find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
TagCatalogEntry {
ok_json(TagCatalogEntry {
id: tag.id,
label: tag.label,
color: tag.color,
usage_count: 0,
}
.into_json()
})
}
#[utoipa::path(
@@ -182,12 +183,12 @@ pub async fn update_tag(
..
}: TenantScopedConn,
Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> {
) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
let UpdateTagRequest { label, color } = payload;
if label.is_none() && color.is_none() {
@@ -195,13 +196,12 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
return TagCatalogEntry {
return ok_json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}
.into_json();
});
}
let mut new_label: Option<String> = None;
@@ -258,12 +258,12 @@ pub async fn update_tag(
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
return ok_json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}));
});
}
let changeset = UpdateTagChangeset {
@@ -279,26 +279,27 @@ pub async fn update_tag(
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset)
.execute(&mut conn)?;
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
.into_app_result()?;
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star())
.first(&mut conn)?;
TagCatalogEntry {
ok_json(TagCatalogEntry {
id: updated.id,
label: updated.label,
color: updated.color,
usage_count,
}
.into_json()
})
}
#[utoipa::path(
@@ -328,15 +329,14 @@ pub async fn delete_tag(
));
}
let deleted = diesel::delete(
diesel::delete(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
no_content()
}
+1 -34
View File
@@ -1,4 +1,4 @@
use diesel::{pg::PgConnection, result::Error as DieselError};
use diesel::pg::PgConnection;
use uuid::Uuid;
use crate::{
@@ -6,25 +6,6 @@ use crate::{
state::AppState,
};
pub trait EnsureEntity<T> {
fn one(self) -> AppResult<T>;
fn maybe(self) -> AppResult<Option<T>>;
}
impl<T> EnsureEntity<T> for Result<T, DieselError> {
fn one(self) -> AppResult<T> {
self.map_err(AppError::from)
}
fn maybe(self) -> AppResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(DieselError::NotFound) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
}
}
impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where
@@ -43,17 +24,3 @@ pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
ids.dedup();
Ok(())
}
pub trait IntoJsonResponse<T> {
fn into_json(self) -> AppResult<axum::Json<T>>;
}
impl<T> IntoJsonResponse<T> for T {
fn into_json(self) -> AppResult<axum::Json<T>> {
Ok(axum::Json(self))
}
}
pub fn no_content() -> AppResult<axum::http::StatusCode> {
Ok(axum::http::StatusCode::NO_CONTENT)
}
+17
View File
@@ -0,0 +1,17 @@
# API response helpers
The backend now exposes `crate::http::responders`, which wraps common success and
error patterns for routes:
- `ok_json`, `created_json`, `accepted_json` return `JsonResponse<T>` with the
respective status codes.
- `no_content`/`empty` provide shared empty responses.
- `JsonResponse<T>` implements `IntoResponse`, so any handler can return
`AppResult<JsonResponse<T>>` without pairing tuples manually.
- `IntoAppResult`, `RowsAffectedExt`, and friends convert Diesel results into
`AppResult<T>` with consistent `AppError` handling.
When adding new routes, import from `crate::http::responders` instead of
constructing `(StatusCode, Json<T>)` tuples directly. The folders, documents,
auth, capability-set, correspondent, tag, and profile routers now all share
these helpers; WebDAV keeps its bespoke streaming responses for now.