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,65 @@
|
||||
//! Account objects (SPEC §14.3.3, §14.3.4 E-18, E-19).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
/// A share of an account default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Share {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub user_id: Uuid,
|
||||
#[schema(schema_with = super::int)]
|
||||
pub share_bp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Account {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// Sorted by `user_id` ([`Account::sort`]).
|
||||
#[schema(min_items = 2, max_items = 2)]
|
||||
pub default_shares: Vec<Share>,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub created_by_user_id: Uuid,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub archived_at: Option<String>,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// Sorts `default_shares` by `user_id` (SPEC §14.3.3).
|
||||
pub fn sort(&mut self) {
|
||||
self.default_shares.sort_by_key(|s| s.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// E-18 `POST /accounts`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateAccountRequest {
|
||||
pub name: String,
|
||||
#[schema(min_items = 2, max_items = 2)]
|
||||
pub default_shares: Vec<Share>,
|
||||
}
|
||||
|
||||
/// E-19 `PATCH /accounts/{account_id}`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[schema(min_properties = 1)]
|
||||
pub struct PatchAccountRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Vec<Share>, min_items = 2, max_items = 2)]
|
||||
pub default_shares: Option<Vec<Share>>,
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Audit entries (SPEC §14.3.3, §14.4.7).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EntityType {
|
||||
Expense,
|
||||
Settlement,
|
||||
}
|
||||
|
||||
impl EntityType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Expense => "expense",
|
||||
Self::Settlement => "settlement",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditAction {
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "create",
|
||||
Self::Update => "update",
|
||||
Self::Delete => "delete",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditSource {
|
||||
App,
|
||||
Recurring,
|
||||
Import,
|
||||
}
|
||||
|
||||
impl AuditSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "app",
|
||||
Self::Recurring => "recurring",
|
||||
Self::Import => "import",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One field of the audit diff (SPEC §14.4.7). `old` and `new` are JSON values of the field's type, or null.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AuditChange {
|
||||
pub field: String,
|
||||
#[schema(value_type = AnyValue, required = true)]
|
||||
pub old: serde_json::Value,
|
||||
#[schema(value_type = AnyValue, required = true)]
|
||||
pub new: serde_json::Value,
|
||||
}
|
||||
|
||||
/// `{}`: any JSON value, `null` included.
|
||||
pub struct AnyValue;
|
||||
|
||||
impl utoipa::PartialSchema for AnyValue {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
|
||||
utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
|
||||
utoipa::openapi::schema::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::SchemaType::AnyValue)
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSchema for AnyValue {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AuditEntry {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
pub entity_type: EntityType,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub entity_id: Uuid,
|
||||
pub action: AuditAction,
|
||||
pub source: AuditSource,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub actor_user_id: Uuid,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub client_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub server_at: String,
|
||||
pub changes: Vec<AuditChange>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Category objects (SPEC §14.3.3, §14.3.4 E-23, E-24).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DefaultCategoryKey {
|
||||
Groceries,
|
||||
Restaurants,
|
||||
Home,
|
||||
Utilities,
|
||||
Transport,
|
||||
Travel,
|
||||
Entertainment,
|
||||
Health,
|
||||
Shopping,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Category {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(required = true)]
|
||||
pub default_key: Option<DefaultCategoryKey>,
|
||||
#[schema(required = true)]
|
||||
pub name: Option<String>,
|
||||
/// An icon token of SPEC §9.7.
|
||||
pub icon: String,
|
||||
/// A colour token of SPEC §9.7.
|
||||
pub color: String,
|
||||
#[schema(schema_with = super::int)]
|
||||
pub sort_order: i64,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub archived_at: Option<String>,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
/// E-23 `POST /categories`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateCategoryRequest {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// E-24 `PATCH /categories/{category_id}`. `name`: absent = unchanged (`None`), `null` = reset a default category's
|
||||
/// name (`Some(None)`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[schema(min_properties = 1)]
|
||||
pub struct PatchCategoryRequest {
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
#[schema(value_type = Option<String>, required = false)]
|
||||
pub name: Option<Option<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(schema_with = super::int)]
|
||||
pub sort_order: Option<i64>,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Device objects (SPEC §14.3.3, §14.3.4 E-15, E-17).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{TimestampSchema, UuidSchema};
|
||||
|
||||
/// Only in `/me`, `/devices` and `/invites/accept`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Device {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(schema_with = super::nullable_int, required = true)]
|
||||
pub app_version_code: Option<i64>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub last_seen_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub revoked_at: Option<String>,
|
||||
pub is_current: bool,
|
||||
}
|
||||
|
||||
/// E-15 `GET /devices`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DeviceList {
|
||||
pub devices: Vec<Device>,
|
||||
}
|
||||
|
||||
/// E-17 `PUT /devices/current/push-token`: the key is required, its value nullable (null clears).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushTokenRequest {
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(required = true)]
|
||||
pub fcm_token: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Expense objects (SPEC §14.3.3).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SplitMode {
|
||||
Default,
|
||||
Ratio,
|
||||
Exact,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// A split of an expense; `share_bp` null for mode exact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Split {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub user_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub share_ore: i64,
|
||||
#[schema(schema_with = super::nullable_int, required = true)]
|
||||
pub share_bp: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Expense {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
pub description: String,
|
||||
pub notes: String,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub expense_date: String,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
pub split_mode: SplitMode,
|
||||
/// Sorted by `user_id` ([`Expense::sort`]).
|
||||
#[schema(min_items = 2, max_items = 2)]
|
||||
pub splits: Vec<Split>,
|
||||
#[schema(value_type = Option<UuidSchema>, required = true)]
|
||||
pub recurring_template_id: Option<Uuid>,
|
||||
#[schema(value_type = Option<PeriodSchema>, required = true)]
|
||||
pub recurring_period: Option<String>,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub created_by_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub updated_by_user_id: Uuid,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
impl Expense {
|
||||
/// Sorts `splits` by `user_id` (SPEC §14.3.3).
|
||||
pub fn sort(&mut self) {
|
||||
self.splits.sort_by_key(|s| s.user_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Invite objects (SPEC §14.3.3, §14.3.4 E-3, E-12, E-13).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::device::Device;
|
||||
use super::user::User;
|
||||
use super::{TimestampSchema, UuidSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InviteKind {
|
||||
AdminSetup,
|
||||
Partner,
|
||||
Device,
|
||||
}
|
||||
|
||||
/// `code` and `link` only in the `POST /invites` response; `null` in `GET /invites`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Invite {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
pub kind: InviteKind,
|
||||
#[schema(value_type = Option<UuidSchema>, required = true)]
|
||||
pub target_user_id: Option<Uuid>,
|
||||
#[schema(required = true)]
|
||||
pub code: Option<String>,
|
||||
#[schema(required = true)]
|
||||
pub link: Option<String>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
/// E-13 `GET /invites`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct InviteList {
|
||||
pub invites: Vec<Invite>,
|
||||
}
|
||||
|
||||
/// E-3 `POST /invites/accept`. `display_name` is required for `admin_setup` and `partner` invites
|
||||
/// (`invalid_request` if missing) and ignored for `device`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AcceptInviteRequest {
|
||||
pub code: String,
|
||||
pub device_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// E-3 response. The token is shown only here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AcceptInviteResponse {
|
||||
pub token: String,
|
||||
pub user: User,
|
||||
pub device: Device,
|
||||
pub household: Vec<User>,
|
||||
}
|
||||
|
||||
/// The kinds an admin may create (E-12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreateInviteKind {
|
||||
Partner,
|
||||
Device,
|
||||
}
|
||||
|
||||
/// E-12 `POST /invites`. `target_user_id` is optional and nullable (absent = null); required for `device`, must be
|
||||
/// null for `partner`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateInviteRequest {
|
||||
#[schema(inline)]
|
||||
pub kind: CreateInviteKind,
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<UuidSchema>)]
|
||||
pub target_user_id: Option<Uuid>,
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Wire objects (SPEC §14.3.3, §14.4) with their serde form and utoipa schemas.
|
||||
//!
|
||||
//! Conventions: ids are [`uuid::Uuid`] (lowercase, hyphenated on the wire); timestamps, dates and periods are
|
||||
//! `String`s in the fixed forms of the SPEC conventions; optional response fields are `Option`s that serialise as
|
||||
//! `null`, never omitted. Arrays are sorted as SPEC §14.3.3 says by the code that builds them (`sort_*` helpers).
|
||||
//! Request bodies ignore unknown fields (serde's default).
|
||||
|
||||
pub mod account;
|
||||
pub mod audit;
|
||||
pub mod category;
|
||||
pub mod device;
|
||||
pub mod expense;
|
||||
pub mod invite;
|
||||
pub mod recurring;
|
||||
pub mod settlement;
|
||||
pub mod sync;
|
||||
pub mod user;
|
||||
|
||||
pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share};
|
||||
pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType};
|
||||
pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest};
|
||||
pub use device::{Device, DeviceList, PushTokenRequest};
|
||||
pub use expense::{Expense, Split, SplitMode};
|
||||
pub use invite::{
|
||||
AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite,
|
||||
InviteKind, InviteList,
|
||||
};
|
||||
pub use recurring::{
|
||||
CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, TemplateSplit,
|
||||
};
|
||||
pub use settlement::{Allocation, ProposalRequest, ProposalResponse, Settlement};
|
||||
pub use sync::{
|
||||
AcceptedResult, ExpenseMutation, Mutation, MutationResult, PushedExpense, PushedSettlement,
|
||||
RejectedResult, RejectionReason, SettlementMutation, SyncPullResponse, SyncPushRequest,
|
||||
SyncPushResponse,
|
||||
};
|
||||
pub use user::{
|
||||
ApkLatest, Health, HealthStatus, MeResponse, PatchMeRequest, Role, SetupStatus, User,
|
||||
};
|
||||
|
||||
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaFormat, SchemaType, Type};
|
||||
use utoipa::openapi::{KnownFormat, RefOr};
|
||||
use utoipa::{PartialSchema, ToSchema};
|
||||
|
||||
/// Declares a schema-only marker type for a primitive of SPEC §14.3.3 (used with `#[schema(value_type = …)]`).
|
||||
macro_rules! primitive_schema {
|
||||
($(#[$doc:meta])* $marker:ident, $name:literal, $schema:expr) => {
|
||||
$(#[$doc])*
|
||||
pub struct $marker;
|
||||
|
||||
impl PartialSchema for $marker {
|
||||
fn schema() -> RefOr<Schema> {
|
||||
let builder: ObjectBuilder = $schema;
|
||||
RefOr::T(Schema::Object(builder.build()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSchema for $marker {
|
||||
fn name() -> std::borrow::Cow<'static, str> {
|
||||
std::borrow::Cow::Borrowed($name)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
primitive_schema!(
|
||||
/// `uuid`: lowercase, hyphenated, 36 characters (UUIDv7).
|
||||
UuidSchema,
|
||||
"Uuid",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::String))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid)))
|
||||
.description(Some("Lowercase, hyphenated, 36 characters (UUIDv7)."))
|
||||
.pattern(Some("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// `ts`: RFC 3339 UTC with millisecond precision and a literal `Z`.
|
||||
TimestampSchema,
|
||||
"Timestamp",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::String))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::DateTime)))
|
||||
.description(Some("RFC 3339 UTC, millisecond precision, literal Z."))
|
||||
.pattern(Some(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$"))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// `date`: `YYYY-MM-DD`.
|
||||
DateSchema,
|
||||
"Date",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::String))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Date)))
|
||||
.pattern(Some(r"^\d{4}-\d{2}-\d{2}$"))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// `period`: a month, `YYYY-MM`.
|
||||
PeriodSchema,
|
||||
"Period",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::String))
|
||||
.description(Some("A month, YYYY-MM."))
|
||||
.pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$"))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// Money in öre (1/100 SEK), 64-bit.
|
||||
OreSchema,
|
||||
"Ore",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::Integer))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
|
||||
.description(Some("Money in öre (1/100 SEK)."))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// A `server_version` from the global sequence.
|
||||
ServerVersionSchema,
|
||||
"ServerVersionNumber",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::Integer))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
|
||||
.description(Some("From the one global, strictly increasing sequence (SPEC §5.2)."))
|
||||
);
|
||||
|
||||
/// `{ "type": "integer" }` without a format: the contract leaves small integers (bp, sort order, day of month,
|
||||
/// versions) unformatted. Used with `#[schema(schema_with = int)]`.
|
||||
pub fn int() -> Schema {
|
||||
Schema::Object(
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::Integer))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `{ "type": ["integer", "null"] }`.
|
||||
pub fn nullable_int() -> Schema {
|
||||
Schema::Object(
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::from_iter([Type::Integer, Type::Null]))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `{ "type": ["integer", "null"], "format": "int64" }`.
|
||||
pub fn nullable_int64() -> Schema {
|
||||
Schema::Object(
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::from_iter([Type::Integer, Type::Null]))
|
||||
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialises a field that distinguishes "absent" (`None`, via `#[serde(default)]`) from `null` (`Some(None)`),
|
||||
/// for PATCH bodies where `null` means something (SPEC §14.3.4 E-24 `name`).
|
||||
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
<Option<T> as serde::Deserialize>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
/// Deserialises a key that must be present but may be `null` (with `deserialize_with`, serde no longer treats a
|
||||
/// missing `Option` as `None`, so absence is `invalid_request`). SPEC §14.3.4 E-17 `fcm_token`, E-28 `end_period`.
|
||||
pub fn required_nullable<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
<Option<T> as serde::Deserialize>::deserialize(deserializer)
|
||||
}
|
||||
|
||||
primitive_schema!(
|
||||
/// `{ "type": "integer" }` without a format (query parameters and headers).
|
||||
IntSchema,
|
||||
"Int",
|
||||
ObjectBuilder::new().schema_type(SchemaType::Type(Type::Integer))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// The `size` query parameter of E-11: 128 or 512, default 512.
|
||||
PictureSizeSchema,
|
||||
"PictureSize",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::Integer))
|
||||
.enum_values(Some([128, 512]))
|
||||
.default(Some(serde_json::json!(512)))
|
||||
);
|
||||
|
||||
primitive_schema!(
|
||||
/// The `limit` query parameter of E-31: default 500.
|
||||
SyncLimitSchema,
|
||||
"SyncLimit",
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::Integer))
|
||||
.default(Some(serde_json::json!(500)))
|
||||
);
|
||||
|
||||
/// Declares a schema-only marker for a raw binary body of the given media type.
|
||||
macro_rules! binary_schema {
|
||||
($(#[$doc:meta])* $marker:ident, $name:literal, $media:literal) => {
|
||||
primitive_schema!(
|
||||
$(#[$doc])*
|
||||
$marker,
|
||||
$name,
|
||||
ObjectBuilder::new()
|
||||
.schema_type(SchemaType::Type(Type::String))
|
||||
.content_media_type($media)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
binary_schema!(
|
||||
/// Raw JPEG bytes.
|
||||
JpegBytes,
|
||||
"JpegBytes",
|
||||
"image/jpeg"
|
||||
);
|
||||
binary_schema!(
|
||||
/// Raw PNG bytes.
|
||||
PngBytes,
|
||||
"PngBytes",
|
||||
"image/png"
|
||||
);
|
||||
binary_schema!(
|
||||
/// Raw WebP bytes.
|
||||
WebpBytes,
|
||||
"WebpBytes",
|
||||
"image/webp"
|
||||
);
|
||||
binary_schema!(
|
||||
/// Raw APK bytes.
|
||||
ApkBytes,
|
||||
"ApkBytes",
|
||||
"application/vnd.android.package-archive"
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Recurring template objects (SPEC §14.3.3, §14.3.4 E-28, E-29).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::expense::SplitMode;
|
||||
use super::{
|
||||
DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema,
|
||||
};
|
||||
|
||||
/// Exactly one of `share_bp` / `share_ore` non-null.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TemplateSplit {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub user_id: Uuid,
|
||||
#[schema(schema_with = super::nullable_int, required = true)]
|
||||
pub share_bp: Option<i64>,
|
||||
#[schema(schema_with = super::nullable_int64, required = true)]
|
||||
pub share_ore: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RecurringTemplate {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
pub description: String,
|
||||
pub notes: String,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
pub split_mode: SplitMode,
|
||||
/// Sorted by `user_id`; empty for mode default ([`RecurringTemplate::sort`]).
|
||||
pub splits: Vec<TemplateSplit>,
|
||||
#[schema(schema_with = super::int)]
|
||||
pub day_of_month: i64,
|
||||
#[schema(value_type = PeriodSchema)]
|
||||
pub start_period: String,
|
||||
#[schema(value_type = Option<PeriodSchema>, required = true)]
|
||||
pub end_period: Option<String>,
|
||||
pub paused: bool,
|
||||
#[schema(value_type = Option<PeriodSchema>, required = true)]
|
||||
pub last_generated_period: Option<String>,
|
||||
/// Computed with `kvitt_domain::next_occurrence_date` (SPEC §9.5).
|
||||
#[schema(value_type = Option<DateSchema>, required = true)]
|
||||
pub next_occurrence_date: Option<String>,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub created_by_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub updated_by_user_id: Uuid,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
impl RecurringTemplate {
|
||||
/// Sorts `splits` by `user_id` (SPEC §14.3.3).
|
||||
pub fn sort(&mut self) {
|
||||
self.splits.sort_by_key(|s| s.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// E-28 `POST /recurring-templates`. `splits` per SPEC §9.1: none for `default`; two bp entries for `ratio` and
|
||||
/// `full`; two öre entries summing to the amount for `exact`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateRecurringTemplateRequest {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
pub description: String,
|
||||
pub notes: String,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
pub split_mode: SplitMode,
|
||||
pub splits: Vec<TemplateSplit>,
|
||||
#[schema(schema_with = super::int)]
|
||||
pub day_of_month: i64,
|
||||
#[schema(value_type = PeriodSchema)]
|
||||
pub start_period: String,
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(value_type = Option<PeriodSchema>, required = true)]
|
||||
pub end_period: Option<String>,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// E-29 `PATCH /recurring-templates/{template_id}`: any subset of the E-28 fields; `split_mode` and `splits` must
|
||||
/// be sent together (the schema's `dependentRequired`, added by [`patch_template_dependent_required`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
|
||||
#[schema(min_properties = 1)]
|
||||
pub struct PatchRecurringTemplateRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Option<Uuid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Option<Uuid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Option<Uuid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = SplitMode)]
|
||||
pub split_mode: Option<SplitMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Vec<TemplateSplit>)]
|
||||
pub splits: Option<Vec<TemplateSplit>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(schema_with = super::int)]
|
||||
pub day_of_month: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = PeriodSchema)]
|
||||
pub start_period: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
#[schema(value_type = Option<PeriodSchema>, required = false)]
|
||||
pub end_period: Option<Option<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = bool)]
|
||||
pub paused: Option<bool>,
|
||||
}
|
||||
|
||||
/// The `dependentRequired` keyword of [`PatchRecurringTemplateRequest`]: `split_mode` and `splits` together. utoipa
|
||||
/// has no attribute for it, so `openapi.rs` writes it into the generated schema.
|
||||
pub fn patch_template_dependent_required() -> serde_json::Value {
|
||||
serde_json::json!({ "split_mode": ["splits"], "splits": ["split_mode"] })
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Settlement and proposal objects (SPEC §14.3.3, §14.3.4 E-33).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Allocation {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Settlement {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub from_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub to_user_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub settlement_date: String,
|
||||
pub note: String,
|
||||
/// Sorted by `account_id` ([`Settlement::sort`]).
|
||||
pub allocations: Vec<Allocation>,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub created_by_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub updated_by_user_id: Uuid,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
impl Settlement {
|
||||
/// Sorts `allocations` by `account_id` (SPEC §14.3.3).
|
||||
pub fn sort(&mut self) {
|
||||
self.allocations.sort_by_key(|a| a.account_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// E-33 `POST /settlements/proposal`. `exclude_settlement_id` is optional and nullable (absent = null).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProposalRequest {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<UuidSchema>)]
|
||||
pub exclude_settlement_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// E-33 response, computed with `kvitt_domain::propose_allocation`. Allocations in the order of SPEC §9.4.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProposalResponse {
|
||||
pub allocations: Vec<Allocation>,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub excess_ore: i64,
|
||||
#[schema(value_type = Vec<UuidSchema>)]
|
||||
pub over_settled_account_ids: Vec<Uuid>,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Sync protocol objects (SPEC §14.4.2, §14.4.3, §14.4.6).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::account::Account;
|
||||
use super::audit::{AuditEntry, EntityType};
|
||||
use super::category::Category;
|
||||
use super::expense::{Expense, Split, SplitMode};
|
||||
use super::recurring::RecurringTemplate;
|
||||
use super::settlement::{Allocation, Settlement};
|
||||
use super::user::User;
|
||||
use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
/// E-31 `GET /sync` response: one page; every array present (possibly empty) and sorted by `server_version`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPullResponse {
|
||||
/// `meta.sync_epoch`, 32 lowercase hex characters.
|
||||
#[schema(pattern = "^[0-9a-f]{32}$")]
|
||||
pub epoch: String,
|
||||
pub since: i64,
|
||||
/// The highest `server_version` in the page, or `since` when the page is empty.
|
||||
pub next_since: i64,
|
||||
pub has_more: bool,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub server_time: String,
|
||||
pub users: Vec<User>,
|
||||
pub categories: Vec<Category>,
|
||||
pub accounts: Vec<Account>,
|
||||
pub recurring_templates: Vec<RecurringTemplate>,
|
||||
pub expenses: Vec<Expense>,
|
||||
pub settlements: Vec<Settlement>,
|
||||
pub audit_entries: Vec<AuditEntry>,
|
||||
}
|
||||
|
||||
/// Exactly the client-writable fields of an expense (SPEC §14.4.3); server-owned fields are ignored if sent. A
|
||||
/// delete is the full entity with `deleted_at` set.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushedExpense {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
pub description: String,
|
||||
pub notes: String,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub expense_date: String,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
pub split_mode: SplitMode,
|
||||
pub splits: Vec<Split>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Exactly the client-writable fields of a settlement (SPEC §14.4.3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushedSettlement {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub from_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub to_user_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub settlement_date: String,
|
||||
pub note: String,
|
||||
pub allocations: Vec<Allocation>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// The constant `"expense"` (the discriminator value of [`ExpenseMutation`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum ExpenseTag {
|
||||
#[serde(rename = "expense")]
|
||||
Expense,
|
||||
}
|
||||
|
||||
/// The constant `"settlement"` (the discriminator value of [`SettlementMutation`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum SettlementTag {
|
||||
#[serde(rename = "settlement")]
|
||||
Settlement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ExpenseMutation {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub entity_type: ExpenseTag,
|
||||
pub entity: PushedExpense,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SettlementMutation {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub entity_type: SettlementTag,
|
||||
pub entity: PushedSettlement,
|
||||
}
|
||||
|
||||
/// One pushed change, discriminated by `entity_type`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[schema(discriminator(property_name = "entity_type", mapping(
|
||||
("expense" = "#/components/schemas/ExpenseMutation"),
|
||||
("settlement" = "#/components/schemas/SettlementMutation"),
|
||||
)))]
|
||||
pub enum Mutation {
|
||||
Expense(ExpenseMutation),
|
||||
Settlement(SettlementMutation),
|
||||
}
|
||||
|
||||
impl Mutation {
|
||||
pub fn mutation_id(&self) -> Uuid {
|
||||
match self {
|
||||
Self::Expense(m) => m.mutation_id,
|
||||
Self::Settlement(m) => m.mutation_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entity_type(&self) -> EntityType {
|
||||
match self {
|
||||
Self::Expense(_) => EntityType::Expense,
|
||||
Self::Settlement(_) => EntityType::Settlement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// E-32 `POST /sync` request: 1 to 100 mutations (more is 422 `batch_too_large`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPushRequest {
|
||||
pub mutations: Vec<Mutation>,
|
||||
}
|
||||
|
||||
/// A full wire object of the type named by `entity_type`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum SyncEntity {
|
||||
Expense(Expense),
|
||||
Settlement(Settlement),
|
||||
}
|
||||
|
||||
/// The constant `"accepted"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum AcceptedTag {
|
||||
#[serde(rename = "accepted")]
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// The constant `"rejected"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum RejectedTag {
|
||||
#[serde(rename = "rejected")]
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Exhaustive (SPEC §14.4.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RejectionReason {
|
||||
Stale,
|
||||
Deleted,
|
||||
ValidationFailed,
|
||||
ReferenceNotFound,
|
||||
ClockSkew,
|
||||
}
|
||||
|
||||
/// An accepted mutation. Accepted and rejected results have different key sets (SPEC §14.4.3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AcceptedResult {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
pub entity_type: EntityType,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub entity_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub status: AcceptedTag,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
// The full wire object after processing. (A doc comment here would make utoipa wrap the schema in `allOf`.)
|
||||
#[schema(inline)]
|
||||
pub entity: SyncEntity,
|
||||
}
|
||||
|
||||
/// A rejected mutation (SPEC §14.4.6).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RejectedResult {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
pub entity_type: EntityType,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub entity_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub status: RejectedTag,
|
||||
pub reason: RejectionReason,
|
||||
/// For validation failures, the domain error token of SPEC §14.5.3.
|
||||
pub message: String,
|
||||
// The stored row, or null when the server has no row with that id.
|
||||
#[schema(inline, required = true)]
|
||||
pub current: Option<SyncEntity>,
|
||||
}
|
||||
|
||||
/// One result per mutation, discriminated by `status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[schema(discriminator(property_name = "status", mapping(
|
||||
("accepted" = "#/components/schemas/AcceptedResult"),
|
||||
("rejected" = "#/components/schemas/RejectedResult"),
|
||||
)))]
|
||||
pub enum MutationResult {
|
||||
Accepted(AcceptedResult),
|
||||
Rejected(RejectedResult),
|
||||
}
|
||||
|
||||
/// E-32 response: one result per mutation, in request order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPushResponse {
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub server_time: String,
|
||||
pub results: Vec<MutationResult>,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! User, health, setup, `/me` and APK objects (SPEC §14.3.3, §14.3.4 E-1, E-2, E-5, E-7, E-8).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::device::Device;
|
||||
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
Member,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Admin => "admin",
|
||||
Self::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `users.role` column value.
|
||||
pub fn from_db(text: &str) -> Option<Self> {
|
||||
match text {
|
||||
"admin" => Some(Self::Admin),
|
||||
"member" => Some(Self::Member),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct User {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
pub role: Role,
|
||||
pub display_name: String,
|
||||
#[schema(schema_with = super::nullable_int, required = true)]
|
||||
pub picture_version: Option<i64>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealthStatus {
|
||||
Ok,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
/// E-1 `GET /health`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[schema(as = Health)]
|
||||
pub struct Health {
|
||||
#[schema(inline)]
|
||||
pub status: HealthStatus,
|
||||
pub server_version: String,
|
||||
#[schema(schema_with = super::int)]
|
||||
pub schema_version: i64,
|
||||
pub db_ok: bool,
|
||||
}
|
||||
|
||||
/// E-2 `GET /setup/status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SetupStatus {
|
||||
pub initialized: bool,
|
||||
}
|
||||
|
||||
/// E-5 `GET /apk/latest`: the content of `latest.json` (SPEC §10.6).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ApkLatest {
|
||||
#[schema(schema_with = super::int)]
|
||||
pub version_code: i64,
|
||||
pub version_name: String,
|
||||
pub file_name: String,
|
||||
pub size_bytes: i64,
|
||||
pub sha256: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub released_at: String,
|
||||
}
|
||||
|
||||
/// E-7 `GET /me`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MeResponse {
|
||||
pub user: User,
|
||||
pub device: Device,
|
||||
pub household: Vec<User>,
|
||||
pub public_url: String,
|
||||
}
|
||||
|
||||
/// E-8 `PATCH /me`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[schema(min_properties = 1)]
|
||||
pub struct PatchMeRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user