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,155 @@
|
||||
//! The router: every route of SPEC §7 and the layers of SPEC §14.3.1 and §8.6.
|
||||
//!
|
||||
//! M2a owns this file. M3 and M4 fill in handler bodies in their own `api/*.rs` files; the routes are all here.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{DefaultBodyLimit, MatchedPath, Request};
|
||||
use axum::http::HeaderValue;
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
use crate::api::{
|
||||
accounts, apk, categories, devices, health, invites, landing, me, pictures, proposal,
|
||||
rate_limit, recurring, setup, sync,
|
||||
};
|
||||
use crate::error::{AppError, LoggedErrorCode};
|
||||
use crate::{AppState, SERVER_VERSION, auth};
|
||||
|
||||
/// Request body limit (SPEC §8.6).
|
||||
pub const BODY_LIMIT_BYTES: usize = 1024 * 1024;
|
||||
/// Request body limit of `PUT /me/picture` (SPEC §8.6).
|
||||
pub const PICTURE_BODY_LIMIT_BYTES: usize = 5 * 1024 * 1024;
|
||||
/// Response header carrying the crate version (SPEC §14.3.1).
|
||||
pub const SERVER_VERSION_HEADER: &str = "x-kvitt-server-version";
|
||||
|
||||
/// Builds the application.
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// E-1 to E-6: no authentication, no protocol check.
|
||||
let public = Router::new()
|
||||
.route("/health", get(health::get_health))
|
||||
.route("/setup/status", get(setup::get_setup_status))
|
||||
.route("/invites/accept", post(invites::accept_invite))
|
||||
.route("/i/{code}", get(landing::get_invite_landing))
|
||||
.route("/apk/latest", get(apk::get_apk_latest))
|
||||
.route("/apk/files/{file_name}", get(apk::get_apk_file));
|
||||
|
||||
// E-7 to E-33: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`.
|
||||
let authenticated = Router::new()
|
||||
.route("/me", get(me::get_me).patch(me::patch_me))
|
||||
.route(
|
||||
"/me/picture",
|
||||
put(pictures::put_me_picture)
|
||||
.layer(DefaultBodyLimit::max(PICTURE_BODY_LIMIT_BYTES))
|
||||
.delete(pictures::delete_me_picture),
|
||||
)
|
||||
.route("/users/{user_id}/picture", get(pictures::get_user_picture))
|
||||
.route(
|
||||
"/invites",
|
||||
post(invites::create_invite).get(invites::list_invites),
|
||||
)
|
||||
.route(
|
||||
"/invites/{invite_id}",
|
||||
axum::routing::delete(invites::cancel_invite),
|
||||
)
|
||||
.route("/devices", get(devices::list_devices))
|
||||
.route(
|
||||
"/devices/{device_id}",
|
||||
axum::routing::delete(devices::revoke_device),
|
||||
)
|
||||
.route("/devices/current/push-token", put(devices::put_push_token))
|
||||
.route("/accounts", post(accounts::create_account))
|
||||
.route(
|
||||
"/accounts/{account_id}",
|
||||
axum::routing::patch(accounts::patch_account).delete(accounts::delete_account),
|
||||
)
|
||||
.route(
|
||||
"/accounts/{account_id}/archive",
|
||||
post(accounts::archive_account),
|
||||
)
|
||||
.route(
|
||||
"/accounts/{account_id}/unarchive",
|
||||
post(accounts::unarchive_account),
|
||||
)
|
||||
.route("/categories", post(categories::create_category))
|
||||
.route(
|
||||
"/categories/{category_id}",
|
||||
axum::routing::patch(categories::patch_category).delete(categories::delete_category),
|
||||
)
|
||||
.route(
|
||||
"/categories/{category_id}/archive",
|
||||
post(categories::archive_category),
|
||||
)
|
||||
.route(
|
||||
"/categories/{category_id}/unarchive",
|
||||
post(categories::unarchive_category),
|
||||
)
|
||||
.route(
|
||||
"/recurring-templates",
|
||||
post(recurring::create_recurring_template),
|
||||
)
|
||||
.route(
|
||||
"/recurring-templates/{template_id}",
|
||||
axum::routing::patch(recurring::patch_recurring_template)
|
||||
.delete(recurring::delete_recurring_template),
|
||||
)
|
||||
.route("/sync", get(sync::sync_pull).post(sync::sync_push))
|
||||
.route("/settlements/proposal", post(proposal::propose_settlement))
|
||||
.route_layer(middleware::from_fn(auth::require_protocol));
|
||||
|
||||
Router::new()
|
||||
.merge(public)
|
||||
.merge(authenticated)
|
||||
.fallback(fallback)
|
||||
.layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES))
|
||||
.layer(rate_limit::layer()) // M3: the rate limiter of SPEC §8.6 attaches here (src/api/rate_limit.rs).
|
||||
.layer(middleware::from_fn(log_request))
|
||||
.layer(middleware::map_response(add_server_version))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Unknown paths: 404 `not_found` in the error shape.
|
||||
async fn fallback() -> AppError {
|
||||
AppError::not_found()
|
||||
}
|
||||
|
||||
/// Every response carries `X-Kvitt-Server-Version` (SPEC §14.3.1).
|
||||
async fn add_server_version(mut response: Response) -> Response {
|
||||
response.headers_mut().insert(
|
||||
SERVER_VERSION_HEADER,
|
||||
HeaderValue::from_static(SERVER_VERSION),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// One log line per request with the fields SPEC §8.7 allows: method, route template (never the raw path, which can
|
||||
/// hold invite codes), status, latency, device id (recorded by the auth extractor) and error code.
|
||||
async fn log_request(request: Request, next: Next) -> Response {
|
||||
let method = request.method().to_string();
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|p| p.as_str().to_owned())
|
||||
.unwrap_or_else(|| "<unmatched>".to_owned());
|
||||
let span = tracing::info_span!(
|
||||
"request",
|
||||
method = %method,
|
||||
route = %route,
|
||||
device_id = tracing::field::Empty
|
||||
);
|
||||
let started = Instant::now();
|
||||
let response = tracing::Instrument::instrument(next.run(request), span.clone()).await;
|
||||
let latency_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let status = response.status().as_u16();
|
||||
let error_code = response
|
||||
.extensions()
|
||||
.get::<LoggedErrorCode>()
|
||||
.map(|c| c.0.as_str())
|
||||
.unwrap_or("");
|
||||
span.in_scope(|| {
|
||||
tracing::info!(status, latency_ms, error_code, "request");
|
||||
});
|
||||
response.into_response()
|
||||
}
|
||||
Reference in New Issue
Block a user