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,103 @@
|
||||
//! Accounts: E-18 to E-22 (FR-6).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
|
||||
use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/accounts",
|
||||
operation_id = "createAccount",
|
||||
summary = "E-18 Create account",
|
||||
request_body(content = CreateAccountRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 201, description = "The new account.", body = Account, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn create_account(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/accounts/{account_id}",
|
||||
operation_id = "patchAccount",
|
||||
summary = "E-19 Rename, change default ratio",
|
||||
params(
|
||||
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
request_body(content = PatchAccountRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "The updated account.", body = Account, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/accounts/{account_id}/archive",
|
||||
operation_id = "archiveAccount",
|
||||
summary = "E-20 Archive account",
|
||||
description = "No body. Idempotent.",
|
||||
params(
|
||||
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/accounts/{account_id}/unarchive",
|
||||
operation_id = "unarchiveAccount",
|
||||
summary = "E-21 Unarchive account",
|
||||
description = "No body. Idempotent.",
|
||||
params(
|
||||
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/accounts/{account_id}",
|
||||
operation_id = "deleteAccount",
|
||||
summary = "E-22 Soft delete when empty",
|
||||
description = "Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5).",
|
||||
params(
|
||||
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The tombstone.", body = Account, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn delete_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::NotFound;
|
||||
use crate::wire::{ApkBytes, ApkLatest, IntSchema};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/apk/latest",
|
||||
operation_id = "getApkLatest",
|
||||
summary = "E-5 Latest APK metadata",
|
||||
security(()),
|
||||
responses(
|
||||
(status = 200, description = "The content of `latest.json` (SPEC §10.6), read on every request.", body = ApkLatest, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn get_apk_latest() -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/apk/files/{file_name}",
|
||||
operation_id = "getApkFile",
|
||||
summary = "E-6 APK download",
|
||||
security(()),
|
||||
params(
|
||||
("file_name" = String, Path, description = "Served only when it matches `^kvitt-[0-9]+\\.apk$` and exists (SPEC §10.6)."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The APK bytes.", body = ApkBytes, content_type = "application/vnd.android.package-archive", headers(("Content-Length" = IntSchema))),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn get_apk_file() -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Categories: E-23 to E-27 (FR-7).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
|
||||
use crate::wire::{Category, CreateCategoryRequest, PatchCategoryRequest, UuidSchema};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/categories",
|
||||
operation_id = "createCategory",
|
||||
summary = "E-23 Create category",
|
||||
description = "New categories get `sort_order` = current maximum + 10 (AC-7.3). `icon` and `color` are tokens of SPEC §9.7.",
|
||||
request_body(content = CreateCategoryRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 201, description = "The new category.", body = Category, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn create_category(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/categories/{category_id}",
|
||||
operation_id = "patchCategory",
|
||||
summary = "E-24 Rename, icon, colour, order, reset name",
|
||||
description = "`name: null` resets a default category's name. Reordering several categories is one PATCH per category whose `sort_order` changes.",
|
||||
params(
|
||||
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
request_body(content = PatchCategoryRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "The updated category.", body = Category, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn patch_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/categories/{category_id}/archive",
|
||||
operation_id = "archiveCategory",
|
||||
summary = "E-25 Archive category",
|
||||
description = "No body.",
|
||||
params(
|
||||
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn archive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/categories/{category_id}/unarchive",
|
||||
operation_id = "unarchiveCategory",
|
||||
summary = "E-26 Unarchive category",
|
||||
description = "No body.",
|
||||
params(
|
||||
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn unarchive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/categories/{category_id}",
|
||||
operation_id = "deleteCategory",
|
||||
summary = "E-27 Soft delete when unused and not default",
|
||||
params(
|
||||
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The tombstone.", body = Category, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn delete_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Devices: E-15 list, E-16 revoke, E-17 push token (FR-3, SPEC §8.4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound};
|
||||
use crate::wire::{DeviceList, PushTokenRequest, UuidSchema};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/devices",
|
||||
operation_id = "listDevices",
|
||||
summary = "E-15 List devices (admin all, member own)",
|
||||
responses(
|
||||
(status = 200, description = "Ordered by `created_at`, revoked devices included (with `revoked_at`).", body = DeviceList, content_type = "application/json; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn list_devices(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/devices/{device_id}",
|
||||
operation_id = "revokeDevice",
|
||||
summary = "E-16 Revoke a device",
|
||||
description = "Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4).",
|
||||
params(
|
||||
("device_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Revoked."),
|
||||
(status = 403, response = Forbidden),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn revoke_device(_device: AuthDevice, IdPath(_device_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/devices/current/push-token",
|
||||
operation_id = "putPushToken",
|
||||
summary = "E-17 Set or clear this device's FCM token",
|
||||
request_body(content = PushTokenRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 204, description = "Stored (or cleared)."),
|
||||
(status = 400, response = BadRequest),
|
||||
)
|
||||
)]
|
||||
pub async fn put_push_token(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! E-1 `GET /health` (FR-20, SPEC §14.3.4).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::JsonBody;
|
||||
use crate::wire::{Health, HealthStatus};
|
||||
use crate::{AppState, SERVER_VERSION, db};
|
||||
|
||||
/// The database check must answer well within the 100 ms budget of AC-20.1; beyond this it counts as failed.
|
||||
const DB_CHECK_TIMEOUT: Duration = Duration::from_millis(80);
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
operation_id = "getHealth",
|
||||
summary = "E-1 Liveness and DB check",
|
||||
security(()),
|
||||
responses(
|
||||
(status = 200, description = "Server and database are up.", body = Health,
|
||||
content_type = "application/json; charset=utf-8"),
|
||||
(status = 503, description = "The database check (`SELECT 1`) failed: the `Health` shape with `status` \
|
||||
`degraded` and `db_ok` false. An `Error` with code `unavailable` when the server is shutting down.",
|
||||
body = HealthOrError, content_type = "application/json; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_health(State(state): State<AppState>) -> Response {
|
||||
let db_ok = matches!(
|
||||
tokio::time::timeout(DB_CHECK_TIMEOUT, db::ping(&state.db)).await,
|
||||
Ok(Ok(()))
|
||||
);
|
||||
let body = Health {
|
||||
status: if db_ok {
|
||||
HealthStatus::Ok
|
||||
} else {
|
||||
HealthStatus::Degraded
|
||||
},
|
||||
server_version: SERVER_VERSION.to_owned(),
|
||||
schema_version: db::SCHEMA_VERSION,
|
||||
db_ok,
|
||||
};
|
||||
let status = if db_ok {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
tracing::warn!(error_code = "unavailable", "health: database check failed");
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
};
|
||||
(status, JsonBody(body)).into_response()
|
||||
}
|
||||
|
||||
/// The 503 body of E-1: the health object, or an `Error` from a server that is shutting down (SPEC §14.3.4 E-1).
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum HealthOrError {
|
||||
Health(Health),
|
||||
Error(crate::error::ErrorBody),
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AdminDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable};
|
||||
use crate::wire::{
|
||||
AcceptInviteRequest, AcceptInviteResponse, CreateInviteRequest, Invite, InviteList, UuidSchema,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/invites/accept",
|
||||
operation_id = "acceptInvite",
|
||||
summary = "E-3 Pair a device with an invite code (all three invite kinds)",
|
||||
security(()),
|
||||
request_body(content = AcceptInviteRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "Paired. The token is shown only here.", body = AcceptInviteResponse, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn accept_invite() -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/invites",
|
||||
operation_id = "createInvite",
|
||||
summary = "E-12 Create a partner or device invite (admin)",
|
||||
description = "Creating a `partner` invite cancels any pending one (AC-2.1).",
|
||||
request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>`).", body = Invite, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 403, response = Forbidden),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn create_invite(_admin: AdminDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/invites",
|
||||
operation_id = "listInvites",
|
||||
summary = "E-13 List pending invites (admin)",
|
||||
responses(
|
||||
(status = 200, description = "Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.", body = InviteList, content_type = "application/json; charset=utf-8"),
|
||||
(status = 403, response = Forbidden),
|
||||
)
|
||||
)]
|
||||
pub async fn list_invites(_admin: AdminDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/invites/{invite_id}",
|
||||
operation_id = "cancelInvite",
|
||||
summary = "E-14 Cancel an invite (admin)",
|
||||
description = "Idempotent for an already cancelled invite.",
|
||||
params(
|
||||
("invite_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Cancelled."),
|
||||
(status = 403, response = Forbidden),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn cancel_invite(_admin: AdminDevice, IdPath(_invite_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! E-4 invite landing page (AC-2.5).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/i/{code}",
|
||||
operation_id = "getInviteLanding",
|
||||
summary = "E-4 Invite landing page (HTML)",
|
||||
description = "HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when `Accept-Language` prefers `sv`, English otherwise.",
|
||||
security(()),
|
||||
params(
|
||||
("code" = String, Path),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"),
|
||||
(status = 404, description = "`code` not 1 to 32 characters: an HTML 404 page.", body = String, content_type = "text/html; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_invite_landing() -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! E-7 `GET /me` and E-8 `PATCH /me` (FR-4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Unprocessable};
|
||||
use crate::wire::{MeResponse, PatchMeRequest, User};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me",
|
||||
operation_id = "getMe",
|
||||
summary = "E-7 Current user, device and household users",
|
||||
responses(
|
||||
(status = 200, description = "The caller's user and device.", body = MeResponse, content_type = "application/json; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_me(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/me",
|
||||
operation_id = "patchMe",
|
||||
summary = "E-8 Change own display name",
|
||||
request_body(content = PatchMeRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "The updated user.", body = User, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn patch_me(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Pictures: E-9 upload, E-10 remove, E-11 download (AC-4.4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{
|
||||
BadRequest, NotFound, PayloadTooLarge, Unprocessable, UnsupportedMediaType,
|
||||
};
|
||||
use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidSchema, WebpBytes};
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/me/picture",
|
||||
operation_id = "putMePicture",
|
||||
summary = "E-9 Upload own picture",
|
||||
description = "JPEG, PNG or WebP up to 5 MiB and 8000 × 8000 px; processed per AC-4.4.",
|
||||
request_body(content((JpegBytes = "image/jpeg"), (PngBytes = "image/png"), (WebpBytes = "image/webp"))),
|
||||
responses(
|
||||
(status = 200, description = "The user with the new `picture_version`.", body = User, content_type = "application/json; charset=utf-8"),
|
||||
(status = 413, response = PayloadTooLarge),
|
||||
(status = 415, response = UnsupportedMediaType),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn put_me_picture(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/me/picture",
|
||||
operation_id = "deleteMePicture",
|
||||
summary = "E-10 Remove own picture",
|
||||
description = "Idempotent.",
|
||||
responses(
|
||||
(status = 200, description = "The user with `picture_version` null.", body = User, content_type = "application/json; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn delete_me_picture(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/users/{user_id}/picture",
|
||||
operation_id = "getUserPicture",
|
||||
summary = "E-11 Download a user's picture",
|
||||
params(
|
||||
("user_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
("size" = Option<PictureSizeSchema>, Query, description = "Other values are `invalid_request`."),
|
||||
("v" = Option<IntSchema>, Query, description = "The `picture_version`; a cache key only, ignored by the server."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The square JPEG.", body = JpegBytes, content_type = "image/jpeg", headers(("ETag" = String, description = "`\"<picture_version>\"`"), ("Cache-Control" = String, description = "`private, max-age=31536000, immutable`"))),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn get_user_picture(_device: AuthDevice, IdPath(_user_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! E-33 settlement allocation proposal (SPEC §9.4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, Unprocessable};
|
||||
use crate::wire::{ProposalRequest, ProposalResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/settlements/proposal",
|
||||
operation_id = "proposeSettlement",
|
||||
summary = "E-33 Server-side allocation proposal",
|
||||
description = "Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows.",
|
||||
request_body(content = ProposalRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "The proposal.", body = ProposalResponse, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn propose_settlement(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Rate limiting (SPEC §8.6). M3 owns this file; in M2a the layer lets every request through.
|
||||
|
||||
use tower::layer::util::Identity;
|
||||
|
||||
/// The rate-limit layer `router.rs` attaches to every route.
|
||||
pub fn layer() -> Identity {
|
||||
Identity::new()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Recurring templates: E-28 to E-30 (FR-9, SPEC §9.5).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
|
||||
use crate::wire::{
|
||||
CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, UuidSchema,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/recurring-templates",
|
||||
operation_id = "createRecurringTemplate",
|
||||
summary = "E-28 Create recurring template",
|
||||
description = "If a period is already due (SPEC §9.5), the server generates it before responding.",
|
||||
request_body(content = CreateRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 201, description = "The new template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn create_recurring_template(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/recurring-templates/{template_id}",
|
||||
operation_id = "patchRecurringTemplate",
|
||||
summary = "E-29 Edit, pause, resume",
|
||||
description = "Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.",
|
||||
params(
|
||||
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
request_body(content = PatchRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "The updated template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 404, response = NotFound),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn patch_recurring_template(
|
||||
_device: AuthDevice,
|
||||
IdPath(_template_id): IdPath,
|
||||
) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/recurring-templates/{template_id}",
|
||||
operation_id = "deleteRecurringTemplate",
|
||||
summary = "E-30 Soft delete recurring template",
|
||||
params(
|
||||
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "The tombstone.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn delete_recurring_template(
|
||||
_device: AuthDevice,
|
||||
IdPath(_template_id): IdPath,
|
||||
) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! E-2 `GET /setup/status` (AC-1.5): whether the household has been bootstrapped, that is whether the
|
||||
//! `admin_setup` invite has been accepted and the admin user exists.
|
||||
|
||||
use axum::extract::State;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::JsonBody;
|
||||
use crate::error::AppError;
|
||||
use crate::wire::SetupStatus;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/setup/status",
|
||||
operation_id = "getSetupStatus",
|
||||
summary = "E-2 Whether the household has been bootstrapped",
|
||||
security(()),
|
||||
responses(
|
||||
(status = 200, description = "`initialized` is true once the admin_setup invite has been accepted (AC-1.5).",
|
||||
body = SetupStatus, content_type = "application/json; charset=utf-8"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_setup_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<JsonBody<SetupStatus>, AppError> {
|
||||
let initialized: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(JsonBody(SetupStatus { initialized }))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Sync: E-31 pull and E-32 push (SPEC §14.4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M4 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::openapi::responses::{BadRequest, Conflict, PayloadTooLarge, Unprocessable};
|
||||
use crate::wire::{SyncLimitSchema, SyncPullResponse, SyncPushRequest, SyncPushResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/sync",
|
||||
operation_id = "syncPull",
|
||||
summary = "E-31 Pull changes since a cursor",
|
||||
description = "SPEC §14.4.2.",
|
||||
params(
|
||||
("since" = i64, Query, description = "The highest `server_version` the phone has applied (0 initially)."),
|
||||
("limit" = Option<SyncLimitSchema>, Query, description = "Rows per page, 1 to 1000 (`invalid_request` otherwise)."),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "One page. Every array is present (possibly empty) and sorted by `server_version`.", body = SyncPullResponse, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn sync_pull(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sync",
|
||||
operation_id = "syncPush",
|
||||
summary = "E-32 Push local expense and settlement changes",
|
||||
description = "SPEC §14.4.3 and §14.4.4. 1 to 100 mutations, body at most 1 MiB. One result per mutation, in request order.",
|
||||
request_body(content = SyncPushRequest, content_type = "application/json; charset=utf-8"),
|
||||
responses(
|
||||
(status = 200, description = "Per-mutation results.", body = SyncPushResponse, content_type = "application/json; charset=utf-8"),
|
||||
(status = 400, response = BadRequest),
|
||||
(status = 409, response = Conflict),
|
||||
(status = 413, response = PayloadTooLarge),
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn sync_push(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
}
|
||||
Reference in New Issue
Block a user