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:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
+116
View File
@@ -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()
}