From 635bea4de4eed088806e559deb3dbd62801a3410 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Sat, 25 Apr 2026 00:02:32 +0300 Subject: [PATCH] feat(api): add Patch enum for JSON merge-patch semantics Introduce a three-state Patch (Unchanged / Remove / Set) and a serde helper patch_field that distinguishes an omitted JSON field from an explicit null. Wired up next as the field type for the removable settings on PATCH /v1/users/{user}. --- src/api/mod.rs | 1 + src/api/patch.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/api/patch.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index f33a89b..1778d7d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,7 @@ mod config_store; mod events; mod http_utils; mod model; +mod patch; mod runtime_edge; mod runtime_init; mod runtime_min; diff --git a/src/api/patch.rs b/src/api/patch.rs new file mode 100644 index 0000000..6425af1 --- /dev/null +++ b/src/api/patch.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; + +/// Three-state field for JSON Merge Patch semantics on the `PATCH /v1/users/{user}` +/// endpoint. +/// +/// `Unchanged` is produced when the JSON body omits the field entirely and tells the +/// handler to leave the corresponding configuration entry untouched. `Remove` is +/// produced when the JSON body sets the field to `null` and instructs the handler to +/// drop the entry from the corresponding access HashMap. `Set` carries an explicit +/// new value, including zero, which is preserved verbatim in the configuration. +#[derive(Debug)] +pub(super) enum Patch { + Unchanged, + Remove, + Set(T), +} + +impl Default for Patch { + fn default() -> Self { + Self::Unchanged + } +} + +/// Serde deserializer adapter for fields that follow JSON Merge Patch semantics. +/// +/// Pair this with `#[serde(default, deserialize_with = "patch_field")]` on a +/// `Patch` field. An omitted field falls back to `Patch::Unchanged` via +/// `Default`; an explicit JSON `null` becomes `Patch::Remove`; any other value +/// becomes `Patch::Set(v)`. +pub(super) fn patch_field<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(|opt| match opt { + Some(value) => Patch::Set(value), + None => Patch::Remove, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct Holder { + #[serde(default, deserialize_with = "patch_field")] + value: Patch, + } + + fn parse(json: &str) -> Holder { + serde_json::from_str(json).expect("valid json") + } + + #[test] + fn omitted_field_yields_unchanged() { + let h = parse("{}"); + assert!(matches!(h.value, Patch::Unchanged)); + } + + #[test] + fn explicit_null_yields_remove() { + let h = parse(r#"{"value": null}"#); + assert!(matches!(h.value, Patch::Remove)); + } + + #[test] + fn explicit_value_yields_set() { + let h = parse(r#"{"value": 42}"#); + assert!(matches!(h.value, Patch::Set(42))); + } + + #[test] + fn explicit_zero_yields_set_zero() { + let h = parse(r#"{"value": 0}"#); + assert!(matches!(h.value, Patch::Set(0))); + } +}