From a6f79dbc7576906f243c93748743dd2d4a00a5b3 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 12:26:29 +0100 Subject: [PATCH] refactor --- backend/src/http/mod.rs | 1 + backend/src/http/responders.rs | 149 ++++++++++++++++++++++++++ backend/src/lib.rs | 1 + backend/src/routes/auth.rs | 41 +++---- backend/src/routes/capability_sets.rs | 53 +++++---- backend/src/routes/correspondents.rs | 35 +++--- backend/src/routes/documents.rs | 81 +++++++------- backend/src/routes/folders.rs | 47 ++++---- backend/src/routes/profile.rs | 19 ++-- backend/src/routes/tags.rs | 64 +++++------ backend/src/utils/db.rs | 35 +----- docs/api_responses.md | 17 +++ 12 files changed, 344 insertions(+), 199 deletions(-) create mode 100644 backend/src/http/mod.rs create mode 100644 backend/src/http/responders.rs create mode 100644 docs/api_responses.md diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs new file mode 100644 index 0000000..3e7b0d6 --- /dev/null +++ b/backend/src/http/mod.rs @@ -0,0 +1 @@ +pub mod responders; diff --git a/backend/src/http/responders.rs b/backend/src/http/responders.rs new file mode 100644 index 0000000..20571c0 --- /dev/null +++ b/backend/src/http/responders.rs @@ -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 { + fn into_app_result(self) -> AppResult; +} + +impl IntoAppResult for Result +where + AppError: From, +{ + fn into_app_result(self) -> AppResult { + self.map_err(AppError::from) + } +} + +/// Extension helpers for optional values to map them into `AppResult`. +pub trait OptionAppResultExt { + fn or_not_found(self) -> AppResult; + fn or_bad_request(self, message: impl Into) -> AppResult; +} + +impl OptionAppResultExt for Option { + fn or_not_found(self) -> AppResult { + self.ok_or_else(AppError::not_found) + } + + fn or_bad_request(self, message: impl Into) -> AppResult { + 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; + fn or_not_found(self) -> AppResult { + self.or_error(AppError::not_found()) + } +} + +impl RowsAffectedExt for usize { + fn or_error(self, error: AppError) -> AppResult { + if self == 0 { + Err(error) + } else { + Ok(self) + } + } +} + +/// Wrapper providing a consistent JSON response with a status code. +pub struct JsonResponse { + status: StatusCode, + payload: T, +} + +impl JsonResponse { + 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 From for JsonResponse { + fn from(value: T) -> Self { + Self::ok(value) + } +} + +impl IntoResponse for JsonResponse +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 { + Ok(status) +} + +/// Helper for returning `204 No Content`. +pub fn no_content() -> AppResult { + empty(StatusCode::NO_CONTENT) +} + +/// Helper for returning JSON payloads with `200 OK`. +pub fn ok_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::ok(value)) +} + +/// Helper for returning JSON payloads with `201 Created`. +pub fn created_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::created(value)) +} + +/// Helper for returning JSON payloads with `202 Accepted`. +pub fn accepted_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::accepted(value)) +} + +/// Standard wrapper for paginated responses. +#[derive(Serialize)] +pub struct PaginatedResponse +where + T: Serialize, + M: Serialize, +{ + pub data: T, + pub meta: M, +} + +pub fn paginated_json(data: T, meta: M) -> AppResult>> +where + T: Serialize, + M: Serialize, +{ + let payload = PaginatedResponse { data, meta }; + Ok(JsonResponse::ok(payload)) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5af55c1..f2aa7e6 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -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; diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 4cda26c..1f497f8 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -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, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { 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, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { 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 { pub async fn list_tenants( State(state): State, auth: Option>>, -) -> AppResult> { +) -> AppResult> { 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, user: AuthenticatedUser, -) -> AppResult> { +) -> AppResult> { 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, ¤t_user)?; - Ok(Json(challenge)) + ok_json(challenge) } #[utoipa::path( @@ -665,7 +666,7 @@ pub async fn passkey_register_finish( State(state): State, user: AuthenticatedUser, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { 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, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { 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, diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs index c6699e1..55ec4cd 100644 --- a/backend/src/routes/capability_sets.rs +++ b/backend/src/routes/capability_sets.rs @@ -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>> { +) -> AppResult>> { 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>> { +) -> AppResult>> { let capabilities = ApiCapability::variants() .iter() .map(|value| value.parse::().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, -) -> AppResult> { +) -> AppResult> { let set = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .find(id) .first::(&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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { let set = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .find(id) .first::(&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::(|conn| { @@ -250,7 +247,7 @@ pub async fn update_capability_set( .filter(cs_dsl::id.ne(working.id)) .first::(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::(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::(&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)] diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs index 6aa1f09..2aa9468 100644 --- a/backend/src/routes/correspondents.rs +++ b/backend/src/routes/correspondents.rs @@ -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>> { +) -> AppResult>> { let correspondents_list: Vec = 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, -) -> AppResult> { +) -> AppResult> { 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, -) -> AppResult> { +) -> AppResult> { 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 = 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() } diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index d194977..9b63163 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -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>> { +) -> AppResult>> { 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::::new()); } quickwit_order = Some(ids.clone()); @@ -480,7 +481,7 @@ pub async fn list_documents( let docs_set: HashSet = docs_without_tags.into_iter().collect(); if docs_set.is_empty() { - return Ok(Json(vec![])); + return ok_json(Vec::::new()); } let new_filter = match &filter_ids { @@ -519,7 +520,7 @@ pub async fn list_documents( let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); if matching_doc_ids.is_empty() { - return Ok(Json(vec![])); + return ok_json(Vec::::new()); } let new_filter = match &filter_ids { @@ -566,7 +567,7 @@ pub async fn list_documents( let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); if matching_doc_ids.is_empty() { - return Ok(Json(vec![])); + return ok_json(Vec::::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::::new()); } let ids_vec: Vec = 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::::new()); } let mut fetched: Vec = 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> { +) -> AppResult> { 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> { +) -> AppResult> { 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 { +) -> AppResult> { let mut file_bytes: Option> = None; let mut original_name: Option = None; let mut content_type: Option = 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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>> { +) -> AppResult>> { 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> { +) -> AppResult> { 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>> { +) -> AppResult>> { 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 = 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> { +) -> AppResult> { 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, -) -> AppResult> { +) -> AppResult> { 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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( diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index dd613a2..2015237 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -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> { +) -> AppResult> { 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, -) -> AppResult> { +) -> AppResult> { 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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> { +) -> AppResult> { 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>> { +) -> AppResult>> { let folders: Vec = folders::table .filter(folders::tenant_id.eq(tenant_id)) .order(sql::("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 { diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index 04d99ed..5921fb7 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -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>> { +) -> AppResult>> { 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>> { +) -> AppResult>> { 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, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult> { 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, -) -> AppResult> { +) -> AppResult> { 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( diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index 6a6121a..b95dd8e 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -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>> { +) -> AppResult>> { let tag_list: Vec = 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, -) -> AppResult> { +) -> AppResult> { 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, -) -> AppResult> { +) -> AppResult> { 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 = 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() } diff --git a/backend/src/utils/db.rs b/backend/src/utils/db.rs index 0b0c72f..2c8ba96 100644 --- a/backend/src/utils/db.rs +++ b/backend/src/utils/db.rs @@ -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 { - fn one(self) -> AppResult; - fn maybe(self) -> AppResult>; -} - -impl EnsureEntity for Result { - fn one(self) -> AppResult { - self.map_err(AppError::from) - } - - fn maybe(self) -> AppResult> { - 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(&self, tenant_id: Uuid, f: F) -> AppResult where @@ -43,17 +24,3 @@ pub fn validate_bulk_ids(ids: &mut Vec, label: &str) -> AppResult<()> { ids.dedup(); Ok(()) } - -pub trait IntoJsonResponse { - fn into_json(self) -> AppResult>; -} - -impl IntoJsonResponse for T { - fn into_json(self) -> AppResult> { - Ok(axum::Json(self)) - } -} - -pub fn no_content() -> AppResult { - Ok(axum::http::StatusCode::NO_CONTENT) -} diff --git a/docs/api_responses.md b/docs/api_responses.md new file mode 100644 index 0000000..997c22b --- /dev/null +++ b/docs/api_responses.md @@ -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` with the + respective status codes. +- `no_content`/`empty` provide shared empty responses. +- `JsonResponse` implements `IntoResponse`, so any handler can return + `AppResult>` without pairing tuples manually. +- `IntoAppResult`, `RowsAffectedExt`, and friends convert Diesel results into + `AppResult` with consistent `AppError` handling. + +When adding new routes, import from `crate::http::responders` instead of +constructing `(StatusCode, Json)` 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.