Files
kvitt/server/crates/kvitt-server/src/openapi.rs
T
jacobh2 8c607153c0 M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 15:00:56 +02:00

398 lines
15 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`, `X-Kvitt-App-Version-Name` 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(
"AppVersionName".to_owned(),
header_param(
"X-Kvitt-App-Version-Name",
false,
"The app's `versionName`, sent by the app on every call; stored per device like `X-Kvitt-App-Version` (1 to 32 characters, anything else ignored).",
plain_schema(Type::String),
),
);
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", "AppVersionName", "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);
}
}
}