Skip to main content

spate_json/
config.rs

1//! Configuration and construction.
2//!
3//! The `deserializer: { json: { ... } }` section of the pipeline YAML is
4//! handed here as an opaque
5//! [`ComponentConfig`](spate_core::config::ComponentConfig):
6//!
7//! ```yaml
8//! deserializer:
9//!   json:
10//!     framing: single              # single | ndjson | array
11//!     on_error: skip               # skip | fail
12//!     reject_duplicate_keys: false
13//! ```
14
15use crate::deser::{DecoderCore, JsonSerdeDeserializer, JsonValueDeserializer};
16use crate::metrics::JsonDeserMetrics;
17use serde::Deserialize;
18use serde::de::DeserializeOwned;
19use spate_core::config::ComponentConfig;
20use spate_core::framing::FramingContract;
21use spate_core::metrics::{Meter, SharedString};
22
23/// How one payload is split into JSON documents.
24#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum JsonFraming {
27    /// The whole payload is one JSON document → one record. An empty or
28    /// whitespace-only payload is a tombstone (zero records).
29    #[default]
30    Single,
31    /// Newline-delimited JSON (JSON Lines): one JSON value per `\n`-separated
32    /// line → one record per line, with per-line error isolation.
33    Ndjson,
34    /// A top-level JSON array → one record per element, decoded in one pass
35    /// (a malformed array is handled atomically by [`OnError`]).
36    Array,
37}
38
39/// What to do with a record that does not parse or match the target type.
40#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum OnError {
43    /// Drop the record, count it in
44    /// `spate_json_deser_records_dropped_total{reason}`, and continue. The
45    /// default for deserializers.
46    #[default]
47    Skip,
48    /// Surface a decode error on the first bad record. What that error then
49    /// *does* is the chain's deserializer
50    /// [`ErrorPolicy`](spate_core::ErrorPolicy): `Fail` stops the pipeline and the
51    /// batch replays under at-least-once; `Skip` (the default) drops the whole
52    /// payload. Multi-record framings (`ndjson`, `array`) fail all-or-nothing,
53    /// so a payload never emits a partial prefix before erroring.
54    Fail,
55}
56
57/// The `deserializer: { json: ... }` settings.
58#[derive(Clone, Debug, Default, Deserialize)]
59#[serde(deny_unknown_fields, default)]
60pub struct JsonSettings {
61    /// Payload framing.
62    pub framing: JsonFraming,
63    /// Record-level error policy.
64    pub on_error: OnError,
65    /// Reject (rather than silently last-value-wins) any JSON object with a
66    /// duplicate key, at any depth. Off by default; enabling it parses each
67    /// document a second time for the structural check.
68    pub reject_duplicate_keys: bool,
69}
70
71/// Construction errors.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum JsonConfigError {
75    /// The component section did not deserialize.
76    #[error(transparent)]
77    Config(#[from] spate_core::config::ConfigError),
78    /// The source already frames one record per payload, but the deserializer
79    /// is configured to frame the payload too (double-framing).
80    #[error(
81        "the source frames one record per payload, but the JSON deserializer is set \
82         to `framing: {framing:?}` — remove `framing` (or set it to `single`), or use \
83         a whole-object source format that lets the deserializer frame"
84    )]
85    DoubleFraming {
86        /// The offending deserializer framing.
87        framing: JsonFraming,
88    },
89}
90
91/// Builder produced from the opaque config section; hands out either the
92/// typed or the dynamically-typed deserializer.
93#[derive(Clone, Debug)]
94pub struct JsonDeserializerBuilder {
95    settings: JsonSettings,
96    metrics: Option<JsonDeserMetrics>,
97}
98
99impl JsonDeserializerBuilder {
100    /// Build from the pipeline's `deserializer` component section.
101    pub fn from_component(cfg: &ComponentConfig) -> Result<Self, JsonConfigError> {
102        let settings: JsonSettings = cfg.deserialize_into()?;
103        Ok(Self::from_settings(settings))
104    }
105
106    /// Build from already-parsed settings.
107    #[must_use]
108    pub fn from_settings(settings: JsonSettings) -> Self {
109        JsonDeserializerBuilder {
110            settings,
111            metrics: None,
112        }
113    }
114
115    /// Derive and validate framing against the source's
116    /// [`FramingContract`](spate_core::framing::FramingContract), so the format
117    /// is declared once (on the source) instead of coordinating the source's
118    /// framing with `framing:` by hand.
119    ///
120    /// - [`PerRecord`](FramingContract::PerRecord): the source already frames
121    ///   one record per payload, so the deserializer must decode a single
122    ///   document. `framing: single` (the default) is used; any other
123    ///   `framing` is a [`DoubleFraming`](JsonConfigError::DoubleFraming)
124    ///   error.
125    /// - [`WholePayload`](FramingContract::WholePayload): the deserializer owns
126    ///   framing — the configured `framing` (`single` / `ndjson` / `array`) is
127    ///   honored unchanged.
128    pub fn for_source_framing(self, contract: FramingContract) -> Result<Self, JsonConfigError> {
129        match contract {
130            FramingContract::PerRecord if self.settings.framing != JsonFraming::Single => {
131                Err(JsonConfigError::DoubleFraming {
132                    framing: self.settings.framing,
133                })
134            }
135            _ => Ok(self),
136        }
137    }
138
139    /// Scope the connector-owned `spate_json_deser_*` metrics to this component.
140    /// Reuse the `pipeline` / `component` values the framework was given so the
141    /// families join against the generic `spate_deser_*` stage metrics. Without
142    /// this, per-record skips are logged (rate-limited) but not counted.
143    #[must_use]
144    pub fn with_metrics(
145        mut self,
146        pipeline: impl Into<SharedString>,
147        component: impl Into<SharedString>,
148    ) -> Self {
149        let meter = Meter::with_namespace("json_deser", pipeline, component, "deserializer");
150        self.metrics = Some(JsonDeserMetrics::new(&meter));
151        self
152    }
153
154    /// Decode each document into your own `T: serde::de::DeserializeOwned`.
155    #[must_use]
156    pub fn build_serde<T>(&self) -> JsonSerdeDeserializer<T>
157    where
158        T: DeserializeOwned + Send + 'static,
159    {
160        JsonSerdeDeserializer::new(self.core())
161    }
162
163    /// Decode each document into a dynamically-typed [`serde_json::Value`].
164    #[must_use]
165    pub fn build_value(&self) -> JsonValueDeserializer {
166        JsonValueDeserializer::new(self.core())
167    }
168
169    fn core(&self) -> DecoderCore {
170        DecoderCore {
171            framing: self.settings.framing,
172            on_error: self.settings.on_error,
173            reject_duplicate_keys: self.settings.reject_duplicate_keys,
174            metrics: self.metrics.clone(),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use spate_core::config::{ComponentConfig, YamlValue};
183
184    fn component(yaml: &str) -> ComponentConfig {
185        let raw: YamlValue = serde_yaml::from_str(yaml).unwrap();
186        ComponentConfig::new("json", raw)
187    }
188
189    #[test]
190    fn defaults_are_single_skip_no_dedup() {
191        let s = JsonSettings::default();
192        assert_eq!(s.framing, JsonFraming::Single);
193        assert_eq!(s.on_error, OnError::Skip);
194        assert!(!s.reject_duplicate_keys);
195    }
196
197    #[test]
198    fn parses_a_full_section() {
199        let cfg = component("framing: ndjson\non_error: fail\nreject_duplicate_keys: true\n");
200        let b = JsonDeserializerBuilder::from_component(&cfg).unwrap();
201        assert_eq!(b.settings.framing, JsonFraming::Ndjson);
202        assert_eq!(b.settings.on_error, OnError::Fail);
203        assert!(b.settings.reject_duplicate_keys);
204    }
205
206    #[test]
207    fn empty_section_uses_defaults() {
208        // `json:` with no body → all defaults.
209        let cfg = ComponentConfig::new("json", YamlValue::Null);
210        let b = JsonDeserializerBuilder::from_component(&cfg).unwrap();
211        assert_eq!(b.settings.framing, JsonFraming::Single);
212    }
213
214    #[test]
215    fn unknown_fields_are_rejected() {
216        let cfg = component("framing: single\nbogus: 1\n");
217        let err = JsonDeserializerBuilder::from_component(&cfg).unwrap_err();
218        assert!(err.to_string().contains("bogus"), "{err}");
219    }
220
221    #[test]
222    fn unknown_framing_is_rejected() {
223        let cfg = component("framing: yaml\n");
224        let err = JsonDeserializerBuilder::from_component(&cfg).unwrap_err();
225        assert!(err.to_string().to_lowercase().contains("framing"), "{err}");
226    }
227
228    #[test]
229    fn per_record_source_derives_single_and_rejects_double_framing() {
230        // Omitted framing (defaults to single) under a per-record source is fine.
231        let b =
232            JsonDeserializerBuilder::from_component(&ComponentConfig::new("json", YamlValue::Null))
233                .unwrap()
234                .for_source_framing(FramingContract::PerRecord)
235                .unwrap();
236        assert_eq!(b.settings.framing, JsonFraming::Single);
237
238        // Explicit single is fine too.
239        JsonDeserializerBuilder::from_component(&component("framing: single\n"))
240            .unwrap()
241            .for_source_framing(FramingContract::PerRecord)
242            .unwrap();
243
244        // ndjson / array double-frame an already-framed source.
245        for framing in ["ndjson", "array"] {
246            let err = JsonDeserializerBuilder::from_component(&component(&format!(
247                "framing: {framing}\n"
248            )))
249            .unwrap()
250            .for_source_framing(FramingContract::PerRecord)
251            .unwrap_err();
252            assert!(
253                matches!(err, JsonConfigError::DoubleFraming { .. }),
254                "{framing}: {err}"
255            );
256        }
257    }
258
259    #[test]
260    fn whole_payload_source_honors_configured_framing() {
261        for framing in ["single", "ndjson", "array"] {
262            JsonDeserializerBuilder::from_component(&component(&format!("framing: {framing}\n")))
263                .unwrap()
264                .for_source_framing(FramingContract::WholePayload)
265                .unwrap();
266        }
267    }
268}