M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
//! HTTP handlers, one file per area (SPEC §14.7), and the extractors and response types they share.
|
||||
//!
|
||||
//! M2a implements `health` (E-1) and `setup` (E-2); every other handler is registered and annotated but returns 501
|
||||
//! `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) fill in the bodies.
|
||||
|
||||
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 setup;
|
||||
pub mod sync;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of every handler that is registered but not yet implemented (501 `not_implemented`).
|
||||
pub fn not_implemented() -> AppError {
|
||||
AppError::not_implemented()
|
||||
}
|
||||
Reference in New Issue
Block a user