Files
kvitt/server/crates/kvitt-server/src/api/mod.rs
T
jacobh2 6c405e142d Server overhaul: shared helpers, dead code, synchronous tasks::start, stale comments
crate::lock replaces three poison-tolerant lock helpers; the rate limiter reuses auth::bearer_token; the landing page
reuses invites::display_code. Removed: AppError::not_implemented (D-004 placeholder, only its own test used it) and
the unused Mutation::mutation_id/entity_type. tasks::start never awaited or failed: now a plain fn returning AppState.
Milestone-ownership comments (M2a/M3/M4) rewritten to say what the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:43:54 +02:00

113 lines
3.7 KiB
Rust

//! HTTP handlers, one file per area (SPEC §14.7), and the extractors and response types they share. The sync
//! handlers E-31 and E-32 are in `sync.rs`, the protocol itself in `crate::sync`; `rows` and `validate` hold the
//! row access and input rules the handlers, the sync protocol and the importer share.
pub mod accounts;
pub mod apk;
pub mod categories;
pub mod devices;
pub mod health;
pub mod invites;
pub mod landing;
pub mod me;
pub mod pictures;
pub mod proposal;
pub mod rate_limit;
pub mod recurring;
pub mod rows;
pub mod setup;
pub mod sync;
pub mod validate;
use axum::extract::rejection::{JsonRejection, PathRejection};
use axum::extract::{FromRequest, FromRequestParts, Request};
use axum::http::request::Parts;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use serde::de::DeserializeOwned;
use uuid::Uuid;
use crate::error::AppError;
/// The content type of every JSON body (SPEC §14.3.1).
pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
/// A JSON response body with `Content-Type: application/json; charset=utf-8`.
#[derive(Debug, Clone)]
pub struct JsonBody<T>(pub T);
impl<T: Serialize> IntoResponse for JsonBody<T> {
fn into_response(self) -> Response {
match serde_json::to_vec(&self.0) {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(JSON_CONTENT_TYPE),
);
response
}
Err(e) => {
tracing::error!(detail = %e, "response serialisation failed");
(StatusCode::INTERNAL_SERVER_ERROR, "internal").into_response()
}
}
}
}
/// A JSON request body whose rejections map to the error codes of SPEC §14.3.2: malformed JSON, missing fields and
/// wrong types are 400 `invalid_request`; a body over the limit is 413 `payload_too_large`.
#[derive(Debug, Clone)]
pub struct ApiJson<T>(pub T);
impl<T, S> FromRequest<S> for ApiJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
match axum::Json::<T>::from_request(request, state).await {
Ok(axum::Json(value)) => Ok(Self(value)),
Err(rejection) => Err(json_rejection(rejection)),
}
}
}
fn json_rejection(rejection: JsonRejection) -> AppError {
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
return AppError::new(
crate::error::ErrorCode::PayloadTooLarge,
"request body too large",
);
}
match rejection {
JsonRejection::MissingJsonContentType(_) => {
AppError::invalid_request("Content-Type must be application/json")
}
other => AppError::invalid_request(other.body_text()),
}
}
/// One UUID path segment. Anything that is not a UUID is 404 `not_found` (SPEC §14.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IdPath(pub Uuid);
impl<S: Send + Sync> FromRequestParts<S> for IdPath {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let axum::extract::Path(text): axum::extract::Path<String> =
axum::extract::Path::from_request_parts(parts, state)
.await
.map_err(|_: PathRejection| AppError::not_found())?;
Uuid::parse_str(&text)
.ok()
.filter(|id| id.hyphenated().to_string() == text)
.map(Self)
.ok_or_else(AppError::not_found)
}
}