Files
kvitt/server/crates/kvitt-server/src/router.rs
T
jacobh2 c942d73c66 Server overhaul: clear clippy::pedantic wins
Taken from a -W clippy::pedantic scan, no allow added: map_or/map_or_else, method references instead of closures,
clone_from, write! instead of push_str(format!), borrowed Option<&str>, push_of without a needless Option, the
envelope's entity moved instead of cloned, the scheduler's plan() no longer a needless async fn (its doc now says
what it does on failure), sort_unstable, digit separators and inline format args in tests. Not taken: missing
# Errors/# Panics docs, must_use, doc backticks, assert!(x.is_empty()) in tests, too_many_lines.

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

159 lines
6.1 KiB
Rust

//! The router: every route of SPEC §7 and the layers of SPEC §14.3.1 and §8.6. The handlers are in `api/*.rs`.
use std::time::Instant;
use axum::Router;
use axum::extract::{DefaultBodyLimit, MatchedPath, Path, RawQuery, Request, State};
use axum::http::{HeaderMap, 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))
// `/i/` with an empty code: the landing page's HTML 404, not the JSON fallback.
.route("/i/", get(empty_invite_code))
.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(&state))
.layer(middleware::from_fn(log_request))
.layer(middleware::map_response(add_server_version))
.with_state(state)
}
/// `GET /i/`: E-4 with an empty code, which the landing handler answers with its HTML 404 page.
async fn empty_invite_code(state: State<AppState>, headers: HeaderMap) -> Response {
landing::get_invite_landing(state, headers, Path(String::new()), RawQuery(None)).await
}
/// 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_or_else(|| "<unmatched>".to_owned(), |p| p.as_str().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_or("", |c| c.0.as_str());
span.in_scope(|| {
tracing::info!(status, latency_ms, error_code, "request");
});
response.into_response()
}