06f48ef9e9
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>
389 lines
14 KiB
Rust
389 lines
14 KiB
Rust
//! The utoipa OpenAPI document. `contracts/openapi.yaml` is the committed contract; `tests/openapi_contract.rs`
|
|
//! asserts that [`document`] equals it (SPEC §14.3, §11.3).
|
|
//!
|
|
//! Per-operation facts (path, method, operation id, summary, parameters, body, success response, the error
|
|
//! responses specific to the operation) live in the `#[utoipa::path]` annotation of each handler. The rules that
|
|
//! hold for every operation are applied once here, by [`CommonRules`], so they cannot drift per handler:
|
|
//!
|
|
//! * SPEC §14.3.1 headers: every operation takes `X-Kvitt-Protocol` (required on authenticated operations, optional
|
|
//! on the unauthenticated ones), `X-Kvitt-App-Version` and `Accept-Language`; every response carries
|
|
//! `X-Kvitt-Server-Version`.
|
|
//! * SPEC §14.3.2 always-possible errors: `401`, `426`, `429`, `500`, `503` on authenticated operations and `429`,
|
|
//! `500`, `503` on unauthenticated ones, unless the operation documents its own.
|
|
//! * Security: the document-level requirement is `device` (bearer, SPEC §8.2); unauthenticated operations declare
|
|
//! `security(())` in their annotation, which becomes an empty `security: []`.
|
|
//! * Every documented response header is always sent, so each is marked `required: true`. utoipa's `Header` type
|
|
//! has no `required` field; the keyword is written through the header's extension map.
|
|
//! * `dependentRequired` of `PatchRecurringTemplateRequest` (E-29: `split_mode` and `splits` together), for which
|
|
//! utoipa has no attribute, is written the same way.
|
|
|
|
use utoipa::openapi::extensions::Extensions;
|
|
use utoipa::openapi::header::{Header, HeaderBuilder};
|
|
use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn};
|
|
use utoipa::openapi::response::{Response, ResponseBuilder};
|
|
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
|
|
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme};
|
|
use utoipa::openapi::server::Server;
|
|
use utoipa::openapi::{Content, Ref, RefOr, Required};
|
|
use utoipa::{Modify, OpenApi};
|
|
|
|
use crate::api;
|
|
use crate::wire;
|
|
|
|
/// The document info description (SPEC §14.3.1 common rules).
|
|
const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household (SPEC.md). Money is integer öre, \
|
|
timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \
|
|
strings. Common rules: SPEC §14.3.1.";
|
|
|
|
/// The production base URL (SPEC §7).
|
|
pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443";
|
|
|
|
const JSON: &str = api::JSON_CONTENT_TYPE;
|
|
const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
|
|
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
info(title = "Kvitt", version = "1"),
|
|
paths(
|
|
api::health::get_health,
|
|
api::setup::get_setup_status,
|
|
api::invites::accept_invite,
|
|
api::landing::get_invite_landing,
|
|
api::apk::get_apk_latest,
|
|
api::apk::get_apk_file,
|
|
api::me::get_me,
|
|
api::me::patch_me,
|
|
api::pictures::put_me_picture,
|
|
api::pictures::delete_me_picture,
|
|
api::pictures::get_user_picture,
|
|
api::invites::create_invite,
|
|
api::invites::list_invites,
|
|
api::invites::cancel_invite,
|
|
api::devices::list_devices,
|
|
api::devices::revoke_device,
|
|
api::devices::put_push_token,
|
|
api::accounts::create_account,
|
|
api::accounts::patch_account,
|
|
api::accounts::archive_account,
|
|
api::accounts::unarchive_account,
|
|
api::accounts::delete_account,
|
|
api::categories::create_category,
|
|
api::categories::patch_category,
|
|
api::categories::archive_category,
|
|
api::categories::unarchive_category,
|
|
api::categories::delete_category,
|
|
api::recurring::create_recurring_template,
|
|
api::recurring::patch_recurring_template,
|
|
api::recurring::delete_recurring_template,
|
|
api::sync::sync_pull,
|
|
api::sync::sync_push,
|
|
api::proposal::propose_settlement,
|
|
),
|
|
components(
|
|
schemas(
|
|
crate::error::ErrorCode,
|
|
crate::error::ErrorBody,
|
|
wire::RejectionReason,
|
|
wire::AuditEntry,
|
|
wire::UuidSchema,
|
|
wire::IntSchema,
|
|
wire::PictureSizeSchema,
|
|
wire::SyncLimitSchema,
|
|
),
|
|
responses(
|
|
responses::BadRequest,
|
|
responses::Unauthorized,
|
|
responses::Forbidden,
|
|
responses::NotFound,
|
|
responses::Conflict,
|
|
responses::PayloadTooLarge,
|
|
responses::UnsupportedMediaType,
|
|
responses::Unprocessable,
|
|
responses::ProtocolUnsupported,
|
|
responses::RateLimited,
|
|
responses::Internal,
|
|
responses::NotImplemented,
|
|
responses::Unavailable,
|
|
),
|
|
),
|
|
modifiers(&CommonRules),
|
|
)]
|
|
pub struct ApiDoc;
|
|
|
|
/// The generated document.
|
|
pub fn document() -> utoipa::openapi::OpenApi {
|
|
ApiDoc::openapi()
|
|
}
|
|
|
|
/// A response header that is always sent (`required: true`, see the module docs).
|
|
pub fn required_header(header: HeaderBuilder) -> Header {
|
|
let mut header = header.build();
|
|
mark_required(&mut header);
|
|
header
|
|
}
|
|
|
|
fn mark_required(header: &mut Header) {
|
|
header
|
|
.extensions
|
|
.get_or_insert_with(Extensions::default)
|
|
.insert("required".to_owned(), serde_json::Value::Bool(true));
|
|
}
|
|
|
|
fn header_ref(name: &str) -> Ref {
|
|
Ref::new(format!("#/components/headers/{name}"))
|
|
}
|
|
|
|
fn plain_schema(t: Type) -> ObjectBuilder {
|
|
ObjectBuilder::new().schema_type(SchemaType::Type(t))
|
|
}
|
|
|
|
/// The reusable responses of `components.responses`, each an `Error` body with the server version header.
|
|
pub mod responses {
|
|
use super::*;
|
|
|
|
pub(super) fn error_response(description: &str, retry_after: bool) -> Response {
|
|
let mut builder = ResponseBuilder::new()
|
|
.description(description)
|
|
.header_ref(SERVER_VERSION_HEADER, header_ref("ServerVersion"))
|
|
.content(JSON, Content::new(Some(Ref::from_schema_name("Error"))));
|
|
if retry_after {
|
|
builder = builder.header_ref("Retry-After", header_ref("RetryAfter"));
|
|
}
|
|
builder.build()
|
|
}
|
|
|
|
macro_rules! error_responses {
|
|
($($name:ident => $description:literal $(, $retry:literal)?;)*) => {$(
|
|
#[doc = $description]
|
|
pub struct $name;
|
|
|
|
impl<'r> utoipa::ToResponse<'r> for $name {
|
|
fn response() -> (&'r str, RefOr<Response>) {
|
|
let retry_after = false $(|| $retry)?;
|
|
(stringify!($name), RefOr::T(error_response($description, retry_after)))
|
|
}
|
|
}
|
|
)*};
|
|
}
|
|
|
|
error_responses! {
|
|
BadRequest => "`invalid_request` or `invite_invalid`.";
|
|
Unauthorized => "`unauthorized` or `device_revoked`.";
|
|
Forbidden => "`forbidden`.";
|
|
NotFound => "`not_found`.";
|
|
Conflict => "A 409 error code of SPEC §14.3.2.";
|
|
PayloadTooLarge => "`payload_too_large`.";
|
|
UnsupportedMediaType => "`unsupported_media_type`.";
|
|
Unprocessable => "A 422 error code of SPEC §14.3.2.";
|
|
ProtocolUnsupported => "`protocol_unsupported`.";
|
|
RateLimited => "`rate_limited` (SPEC §8.6).", true;
|
|
Internal => "`internal` (logged with a request id, returned in `message`).";
|
|
NotImplemented => "`not_implemented` (route registered but not yet implemented; only during milestones M2a to M4).";
|
|
Unavailable => "`unavailable` (database busy beyond `busy_timeout`, or server shutting down).";
|
|
}
|
|
}
|
|
|
|
/// Applies the document-wide rules of SPEC §14.3.1 and §14.3.2 (see the module docs).
|
|
pub struct CommonRules;
|
|
|
|
impl Modify for CommonRules {
|
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
|
openapi.info.description = Some(INFO_DESCRIPTION.to_owned());
|
|
openapi.info.license = None;
|
|
openapi.info.contact = None;
|
|
openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]);
|
|
openapi.security = Some(vec![SecurityRequirement::new(
|
|
"device",
|
|
Vec::<String>::new(),
|
|
)]);
|
|
|
|
let components = openapi.components.get_or_insert_with(Default::default);
|
|
let mut bearer = Http::new(HttpAuthScheme::Bearer);
|
|
bearer.description = Some(
|
|
"`Authorization: Bearer kvt_<43 base64url characters>` (SPEC §8.2). Missing or unknown token: 401 \
|
|
`unauthorized`; revoked device: 401 `device_revoked`. Admin-only operations (E-12, E-13, E-14) \
|
|
return 403 `forbidden` for a member (SPEC §8.4)."
|
|
.to_owned(),
|
|
);
|
|
components
|
|
.security_schemes
|
|
.insert("device".to_owned(), RefOr::T(SecurityScheme::Http(bearer)));
|
|
|
|
let protocol_schema = || {
|
|
plain_schema(Type::String)
|
|
.enum_values(Some([crate::auth::PROTOCOL_VERSION]))
|
|
.build()
|
|
};
|
|
let header_param =
|
|
|name: &str, required: bool, description: &str, schema: ObjectBuilder| {
|
|
RefOr::T(
|
|
ParameterBuilder::new()
|
|
.name(name)
|
|
.parameter_in(ParameterIn::Header)
|
|
.required(if required {
|
|
Required::True
|
|
} else {
|
|
Required::False
|
|
})
|
|
.description(Some(description))
|
|
.schema(Some(schema.build()))
|
|
.build(),
|
|
)
|
|
};
|
|
components.parameters.insert(
|
|
"Protocol".to_owned(),
|
|
header_param(
|
|
"X-Kvitt-Protocol",
|
|
true,
|
|
"Sync protocol version. Missing or other than `1` on an authenticated endpoint is 426 \
|
|
`protocol_unsupported`.",
|
|
ObjectBuilder::from(protocol_schema()),
|
|
),
|
|
);
|
|
components.parameters.insert(
|
|
"ProtocolOptional".to_owned(),
|
|
header_param(
|
|
"X-Kvitt-Protocol",
|
|
false,
|
|
"Sent by the app on every call; not enforced on unauthenticated endpoints.",
|
|
ObjectBuilder::from(protocol_schema()),
|
|
),
|
|
);
|
|
components.parameters.insert(
|
|
"AppVersion".to_owned(),
|
|
header_param(
|
|
"X-Kvitt-App-Version",
|
|
false,
|
|
"The app's `versionCode`, sent by the app on every call.",
|
|
plain_schema(Type::Integer),
|
|
),
|
|
);
|
|
components.parameters.insert(
|
|
"AcceptLanguage".to_owned(),
|
|
header_param(
|
|
"Accept-Language",
|
|
false,
|
|
"The device locale.",
|
|
plain_schema(Type::String),
|
|
),
|
|
);
|
|
|
|
components.headers.insert(
|
|
"ServerVersion".to_owned(),
|
|
RefOr::T(required_header(
|
|
HeaderBuilder::new()
|
|
.schema(Some(plain_schema(Type::String).build()))
|
|
.description(Some("The server crate version.")),
|
|
)),
|
|
);
|
|
components.headers.insert(
|
|
"RetryAfter".to_owned(),
|
|
RefOr::T(required_header(
|
|
HeaderBuilder::new()
|
|
.schema(Some(plain_schema(Type::Integer).build()))
|
|
.description(Some("Seconds until the client may retry.")),
|
|
)),
|
|
);
|
|
// JSON Schema keywords utoipa has no attribute for, written through the schema's extension map.
|
|
if let Some(RefOr::T(Schema::Object(object))) =
|
|
components.schemas.get_mut("PatchRecurringTemplateRequest")
|
|
{
|
|
object
|
|
.extensions
|
|
.get_or_insert_with(Extensions::default)
|
|
.insert(
|
|
"dependentRequired".to_owned(),
|
|
wire::recurring::patch_template_dependent_required(),
|
|
);
|
|
}
|
|
for response in components.responses.values_mut() {
|
|
if let RefOr::T(response) = response {
|
|
mark_inline_headers_required(response);
|
|
}
|
|
}
|
|
|
|
for item in openapi.paths.paths.values_mut() {
|
|
for operation in [
|
|
item.get.as_mut(),
|
|
item.put.as_mut(),
|
|
item.post.as_mut(),
|
|
item.delete.as_mut(),
|
|
item.patch.as_mut(),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
apply_operation_rules(operation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_unauthenticated(operation: &Operation) -> bool {
|
|
operation.security.as_ref().is_some_and(|requirements| {
|
|
requirements.iter().all(|r| {
|
|
serde_json::to_value(r).is_ok_and(|v| v.as_object().is_some_and(|o| o.is_empty()))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn apply_operation_rules(operation: &mut Operation) {
|
|
let unauthenticated = is_unauthenticated(operation);
|
|
if unauthenticated {
|
|
operation.security = Some(Vec::new());
|
|
}
|
|
|
|
let parameters = operation.parameters.get_or_insert_with(Vec::new);
|
|
let protocol = if unauthenticated {
|
|
"ProtocolOptional"
|
|
} else {
|
|
"Protocol"
|
|
};
|
|
for name in [protocol, "AppVersion", "AcceptLanguage"] {
|
|
parameters.push(RefOr::Ref(Ref::new(format!(
|
|
"#/components/parameters/{name}"
|
|
))));
|
|
}
|
|
|
|
let always: &[(&str, &str)] = if unauthenticated {
|
|
&[
|
|
("429", "RateLimited"),
|
|
("500", "Internal"),
|
|
("503", "Unavailable"),
|
|
]
|
|
} else {
|
|
&[
|
|
("401", "Unauthorized"),
|
|
("426", "ProtocolUnsupported"),
|
|
("429", "RateLimited"),
|
|
("500", "Internal"),
|
|
("503", "Unavailable"),
|
|
]
|
|
};
|
|
for (status, name) in always {
|
|
operation
|
|
.responses
|
|
.responses
|
|
.entry((*status).to_owned())
|
|
.or_insert_with(|| RefOr::Ref(Ref::from_response_name(*name)));
|
|
}
|
|
|
|
for response in operation.responses.responses.values_mut() {
|
|
if let RefOr::T(response) = response {
|
|
response
|
|
.headers
|
|
.entry(SERVER_VERSION_HEADER.to_owned())
|
|
.or_insert_with(|| RefOr::Ref(header_ref("ServerVersion")));
|
|
mark_inline_headers_required(response);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mark_inline_headers_required(response: &mut Response) {
|
|
for header in response.headers.values_mut() {
|
|
if let RefOr::T(header) = header {
|
|
mark_required(header);
|
|
}
|
|
}
|
|
}
|