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 db;
pub mod documents; pub mod documents;
pub mod error; pub mod error;
pub mod http;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod openapi; pub mod openapi;
+22 -19
View File
@@ -28,6 +28,7 @@ use crate::{
AuthenticatedUser, AuthenticatedUser,
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{ok_json, JsonResponse},
models::{ models::{
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
UserSession, UserSession,
@@ -224,7 +225,7 @@ pub async fn login(
pub async fn api_token_exchange( pub async fn api_token_exchange(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>, Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<Json<LoginResponse>> { ) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim(); let secret = payload.api_token.trim();
if secret.is_empty() { if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be 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( #[utoipa::path(
@@ -306,7 +307,7 @@ pub async fn api_token_exchange(
pub async fn signup_start( pub async fn signup_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>, Json(payload): Json<SignupStartRequest>,
) -> AppResult<Json<SignupStartResponse>> { ) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = payload.username.trim(); let username = payload.username.trim();
if username.is_empty() { if username.is_empty() {
return Err(AppError::bad_request("username must not be 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()) .generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?; .map_err(AppError::from)?;
Ok(Json(SignupStartResponse { ok_json(SignupStartResponse {
signup_token, signup_token,
challenge, challenge,
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -590,7 +591,7 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
pub async fn list_tenants( pub async fn list_tenants(
State(state): State<AppState>, State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>, auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> { ) -> AppResult<JsonResponse<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?; let bearer = auth.ok_or_else(AppError::unauthorized)?;
let token = bearer.token(); let token = bearer.token();
@@ -630,7 +631,7 @@ pub async fn list_tenants(
}); });
} }
Ok(Json(TenantListResponse { tenants })) ok_json(TenantListResponse { tenants })
} }
#[utoipa::path( #[utoipa::path(
@@ -642,7 +643,7 @@ pub async fn list_tenants(
pub async fn passkey_register_start( pub async fn passkey_register_start(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
) -> AppResult<Json<RegistrationChallengeResponse>> { ) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = state let service = state
.passkeys .passkeys
.as_ref() .as_ref()
@@ -651,7 +652,7 @@ pub async fn passkey_register_start(
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?; let challenge = service.start_registration(&mut conn, &current_user)?;
Ok(Json(challenge)) ok_json(challenge)
} }
#[utoipa::path( #[utoipa::path(
@@ -665,7 +666,7 @@ pub async fn passkey_register_finish(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>, Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<Json<PasskeySummary>> { ) -> AppResult<JsonResponse<PasskeySummary>> {
let service = state let service = state
.passkeys .passkeys
.as_ref() .as_ref()
@@ -688,7 +689,7 @@ pub async fn passkey_register_finish(
nickname, nickname,
)?; )?;
Ok(Json(PasskeySummary::from(passkey))) ok_json(PasskeySummary::from(passkey))
} }
#[utoipa::path( #[utoipa::path(
@@ -701,7 +702,7 @@ pub async fn passkey_register_finish(
pub async fn passkey_login_start( pub async fn passkey_login_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>, Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<Json<AuthenticationChallengeResponse>> { ) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let service = state let service = state
.passkeys .passkeys
.as_ref() .as_ref()
@@ -718,7 +719,7 @@ pub async fn passkey_login_start(
.first(&mut conn)?; .first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?; let challenge = service.start_authentication(&mut conn, &user)?;
Ok(Json(challenge)) ok_json(challenge)
} }
#[utoipa::path( #[utoipa::path(
@@ -799,11 +800,12 @@ fn complete_login(
}); });
} }
Ok(Json(TenantSelectionResponse { let response = ok_json(TenantSelectionResponse {
access_token: selection_token, access_token: selection_token,
tenants, tenants,
}) })?;
.into_response())
Ok(response.into_response())
} }
fn magic_token_login( fn magic_token_login(
@@ -925,7 +927,7 @@ fn issue_session(
.values(&new_session) .values(&new_session)
.execute(conn)?; .execute(conn)?;
let mut response = Json(LoginResponse { let json = ok_json(LoginResponse {
access_token, access_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60, expires_in: state.config.jwt_expiry_minutes * 60,
@@ -933,8 +935,9 @@ fn issue_session(
id: tenant_id, id: tenant_id,
name: tenant_name, name: tenant_name,
}, },
}) })?;
.into_response();
let mut response = json.into_response();
response.headers_mut().insert( response.headers_mut().insert(
SET_COOKIE, SET_COOKIE,
+25 -28
View File
@@ -11,6 +11,9 @@ use crate::{
}, },
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
models::{ApiCapability, CapabilitySet}, models::{ApiCapability, CapabilitySet},
schema::{ schema::{
api_tokens, api_tokens,
@@ -97,7 +100,7 @@ pub async fn list_capability_sets(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<CapabilitySetResponse>>> { ) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id)) .filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc()) .order(cs_dsl::slug.asc())
@@ -109,7 +112,7 @@ pub async fn list_capability_sets(
responses.push(to_response(set, capabilities)); responses.push(to_response(set, capabilities));
} }
Ok(Json(responses)) ok_json(responses)
} }
#[utoipa::path( #[utoipa::path(
@@ -120,12 +123,12 @@ pub async fn list_capability_sets(
)] )]
pub async fn list_capabilities( pub async fn list_capabilities(
TenantScopedConn { .. }: TenantScopedConn, TenantScopedConn { .. }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiCapability>>> { ) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants() let capabilities = ApiCapability::variants()
.iter() .iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability")) .map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect(); .collect();
Ok(Json(capabilities)) ok_json(capabilities)
} }
#[utoipa::path( #[utoipa::path(
@@ -142,18 +145,15 @@ pub async fn get_capability_set(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> AppResult<Json<CapabilitySetResponse>> { ) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id)) .filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id) .find(id)
.first::<CapabilitySet>(&mut conn) .first::<CapabilitySet>(&mut conn)
.map_err(|err| match err { .into_app_result()?;
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?; let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities))) ok_json(to_response(set, capabilities))
} }
#[utoipa::path( #[utoipa::path(
@@ -170,7 +170,7 @@ pub async fn create_capability_set(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCapabilitySetRequest>, Json(payload): Json<CreateCapabilitySetRequest>,
) -> AppResult<(StatusCode, Json<CapabilitySetResponse>)> { ) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let original_caps = payload.capabilities; let original_caps = payload.capabilities;
let normalized_caps = normalize_capabilities(original_caps.clone())?; let normalized_caps = normalize_capabilities(original_caps.clone())?;
if normalized_caps.is_empty() { 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 set = create_capability_set_record(&mut conn, tenant_id, &slug, original_caps)?;
let response = to_response(set, normalized_caps); let response = to_response(set, normalized_caps);
Ok((StatusCode::CREATED, Json(response))) created_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -215,15 +215,12 @@ pub async fn update_capability_set(
}: TenantScopedConn, }: TenantScopedConn,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>, Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<Json<CapabilitySetResponse>> { ) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id)) .filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id) .find(id)
.first::<CapabilitySet>(&mut conn) .first::<CapabilitySet>(&mut conn)
.map_err(|err| match err { .into_app_result()?;
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
if set.is_system { if set.is_system {
if payload.slug.is_some() || payload.capabilities.is_some() { 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)?; 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| { let set = conn.transaction::<CapabilitySet, AppError, _>(|conn| {
@@ -250,7 +247,7 @@ pub async fn update_capability_set(
.filter(cs_dsl::id.ne(working.id)) .filter(cs_dsl::id.ne(working.id))
.first::<CapabilitySet>(conn) .first::<CapabilitySet>(conn)
.optional() .optional()
.map_err(AppError::from)? .into_app_result()?
.is_some() .is_some()
{ {
return Err(AppError::conflict("slug already exists")); 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()), cs_dsl::updated_at.eq(Utc::now().naive_utc()),
)) ))
.execute(conn) .execute(conn)
.map_err(AppError::from)?; .into_app_result()?;
working.slug = normalized; working.slug = normalized;
} }
@@ -280,11 +277,11 @@ pub async fn update_capability_set(
capability_sets::table capability_sets::table
.find(working.id) .find(working.id)
.first::<CapabilitySet>(conn) .first::<CapabilitySet>(conn)
.map_err(AppError::from) .into_app_result()
})?; })?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?; let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities))) ok_json(to_response(set, capabilities))
} }
#[utoipa::path( #[utoipa::path(
@@ -306,10 +303,7 @@ pub async fn delete_capability_set(
.filter(cs_dsl::tenant_id.eq(tenant_id)) .filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id) .find(id)
.first::<CapabilitySet>(&mut conn) .first::<CapabilitySet>(&mut conn)
.map_err(|err| match err { .into_app_result()?;
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
if set.is_system { if set.is_system {
return Err(AppError::conflict( 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)] #[derive(utoipa::OpenApi)]
+18 -17
View File
@@ -11,10 +11,10 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name}, named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
@@ -71,7 +71,7 @@ pub async fn list_correspondents(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
@@ -94,7 +94,7 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, total)); response.push(build_summary(correspondent, total));
} }
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -111,7 +111,7 @@ pub async fn create_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let name = normalize_name(&payload.name, || { let name = normalize_name(&payload.name, || {
AppError::bad_request("name must not be empty") AppError::bad_request("name must not be empty")
})?; })?;
@@ -140,9 +140,9 @@ pub async fn create_correspondent(
.find(new_id) .find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
build_summary(correspondent, 0).into_json() ok_json(build_summary(correspondent, 0))
} }
#[utoipa::path( #[utoipa::path(
@@ -161,12 +161,12 @@ pub async fn update_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { 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() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; 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(); let mut changeset = CorrespondentChangeset::default();
@@ -217,15 +217,17 @@ pub async fn update_correspondent(
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; 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( #[utoipa::path(
@@ -255,15 +257,14 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
correspondents::table correspondents::table
.filter(correspondents::id.eq(correspondent_id)) .filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() 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}, tags::{assign_tags as assign_tags_to_document, load_tags_for_documents},
}; };
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse};
use crate::jobs::{ use crate::jobs::{
enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT, 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::state::AppState;
use crate::utils::{ use crate::utils::{
db::{no_content, validate_bulk_ids, IntoJsonResponse}, db::validate_bulk_ids,
error::StorageResultExt, error::StorageResultExt,
http::inline_content_disposition, http::inline_content_disposition,
json::{classify_nullable, NullableValue}, json::{classify_nullable, NullableValue},
@@ -372,7 +373,7 @@ pub async fn list_documents(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentResponse>>> { ) -> AppResult<JsonResponse<Vec<DocumentResponse>>> {
let DocumentListQuery { let DocumentListQuery {
folder_id, folder_id,
include_descendants, include_descendants,
@@ -455,7 +456,7 @@ pub async fn list_documents(
})?; })?;
if ids.is_empty() { if ids.is_empty() {
return Ok(Json(vec![])); return ok_json(Vec::<DocumentResponse>::new());
} }
quickwit_order = Some(ids.clone()); 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(); let docs_set: HashSet<Uuid> = docs_without_tags.into_iter().collect();
if docs_set.is_empty() { if docs_set.is_empty() {
return Ok(Json(vec![])); return ok_json(Vec::<DocumentResponse>::new());
} }
let new_filter = match &filter_ids { 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(); let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() { if matching_doc_ids.is_empty() {
return Ok(Json(vec![])); return ok_json(Vec::<DocumentResponse>::new());
} }
let new_filter = match &filter_ids { 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(); let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() { if matching_doc_ids.is_empty() {
return Ok(Json(vec![])); return ok_json(Vec::<DocumentResponse>::new());
} }
let new_filter = match &filter_ids { let new_filter = match &filter_ids {
@@ -581,7 +582,7 @@ pub async fn list_documents(
if let Some(ref set) = filter_ids { if let Some(ref set) = filter_ids {
if set.is_empty() { if set.is_empty() {
return Ok(Json(vec![])); return ok_json(Vec::<DocumentResponse>::new());
} }
let ids_vec: Vec<Uuid> = set.iter().copied().collect(); let ids_vec: Vec<Uuid> = set.iter().copied().collect();
@@ -606,7 +607,7 @@ pub async fn list_documents(
}; };
if relevant_ids.is_empty() { 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)?; 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)?; let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
Ok(Json(response)) ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -648,7 +649,7 @@ pub async fn check_document(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<DocumentCheckResponse>> { ) -> AppResult<JsonResponse<DocumentCheckResponse>> {
let checksum_raw = query.checksum.trim(); let checksum_raw = query.checksum.trim();
if checksum_raw.is_empty() { if checksum_raw.is_empty() {
return Err(AppError::bad_request("checksum must not be empty")); return Err(AppError::bad_request("checksum must not be empty"));
@@ -672,7 +673,7 @@ pub async fn check_document(
.optional()?; .optional()?;
if let Some((document, version)) = record { if let Some((document, version)) = record {
Ok(Json(DocumentCheckResponse { ok_json(DocumentCheckResponse {
exists: true, exists: true,
document_id: Some(document.id), document_id: Some(document.id),
title: Some(document.title.clone()), title: Some(document.title.clone()),
@@ -680,9 +681,9 @@ pub async fn check_document(
version_id: Some(version.id), version_id: Some(version.id),
version_number: Some(version.version_number), version_number: Some(version.version_number),
created_at: Some(to_iso(document.created_at)), created_at: Some(to_iso(document.created_at)),
})) })
} else { } else {
Ok(Json(DocumentCheckResponse { ok_json(DocumentCheckResponse {
exists: false, exists: false,
document_id: None, document_id: None,
title: None, title: None,
@@ -690,7 +691,7 @@ pub async fn check_document(
version_id: None, version_id: None,
version_number: None, version_number: None,
created_at: None, created_at: None,
})) })
} }
} }
@@ -710,7 +711,7 @@ pub async fn get_document(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<DocumentDetailResponse>> { ) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let doc: Document = documents::table let doc: Document = documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_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 assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version); let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse { ok_json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
&state, &state,
user_id, user_id,
@@ -740,7 +741,7 @@ pub async fn get_document(
correspondents_map.remove(&document_id).unwrap_or_default(), correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)), Some((version_response, assets)),
)?, )?,
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -760,7 +761,7 @@ pub async fn upload_document(
tenant_id, user_id, .. tenant_id, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
mut multipart: Multipart, mut multipart: Multipart,
) -> AppResult<impl IntoResponse> { ) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let mut file_bytes: Option<Vec<u8>> = None; let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None; let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None; let mut content_type: Option<String> = None;
@@ -938,7 +939,7 @@ pub async fn upload_document(
reused_existing = false, reused_existing = false,
"document upload succeeded", "document upload succeeded",
); );
(StatusCode::CREATED, Json(detail)).into_response() created_json(detail)?
} }
UploadOutcome::Reused(detail) => { UploadOutcome::Reused(detail) => {
info!( info!(
@@ -948,7 +949,7 @@ pub async fn upload_document(
reused_existing = true, reused_existing = true,
"document upload succeeded", "document upload succeeded",
); );
(StatusCode::OK, Json(detail)).into_response() ok_json(detail)?
} }
}; };
@@ -1012,7 +1013,7 @@ pub async fn reanalyze_selected_documents(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<BulkReanalyzeSelectionRequest>, Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> { ) -> AppResult<JsonResponse<BulkReanalyzeResponse>> {
let BulkReanalyzeSelectionRequest { let BulkReanalyzeSelectionRequest {
mut document_ids, mut document_ids,
force, force,
@@ -1053,7 +1054,7 @@ pub async fn reanalyze_selected_documents(
queued += 1; queued += 1;
} }
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) accepted_json(BulkReanalyzeResponse { queued })
} }
#[utoipa::path( #[utoipa::path(
@@ -1071,7 +1072,7 @@ pub async fn list_document_assets(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentAssetResponse>>> { ) -> AppResult<JsonResponse<Vec<DocumentAssetResponse>>> {
let document: Document = documents::table let document: Document = documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
@@ -1084,7 +1085,7 @@ pub async fn list_document_assets(
drop(conn); drop(conn);
let assets = load_asset_responses(&state, tenant_id, version_id).await?; let assets = load_asset_responses(&state, tenant_id, version_id).await?;
Ok(Json(assets)) ok_json(assets)
} }
#[utoipa::path( #[utoipa::path(
@@ -1103,7 +1104,7 @@ pub async fn get_document_asset(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<DocumentAssetDetailResponse>> { ) -> AppResult<JsonResponse<DocumentAssetDetailResponse>> {
let asset: DocumentAsset = match document_assets::table let asset: DocumentAsset = match document_assets::table
.find(asset_id) .find(asset_id)
.filter(document_assets::tenant_id.eq(tenant_id)) .filter(document_assets::tenant_id.eq(tenant_id))
@@ -1168,7 +1169,7 @@ pub async fn get_document_asset(
return Err(AppError::not_found()); 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( fn presign_disposition_for_asset(
@@ -1193,7 +1194,7 @@ pub async fn list_document_versions(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentVersionResponse>>> { ) -> AppResult<JsonResponse<Vec<DocumentVersionResponse>>> {
let document: Document = documents::table let document: Document = documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
@@ -1212,7 +1213,7 @@ pub async fn list_document_versions(
let versions: Vec<DocumentVersionResponse> = let versions: Vec<DocumentVersionResponse> =
versions.into_iter().map(to_version_response).collect(); versions.into_iter().map(to_version_response).collect();
Ok(Json(versions)) ok_json(versions)
} }
#[utoipa::path( #[utoipa::path(
@@ -1234,7 +1235,7 @@ pub async fn get_document_version(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<DocumentVersionDetailResponse>> { ) -> AppResult<JsonResponse<DocumentVersionDetailResponse>> {
let document: Document = documents::table let document: Document = documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_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 download_path = build_download_path(&state, &document, user_id)?;
let version_core = to_version_response(version); let version_core = to_version_response(version);
Ok(Json(DocumentVersionDetailResponse { ok_json(DocumentVersionDetailResponse {
version: version_core, version: version_core,
assets, assets,
download_path, download_path,
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -1416,7 +1417,7 @@ pub async fn update_document(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<Value>, Json(payload): Json<Value>,
) -> AppResult<Json<DocumentDetailResponse>> { ) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let mut document: Document = documents::table let mut document: Document = documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_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 assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version); let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse { ok_json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
&state, &state,
user_id, user_id,
@@ -1575,7 +1576,7 @@ pub async fn update_document(
correspondents_map.remove(&document_id).unwrap_or_default(), correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)), Some((version_response, assets)),
)?, )?,
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -1686,7 +1687,7 @@ pub async fn bulk_move_documents(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<BulkMoveRequest>, Json(payload): Json<BulkMoveRequest>,
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> { ) -> AppResult<JsonResponse<BulkMoveResponse>> {
let BulkMoveRequest { let BulkMoveRequest {
mut document_ids, mut document_ids,
folder_id, folder_id,
@@ -1759,7 +1760,7 @@ pub async fn bulk_move_documents(
}; };
let body = BulkMoveResponse { updated }; let body = BulkMoveResponse { updated };
Ok((StatusCode::OK, body.into_json()?)) ok_json(body)
} }
#[utoipa::path( #[utoipa::path(
@@ -1866,7 +1867,7 @@ pub async fn bulk_assign_correspondents(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>, Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> { ) -> AppResult<JsonResponse<BulkCorrespondentResponse>> {
if payload.assignments.is_empty() { if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be 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 }; let body = BulkCorrespondentResponse { assigned, removed };
Ok((StatusCode::OK, body.into_json()?)) ok_json(body)
} }
#[utoipa::path( #[utoipa::path(
@@ -2052,7 +2053,7 @@ pub async fn bulk_update_tags(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<BulkTagRequest>, Json(payload): Json<BulkTagRequest>,
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> { ) -> AppResult<JsonResponse<BulkTagResponse>> {
let BulkTagRequest { let BulkTagRequest {
mut document_ids, mut document_ids,
mut tag_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( #[utoipa::path(
+27 -20
View File
@@ -18,6 +18,9 @@ use crate::state::AppState;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
}; };
use super::documents::{hydrate_documents, DocumentResponse}; use super::documents::{hydrate_documents, DocumentResponse};
@@ -141,15 +144,15 @@ pub async fn get_folder(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
let folder: Folder = folders::table let folder: Folder = folders::table
.find(folder_id) .find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?; .first(&mut conn)?;
Ok(Json(FolderResponse { ok_json(FolderResponse {
folder: folder_to_info(folder), folder: folder_to_info(folder),
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -166,7 +169,7 @@ pub async fn ensure_folder_path(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() { if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be 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")) last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
})?; })?;
Ok(Json(FolderResponse { ok_json(FolderResponse {
folder: folder_to_info(target_folder), folder: folder_to_info(target_folder),
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -262,7 +265,7 @@ pub async fn create_folder(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<(StatusCode, Json<FolderResponse>)> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be 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), folder: folder_to_info(folder),
}); };
if created { if created {
Ok((StatusCode::CREATED, response)) created_json(response)
} else { } else {
Ok((StatusCode::OK, response)) ok_json(response)
} }
} }
@@ -359,7 +362,7 @@ pub async fn list_folder_contents(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery { let FolderContentsQuery {
include_documents, include_documents,
sort, sort,
@@ -427,11 +430,11 @@ pub async fn list_folder_contents(
Vec::new() Vec::new()
}; };
Ok(Json(FolderContentsResponse { ok_json(FolderContentsResponse {
folder, folder,
subfolders, subfolders,
documents, documents,
})) })
} }
#[utoipa::path( #[utoipa::path(
@@ -446,7 +449,7 @@ pub async fn list_folder_tree(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<FolderTreeNode>>> { ) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
let folders: Vec<Folder> = folders::table let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC")) .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)) .map(|root_id| build_node(*root_id, &node_map, &children_map))
.collect(); .collect();
Ok(Json(tree)) ok_json(tree)
} }
#[utoipa::path( #[utoipa::path(
@@ -555,12 +558,14 @@ pub async fn delete_folder(
.filter(folders::id.eq(folder_id)) .filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)), .filter(folders::tenant_id.eq(tenant_id)),
) )
.execute(conn)?; .execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(()) Ok(())
})?; })?;
Ok(StatusCode::NO_CONTENT) no_content()
} }
#[utoipa::path( #[utoipa::path(
@@ -677,12 +682,14 @@ pub async fn update_folder(
folders::parent_id.eq(next_parent), folders::parent_id.eq(next_parent),
folders::name.eq(&new_name), folders::name.eq(&new_name),
)) ))
.execute(conn)?; .execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(()) Ok(())
})?; })?;
Ok(StatusCode::NO_CONTENT) no_content()
} }
fn folder_to_info(folder: Folder) -> FolderInfo { fn folder_to_info(folder: Folder) -> FolderInfo {
+10 -9
View File
@@ -18,9 +18,10 @@ use crate::auth::{
TenantScopedConn, TenantScopedConn,
}; };
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
use crate::models::ApiToken; use crate::models::ApiToken;
use crate::state::{AppState, PgPooledConnection}; use crate::state::{AppState, PgPooledConnection};
use crate::utils::{db::no_content, time::to_iso}; use crate::utils::time::to_iso;
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse { pub struct ApiTokenResponse {
@@ -71,14 +72,14 @@ pub async fn list_passkeys(
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<PasskeySummary>>> { ) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = state let service = state
.passkeys .passkeys
.as_ref() .as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(&mut conn, user_id)?; let passkeys = service.list_for_user(&mut conn, user_id)?;
Ok(Json(passkeys)) ok_json(passkeys)
} }
#[utoipa::path( #[utoipa::path(
@@ -94,10 +95,10 @@ pub async fn list_api_tokens(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> { ) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(api_token_to_response).collect(); let responses = tokens.into_iter().map(api_token_to_response).collect();
Ok(Json(responses)) ok_json(responses)
} }
#[utoipa::path( #[utoipa::path(
@@ -115,7 +116,7 @@ pub async fn create_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>, Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> { ) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = match payload.expires_at { let expires_at = match payload.expires_at {
Some(ref value) => Some(parse_timestamp(value)?), Some(ref value) => Some(parse_timestamp(value)?),
None => None, None => None,
@@ -140,7 +141,7 @@ pub async fn create_api_token(
token_info, token_info,
}; };
Ok((StatusCode::CREATED, Json(response))) created_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -158,7 +159,7 @@ pub async fn regenerate_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Path(token_id): Path<Uuid>, 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 issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
let token_info = api_token_to_response(issued.record); let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse { let response = ApiTokenCreatedResponse {
@@ -166,7 +167,7 @@ pub async fn regenerate_api_token(
token_info, token_info,
}; };
Ok(Json(response)) ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
+32 -32
View File
@@ -5,14 +5,16 @@ use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn; use crate::{
use crate::error::{AppError, AppResult}; auth::TenantScopedConn,
use crate::models::{NewTag, Tag}; error::{AppError, AppResult},
use crate::schema::{document_tags, tags}; http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
use crate::utils::{ models::{NewTag, Tag},
db::{no_content, EnsureEntity, IntoJsonResponse}, schema::{document_tags, tags},
json::deserialize_patch_field, utils::{
named_entity::{ensure_name_available, normalize_name}, json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
},
}; };
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@@ -84,7 +86,7 @@ pub async fn list_tags(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> { ) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc()) .order(tags::label.asc())
@@ -108,7 +110,7 @@ pub async fn list_tags(
}) })
.collect(); .collect();
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -125,7 +127,7 @@ pub async fn create_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let label = normalize_name(&payload.label, || { let label = normalize_name(&payload.label, || {
AppError::bad_request("label must not be empty") AppError::bad_request("label must not be empty")
})?; })?;
@@ -155,15 +157,14 @@ pub async fn create_tag(
.find(new_tag.id) .find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -182,12 +183,12 @@ pub async fn update_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateTagRequest>, Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table let existing: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let UpdateTagRequest { label, color } = payload; let UpdateTagRequest { label, color } = payload;
if label.is_none() && color.is_none() { if label.is_none() && color.is_none() {
@@ -195,13 +196,12 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
} });
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -258,12 +258,12 @@ pub async fn update_tag(
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
})); });
} }
let changeset = UpdateTagChangeset { let changeset = UpdateTagChangeset {
@@ -279,26 +279,27 @@ pub async fn update_tag(
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Tag = tags::table let updated: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -328,15 +329,14 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
tags::table tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() 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 uuid::Uuid;
use crate::{ use crate::{
@@ -6,25 +6,6 @@ use crate::{
state::AppState, 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 { impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T> pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where where
@@ -43,17 +24,3 @@ pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
ids.dedup(); ids.dedup();
Ok(()) 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.