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>
318 lines
10 KiB
Rust
318 lines
10 KiB
Rust
//! The error type and its wire shape (SPEC §14.3.2): `{ "code", "message", "field" }` with the HTTP status of the
|
|
//! code. `message` is English developer text, never shown to users.
|
|
|
|
use axum::http::{HeaderValue, StatusCode, header};
|
|
use axum::response::{IntoResponse, Response};
|
|
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
|
|
/// Every error code of SPEC §14.3.2 (exhaustive), in the order of that table.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ErrorCode {
|
|
InvalidRequest,
|
|
InviteInvalid,
|
|
Unauthorized,
|
|
DeviceRevoked,
|
|
Forbidden,
|
|
NotFound,
|
|
HouseholdFull,
|
|
HouseholdIncomplete,
|
|
NameTaken,
|
|
AccountNotEmpty,
|
|
CategoryInUse,
|
|
CategoryIsDefault,
|
|
CannotRevokeCurrentDevice,
|
|
NoAccounts,
|
|
CursorAhead,
|
|
PayloadTooLarge,
|
|
UnsupportedMediaType,
|
|
ValidationFailed,
|
|
ImageInvalid,
|
|
BatchTooLarge,
|
|
ProtocolUnsupported,
|
|
RateLimited,
|
|
Internal,
|
|
NotImplemented,
|
|
Unavailable,
|
|
}
|
|
|
|
impl ErrorCode {
|
|
/// Every code, in the order of SPEC §14.3.2.
|
|
pub const ALL: [ErrorCode; 25] = [
|
|
Self::InvalidRequest,
|
|
Self::InviteInvalid,
|
|
Self::Unauthorized,
|
|
Self::DeviceRevoked,
|
|
Self::Forbidden,
|
|
Self::NotFound,
|
|
Self::HouseholdFull,
|
|
Self::HouseholdIncomplete,
|
|
Self::NameTaken,
|
|
Self::AccountNotEmpty,
|
|
Self::CategoryInUse,
|
|
Self::CategoryIsDefault,
|
|
Self::CannotRevokeCurrentDevice,
|
|
Self::NoAccounts,
|
|
Self::CursorAhead,
|
|
Self::PayloadTooLarge,
|
|
Self::UnsupportedMediaType,
|
|
Self::ValidationFailed,
|
|
Self::ImageInvalid,
|
|
Self::BatchTooLarge,
|
|
Self::ProtocolUnsupported,
|
|
Self::RateLimited,
|
|
Self::Internal,
|
|
Self::NotImplemented,
|
|
Self::Unavailable,
|
|
];
|
|
|
|
/// The HTTP status of the code (SPEC §14.3.2).
|
|
pub fn status(self) -> StatusCode {
|
|
match self {
|
|
Self::InvalidRequest | Self::InviteInvalid => StatusCode::BAD_REQUEST,
|
|
Self::Unauthorized | Self::DeviceRevoked => StatusCode::UNAUTHORIZED,
|
|
Self::Forbidden => StatusCode::FORBIDDEN,
|
|
Self::NotFound => StatusCode::NOT_FOUND,
|
|
Self::HouseholdFull
|
|
| Self::HouseholdIncomplete
|
|
| Self::NameTaken
|
|
| Self::AccountNotEmpty
|
|
| Self::CategoryInUse
|
|
| Self::CategoryIsDefault
|
|
| Self::CannotRevokeCurrentDevice
|
|
| Self::NoAccounts
|
|
| Self::CursorAhead => StatusCode::CONFLICT,
|
|
Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
|
Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
Self::ValidationFailed | Self::ImageInvalid | Self::BatchTooLarge => {
|
|
StatusCode::UNPROCESSABLE_ENTITY
|
|
}
|
|
Self::ProtocolUnsupported => StatusCode::UPGRADE_REQUIRED,
|
|
Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
|
|
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
|
Self::NotImplemented => StatusCode::NOT_IMPLEMENTED,
|
|
Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
|
}
|
|
}
|
|
|
|
/// The snake_case token, as on the wire.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::InvalidRequest => "invalid_request",
|
|
Self::InviteInvalid => "invite_invalid",
|
|
Self::Unauthorized => "unauthorized",
|
|
Self::DeviceRevoked => "device_revoked",
|
|
Self::Forbidden => "forbidden",
|
|
Self::NotFound => "not_found",
|
|
Self::HouseholdFull => "household_full",
|
|
Self::HouseholdIncomplete => "household_incomplete",
|
|
Self::NameTaken => "name_taken",
|
|
Self::AccountNotEmpty => "account_not_empty",
|
|
Self::CategoryInUse => "category_in_use",
|
|
Self::CategoryIsDefault => "category_is_default",
|
|
Self::CannotRevokeCurrentDevice => "cannot_revoke_current_device",
|
|
Self::NoAccounts => "no_accounts",
|
|
Self::CursorAhead => "cursor_ahead",
|
|
Self::PayloadTooLarge => "payload_too_large",
|
|
Self::UnsupportedMediaType => "unsupported_media_type",
|
|
Self::ValidationFailed => "validation_failed",
|
|
Self::ImageInvalid => "image_invalid",
|
|
Self::BatchTooLarge => "batch_too_large",
|
|
Self::ProtocolUnsupported => "protocol_unsupported",
|
|
Self::RateLimited => "rate_limited",
|
|
Self::Internal => "internal",
|
|
Self::NotImplemented => "not_implemented",
|
|
Self::Unavailable => "unavailable",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ErrorCode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
/// The wire shape of an error (SPEC §14.3.2). `field` is always present, `null` when no field is concerned.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
|
#[schema(as = Error)]
|
|
pub struct ErrorBody {
|
|
pub code: ErrorCode,
|
|
pub message: String,
|
|
#[schema(required = true)]
|
|
pub field: Option<String>,
|
|
}
|
|
|
|
/// An error a handler returns. Converts into the JSON response of SPEC §14.3.2.
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
#[error("{code}: {message}")]
|
|
pub struct AppError {
|
|
pub code: ErrorCode,
|
|
pub message: String,
|
|
pub field: Option<String>,
|
|
/// Seconds for `Retry-After` (only with `rate_limited`, SPEC §8.6).
|
|
pub retry_after_secs: Option<u64>,
|
|
}
|
|
|
|
impl AppError {
|
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code,
|
|
message: message.into(),
|
|
field: None,
|
|
retry_after_secs: None,
|
|
}
|
|
}
|
|
|
|
/// Sets the JSON key concerned.
|
|
pub fn with_field(mut self, field: impl Into<String>) -> Self {
|
|
self.field = Some(field.into());
|
|
self
|
|
}
|
|
|
|
pub fn invalid_request(message: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::InvalidRequest, message)
|
|
}
|
|
|
|
/// `validation_failed` for `field` (SPEC §9.6).
|
|
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::ValidationFailed, message).with_field(field)
|
|
}
|
|
|
|
pub fn not_found() -> Self {
|
|
Self::new(ErrorCode::NotFound, "not found")
|
|
}
|
|
|
|
pub fn unauthorized() -> Self {
|
|
Self::new(
|
|
ErrorCode::Unauthorized,
|
|
"missing, malformed or unknown bearer token",
|
|
)
|
|
}
|
|
|
|
pub fn device_revoked() -> Self {
|
|
Self::new(ErrorCode::DeviceRevoked, "this device has been revoked")
|
|
}
|
|
|
|
pub fn forbidden() -> Self {
|
|
Self::new(ErrorCode::Forbidden, "your role does not allow this action")
|
|
}
|
|
|
|
pub fn protocol_unsupported() -> Self {
|
|
Self::new(
|
|
ErrorCode::ProtocolUnsupported,
|
|
"X-Kvitt-Protocol must be 1 on authenticated endpoints",
|
|
)
|
|
}
|
|
|
|
pub fn payload_too_large(limit_bytes: usize) -> Self {
|
|
Self::new(
|
|
ErrorCode::PayloadTooLarge,
|
|
format!("request body exceeds {limit_bytes} bytes"),
|
|
)
|
|
}
|
|
|
|
pub fn not_implemented() -> Self {
|
|
Self::new(ErrorCode::NotImplemented, "not implemented")
|
|
}
|
|
|
|
pub fn rate_limited(retry_after_secs: u64) -> Self {
|
|
Self {
|
|
retry_after_secs: Some(retry_after_secs),
|
|
..Self::new(ErrorCode::RateLimited, "too many requests")
|
|
}
|
|
}
|
|
|
|
/// `internal`. The detail is logged here; the client sees only a request id (SPEC §14.3.2).
|
|
pub fn internal(detail: impl std::fmt::Display) -> Self {
|
|
let request_id = uuid::Uuid::now_v7();
|
|
tracing::error!(error_code = "internal", %request_id, detail = %detail, "internal error");
|
|
Self::new(
|
|
ErrorCode::Internal,
|
|
format!("internal error, request id {request_id}"),
|
|
)
|
|
}
|
|
|
|
pub fn unavailable(message: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::Unavailable, message)
|
|
}
|
|
|
|
/// The wire body.
|
|
pub fn body(&self) -> ErrorBody {
|
|
ErrorBody {
|
|
code: self.code,
|
|
message: self.message.clone(),
|
|
field: self.field.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<sqlx::Error> for AppError {
|
|
/// `SQLITE_BUSY` beyond `busy_timeout` and pool time-outs are `unavailable`; anything else is `internal`.
|
|
fn from(error: sqlx::Error) -> Self {
|
|
let busy = match &error {
|
|
sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => true,
|
|
sqlx::Error::Database(db) => db
|
|
.code()
|
|
.and_then(|c| c.parse::<i32>().ok())
|
|
.is_some_and(|c| c & 0xff == 5 || c & 0xff == 6),
|
|
_ => false,
|
|
};
|
|
if busy {
|
|
tracing::warn!(error_code = "unavailable", detail = %error, "database busy");
|
|
Self::unavailable("database busy")
|
|
} else {
|
|
Self::internal(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<sqlx::migrate::MigrateError> for AppError {
|
|
fn from(error: sqlx::migrate::MigrateError) -> Self {
|
|
Self::internal(error)
|
|
}
|
|
}
|
|
|
|
/// The error code a response carried, stored in the response extensions for the request log (SPEC §8.7).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct LoggedErrorCode(pub ErrorCode);
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let status = self.code.status();
|
|
let mut response = (status, crate::api::JsonBody(self.body())).into_response();
|
|
if let Some(secs) = self.retry_after_secs {
|
|
response
|
|
.headers_mut()
|
|
.insert(header::RETRY_AFTER, HeaderValue::from(secs));
|
|
}
|
|
response.extensions_mut().insert(LoggedErrorCode(self.code));
|
|
response
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn serde_token_matches_as_str_for_every_code() {
|
|
for code in ErrorCode::ALL {
|
|
assert_eq!(
|
|
serde_json::to_value(code).unwrap(),
|
|
serde_json::json!(code.as_str())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn body_has_field_null() {
|
|
let v = serde_json::to_value(AppError::not_implemented().body()).unwrap();
|
|
assert_eq!(
|
|
v,
|
|
serde_json::json!({"code": "not_implemented", "message": "not implemented", "field": null})
|
|
);
|
|
}
|
|
}
|