//! 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) { 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::::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); } } }