M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
//! Input validation shared by the M3 handlers (SPEC §8.6, §9.6, §9.7).
|
||||
//!
|
||||
//! Strings are trimmed of leading and trailing whitespace and their length is counted in Unicode code points after
|
||||
//! trimming; the trimmed value is what the server stores.
|
||||
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
use kvitt_domain::Period;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::time::STOCKHOLM;
|
||||
|
||||
/// Category icon tokens (SPEC §9.7, exhaustive for v1).
|
||||
pub const ICONS: [&str; 30] = [
|
||||
"shopping_cart",
|
||||
"restaurant",
|
||||
"local_cafe",
|
||||
"home",
|
||||
"bolt",
|
||||
"water_drop",
|
||||
"wifi",
|
||||
"directions_car",
|
||||
"train",
|
||||
"local_gas_station",
|
||||
"flight",
|
||||
"hotel",
|
||||
"luggage",
|
||||
"movie",
|
||||
"music_note",
|
||||
"sports_esports",
|
||||
"fitness_center",
|
||||
"medical_services",
|
||||
"medication",
|
||||
"shopping_bag",
|
||||
"checkroom",
|
||||
"pets",
|
||||
"child_care",
|
||||
"school",
|
||||
"card_giftcard",
|
||||
"celebration",
|
||||
"build",
|
||||
"chair",
|
||||
"subscriptions",
|
||||
"category",
|
||||
];
|
||||
|
||||
/// Colour tokens (SPEC §9.7, exhaustive for v1).
|
||||
pub const COLORS: [&str; 12] = [
|
||||
"green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey",
|
||||
"indigo", "lime",
|
||||
];
|
||||
|
||||
/// Account name: 1 to 60 code points (SPEC §9.6).
|
||||
pub const ACCOUNT_NAME_MAX: usize = 60;
|
||||
/// Category name: 1 to 40 code points.
|
||||
pub const CATEGORY_NAME_MAX: usize = 40;
|
||||
/// User display name: 1 to 40 code points.
|
||||
pub const DISPLAY_NAME_MAX: usize = 40;
|
||||
/// Device name: 1 to 60 code points.
|
||||
pub const DEVICE_NAME_MAX: usize = 60;
|
||||
/// Expense and template description: 1 to 100 code points.
|
||||
pub const DESCRIPTION_MAX: usize = 100;
|
||||
/// Expense and template notes: 0 to 1000 code points.
|
||||
pub const NOTES_MAX: usize = 1000;
|
||||
/// How far back a template's `start_period` may lie, in months (SPEC §9.6, AC-9.8).
|
||||
pub const START_PERIOD_BACKFILL_MONTHS: u32 = 12;
|
||||
|
||||
/// Trims `value` and checks its length in code points is within `min..=max`. Returns the trimmed value.
|
||||
pub fn text(field: &str, value: &str, min: usize, max: usize) -> Result<String, AppError> {
|
||||
let trimmed = value.trim();
|
||||
let len = trimmed.chars().count();
|
||||
if len < min || len > max {
|
||||
return Err(AppError::validation(
|
||||
field,
|
||||
format!("{field} must be {min} to {max} characters after trimming"),
|
||||
));
|
||||
}
|
||||
Ok(trimmed.to_owned())
|
||||
}
|
||||
|
||||
/// Notes: trimmed, 0 to `NOTES_MAX` code points, no control characters other than newline (SPEC §8.6).
|
||||
pub fn notes(field: &str, value: &str) -> Result<String, AppError> {
|
||||
let trimmed = text(field, value, 0, NOTES_MAX)?;
|
||||
if trimmed.chars().any(|c| c.is_control() && c != '\n') {
|
||||
return Err(AppError::validation(
|
||||
field,
|
||||
format!("{field} may not contain control characters other than newline"),
|
||||
));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// An icon token of SPEC §9.7.
|
||||
pub fn icon(value: &str) -> Result<String, AppError> {
|
||||
if ICONS.contains(&value) {
|
||||
Ok(value.to_owned())
|
||||
} else {
|
||||
Err(AppError::validation(
|
||||
"icon",
|
||||
"icon is not a token of SPEC §9.7",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour token of SPEC §9.7.
|
||||
pub fn color(value: &str) -> Result<String, AppError> {
|
||||
if COLORS.contains(&value) {
|
||||
Ok(value.to_owned())
|
||||
} else {
|
||||
Err(AppError::validation(
|
||||
"color",
|
||||
"color is not a token of SPEC §9.7",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `amount_ore` within 1 to 1 000 000 000 (SPEC §9.6).
|
||||
pub fn amount(field: &str, amount_ore: i64) -> Result<i64, AppError> {
|
||||
if kvitt_domain::amount_in_range(amount_ore) {
|
||||
Ok(amount_ore)
|
||||
} else {
|
||||
Err(AppError::validation(field, "amount_out_of_range"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A period `YYYY-MM`.
|
||||
pub fn period(field: &str, value: &str) -> Result<Period, AppError> {
|
||||
value
|
||||
.parse::<Period>()
|
||||
.map_err(|_| AppError::validation(field, format!("{field} must be YYYY-MM")))
|
||||
}
|
||||
|
||||
/// The current month in Europe/Stockholm.
|
||||
pub fn current_period(now: DateTime<Utc>) -> Result<Period, AppError> {
|
||||
let local = now.with_timezone(&STOCKHOLM);
|
||||
let year = u16::try_from(local.year()).map_err(AppError::internal)?;
|
||||
let month = u8::try_from(local.month()).map_err(AppError::internal)?;
|
||||
Period::new(year, month).ok_or_else(|| AppError::internal("current month out of range"))
|
||||
}
|
||||
|
||||
/// `period` minus `months` months, or `None` before year 1.
|
||||
pub fn months_before(period: Period, months: u32) -> Option<Period> {
|
||||
let index = i64::from(period.year()) * 12 + i64::from(period.month()) - 1 - i64::from(months);
|
||||
let year = u16::try_from(index.div_euclid(12)).ok()?;
|
||||
let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
|
||||
Period::new(year, month)
|
||||
}
|
||||
|
||||
/// A template `start_period`: `YYYY-MM`, not earlier than 12 months before the current month (SPEC §9.6).
|
||||
pub fn start_period(value: &str, now: DateTime<Utc>) -> Result<Period, AppError> {
|
||||
let p = period("start_period", value)?;
|
||||
let earliest = months_before(current_period(now)?, START_PERIOD_BACKFILL_MONTHS);
|
||||
if earliest.is_some_and(|earliest| p < earliest) {
|
||||
return Err(AppError::validation(
|
||||
"start_period",
|
||||
"start_period may be at most 12 months before the current month",
|
||||
));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// `day_of_month` within 1 to 31.
|
||||
pub fn day_of_month(value: i64) -> Result<u8, AppError> {
|
||||
u8::try_from(value)
|
||||
.ok()
|
||||
.filter(|d| (1..=31).contains(d))
|
||||
.ok_or_else(|| AppError::validation("day_of_month", "day_of_month must be 1 to 31"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_counts_code_points_after_trimming() {
|
||||
assert_eq!(text("name", " Åsa ", 1, 3).unwrap(), "Åsa");
|
||||
assert!(text("name", " ", 1, 3).is_err());
|
||||
assert!(text("name", "Åsaa", 1, 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_allow_newline_only() {
|
||||
assert_eq!(notes("notes", "a\nb").unwrap(), "a\nb");
|
||||
assert!(notes("notes", "a\tb").is_err());
|
||||
assert!(notes("notes", "a\u{7}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn months_before_crosses_years() {
|
||||
let p: Period = "2026-10".parse().unwrap();
|
||||
assert_eq!(months_before(p, 12).unwrap().to_string(), "2025-10");
|
||||
assert_eq!(months_before(p, 10).unwrap().to_string(), "2025-12");
|
||||
assert_eq!(months_before(p, 9).unwrap().to_string(), "2026-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens() {
|
||||
assert!(icon("pets").is_ok());
|
||||
assert!(icon("dog").is_err());
|
||||
assert!(color("lime").is_ok());
|
||||
assert!(color("black").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user