6c405e142d
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
113 lines
3.7 KiB
Rust
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)
|
|
}
|
|
}
|