Skip to main content

spate_core/config/
component.rs

1//! Opaque per-component configuration passthrough.
2
3use super::ConfigError;
4use super::chunk::ChunkSection;
5use crate::ops::ChunkConfig;
6use serde::Deserialize;
7use serde::de::DeserializeOwned;
8
9/// An opaque component section: `{ <type_tag>: { ...connector config... } }`.
10///
11/// The framework records which component type the section selects (`kafka`,
12/// `clickhouse`, `memory`, ...) and hands the body to that component's
13/// factory, which deserializes it into its own typed config via
14/// [`deserialize_into`](Self::deserialize_into). One key is the exception to
15/// that opacity: **`chunk` is framework-reserved** — it configures the chain
16/// terminal, not the connector, and is peeled out of the body at construction.
17/// A connector must not declare its own `chunk` field: it would never receive
18/// a value (on a sink section the framework consumes the key; on a
19/// `source`/`deserializer` section the key is rejected outright).
20///
21/// The nested-block shape (exactly one key) is what lets every typed struct
22/// in the tree keep `deny_unknown_fields` — a flattened shape would disable
23/// that check.
24#[derive(Debug, Clone, PartialEq)]
25pub struct ComponentConfig {
26    type_tag: String,
27    raw: serde_yaml::Value,
28    /// The framework-reserved `chunk:` block, peeled out of `raw` at
29    /// construction so the connector's `deny_unknown_fields` never sees it.
30    /// Stored unparsed and resolved lazily by [`resolved_chunk`](Self::resolved_chunk)
31    /// (construction — including the infallible [`new`](Self::new) — must not
32    /// fail on a malformed block; it surfaces at assembly/validation instead).
33    chunk: Option<serde_yaml::Value>,
34    /// Where this section sits in the pipeline config (`source`, `sink`,
35    /// `deserializer`) — set after parsing, used to prefix error paths.
36    section: Option<&'static str>,
37}
38
39/// Remove the framework-reserved `chunk` key from a component body so the
40/// connector never sees it. A non-mapping body (a `memory:` null, a scalar)
41/// has nothing to peel.
42fn peel_chunk(raw: &mut serde_yaml::Value) -> Option<serde_yaml::Value> {
43    raw.as_mapping_mut().and_then(|m| m.remove("chunk"))
44}
45
46impl ComponentConfig {
47    /// Which component implementation this section selects.
48    #[must_use]
49    pub fn type_tag(&self) -> &str {
50        &self.type_tag
51    }
52
53    /// Deserialize the opaque body into the component's typed config.
54    ///
55    /// Errors carry the full dotted path from the pipeline config root,
56    /// e.g. `source.kafka.brokers: missing field \`brokers\``.
57    pub fn deserialize_into<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
58        serde_path_to_error::deserialize(self.raw.clone())
59            .map_err(|e| self.component_error(None, e))
60    }
61
62    /// Resolve the framework-reserved `chunk:` block, if present, into a
63    /// runtime [`ChunkConfig`]. `Ok(None)` when the section declared no
64    /// `chunk:`. Errors carry the dotted path (`sink.clickhouse.chunk.…`).
65    ///
66    /// Chunk configures the chain terminal, so this is only meaningful on a
67    /// sink section; [`PipelineConfig::validate`](super::PipelineConfig::validate)
68    /// rejects a stray `chunk:` on a `source`/`deserializer` section.
69    pub(crate) fn resolved_chunk(&self) -> Result<Option<ChunkConfig>, ConfigError> {
70        let Some(value) = &self.chunk else {
71            return Ok(None);
72        };
73        let section: ChunkSection = serde_path_to_error::deserialize(value.clone())
74            .map_err(|e| self.component_error(Some("chunk"), e))?;
75        section.resolve().map(Some)
76    }
77
78    /// Whether this section declared a `chunk:` block (without resolving it) —
79    /// lets assembly warn about a block that nothing will ever read.
80    pub(crate) fn has_chunk(&self) -> bool {
81        self.chunk.is_some()
82    }
83
84    /// Dotted location of this component in the config, for error messages.
85    fn prefix(&self) -> String {
86        match self.section {
87            Some(section) => format!("{section}.{}", self.type_tag),
88            None => self.type_tag.clone(),
89        }
90    }
91
92    /// A [`ConfigError::Component`] anchored at this section's dotted path,
93    /// with an optional sub-block `suffix` (e.g. `chunk`) and the error's own
94    /// inner path appended.
95    fn component_error(
96        &self,
97        suffix: Option<&str>,
98        e: serde_path_to_error::Error<serde_yaml::Error>,
99    ) -> ConfigError {
100        let mut context = self.prefix();
101        if let Some(suffix) = suffix {
102            context.push('.');
103            context.push_str(suffix);
104        }
105        let inner_path = e.path().to_string();
106        if inner_path != "." && !inner_path.is_empty() {
107            context.push('.');
108            context.push_str(&inner_path);
109        }
110        ConfigError::Component {
111            context,
112            message: e.into_inner().to_string(),
113        }
114    }
115
116    pub(super) fn set_section(&mut self, section: &'static str) {
117        self.section = Some(section);
118    }
119
120    /// Build a component config programmatically (primarily for tests and
121    /// `spate-test` pipelines that skip YAML).
122    ///
123    /// `raw` is the opaque connector body as a [`YamlValue`](super::YamlValue)
124    /// (an `spate-core` re-export of `serde_yaml::Value` — see its docs for the
125    /// dependency-policy exemption). The framework-reserved `chunk` key is
126    /// peeled out of `raw` here, exactly as on the YAML path — see the struct
127    /// docs.
128    pub fn new(type_tag: impl Into<String>, mut raw: super::YamlValue) -> Self {
129        let chunk = peel_chunk(&mut raw);
130        ComponentConfig {
131            type_tag: type_tag.into(),
132            raw,
133            chunk,
134            section: None,
135        }
136    }
137}
138
139impl<'de> Deserialize<'de> for ComponentConfig {
140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141    where
142        D: serde::Deserializer<'de>,
143    {
144        use serde::de::Error as _;
145        let mapping = serde_yaml::Mapping::deserialize(deserializer)?;
146        if mapping.len() != 1 {
147            return Err(D::Error::custom(format!(
148                "a component section must be a single-key mapping selecting the \
149                 component type, e.g. `kafka: {{ ... }}` — found {} keys",
150                mapping.len()
151            )));
152        }
153        let (key, mut value) = mapping.into_iter().next().expect("len checked above");
154        let type_tag = key.as_str().ok_or_else(|| {
155            D::Error::custom("component type tag must be a string, e.g. `kafka:`")
156        })?;
157        let chunk = peel_chunk(&mut value);
158        Ok(ComponentConfig {
159            type_tag: type_tag.to_owned(),
160            raw: value,
161            chunk,
162            section: None,
163        })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[derive(Debug, PartialEq, Deserialize)]
172    #[serde(deny_unknown_fields)]
173    struct FakeKafkaConfig {
174        brokers: String,
175        topic: String,
176        #[serde(default)]
177        batch: Batch,
178    }
179
180    #[derive(Debug, PartialEq, Deserialize, Default)]
181    #[serde(deny_unknown_fields)]
182    struct Batch {
183        max_rows: Option<u64>,
184    }
185
186    fn parse(yaml: &str) -> Result<ComponentConfig, serde_yaml::Error> {
187        serde_yaml::from_str(yaml)
188    }
189
190    #[test]
191    fn parses_single_key_section_and_deserializes_body() {
192        let cc = parse("kafka:\n  brokers: k1:9092\n  topic: orders\n").unwrap();
193        assert_eq!(cc.type_tag(), "kafka");
194        let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
195        assert_eq!(typed.brokers, "k1:9092");
196        assert_eq!(typed.topic, "orders");
197    }
198
199    #[test]
200    fn rejects_zero_and_multiple_keys() {
201        let err = parse("{}").unwrap_err().to_string();
202        assert!(err.contains("single-key mapping"), "{err}");
203        assert!(err.contains("0 keys"), "{err}");
204
205        let err = parse("kafka: {}\nmemory: {}\n").unwrap_err().to_string();
206        assert!(err.contains("2 keys"), "{err}");
207    }
208
209    #[test]
210    fn rejects_non_string_tag() {
211        let err = parse("7: {}").unwrap_err().to_string();
212        assert!(err.contains("type tag must be a string"), "{err}");
213    }
214
215    #[test]
216    fn error_paths_include_section_tag_and_field() {
217        let mut cc = parse("kafka:\n  brokers: k1:9092\n").unwrap();
218        cc.set_section("source");
219        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
220        let text = err.to_string();
221        assert!(text.starts_with("source.kafka"), "{text}");
222        assert!(text.contains("topic"), "{text}");
223    }
224
225    #[test]
226    fn nested_error_paths_point_at_the_field() {
227        let mut cc =
228            parse("kafka:\n  brokers: b\n  topic: t\n  batch:\n    max_rows: lots\n").unwrap();
229        cc.set_section("source");
230        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
231        let text = err.to_string();
232        assert!(text.contains("source.kafka.batch.max_rows"), "{text}");
233    }
234
235    #[test]
236    fn unknown_field_in_component_body_is_rejected_with_path() {
237        let cc = parse("kafka:\n  brokers: b\n  topic: t\n  bogus: 1\n").unwrap();
238        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
239        assert!(err.to_string().contains("bogus"), "{err}");
240    }
241
242    #[test]
243    fn empty_body_deserializes_into_defaultable_types() {
244        #[derive(Debug, Deserialize, Default)]
245        struct Empty {}
246        let cc = parse("memory:\n").unwrap();
247        assert_eq!(cc.type_tag(), "memory");
248        // `memory:` with no body is a null value; struct with no required
249        // fields must accept it.
250        let _typed: Option<Empty> = cc.deserialize_into().unwrap();
251    }
252
253    #[test]
254    fn reserved_chunk_is_peeled_so_the_connector_never_sees_it() {
255        // A `chunk:` block sits beside the connector's own keys. It must be
256        // stripped before the body reaches the (deny_unknown_fields) connector
257        // config, and be resolvable by the framework.
258        let cc = parse(
259            "kafka:\n  brokers: b\n  topic: t\n  chunk:\n    target_bytes: 256KiB\n    encode_policy: fail\n",
260        )
261        .unwrap();
262        assert_eq!(cc.type_tag(), "kafka");
263        // The connector's strict config still deserializes — `chunk` is gone.
264        let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
265        assert_eq!(typed.brokers, "b");
266        // The framework resolves the peeled block.
267        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
268        assert_eq!(chunk.target_bytes, 256 * 1024);
269        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
270    }
271
272    #[test]
273    fn programmatic_new_also_peels_chunk() {
274        let body: super::super::YamlValue =
275            serde_yaml::from_str("brokers: b\ntopic: t\nchunk:\n  target_bytes: 32KiB\n").unwrap();
276        let cc = ComponentConfig::new("kafka", body);
277        let _typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
278        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
279        assert_eq!(chunk.target_bytes, 32 * 1024);
280    }
281
282    #[test]
283    fn no_chunk_block_resolves_to_none() {
284        let cc = parse("kafka:\n  brokers: b\n  topic: t\n").unwrap();
285        assert!(cc.resolved_chunk().unwrap().is_none());
286        // A non-mapping body has nothing to peel.
287        let cc = parse("memory:\n").unwrap();
288        assert!(cc.resolved_chunk().unwrap().is_none());
289    }
290
291    #[test]
292    fn bare_chunk_key_resolves_to_the_defaults() {
293        // `chunk:` with no body (a natural "defaults, please") — the null
294        // value deserializes as an empty mapping, i.e. 64 KiB / Skip, matching
295        // `chunk: {}`. Pinned so a serde change can't turn it into an error.
296        let cc = parse("clickhouse:\n  chunk:\n").unwrap();
297        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
298        assert_eq!(chunk.target_bytes, 64 * 1024);
299        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Skip);
300    }
301
302    #[test]
303    fn malformed_chunk_surfaces_at_resolve_with_a_dotted_path() {
304        let mut cc = parse("clickhouse:\n  chunk:\n    target_bytes: 0B\n").unwrap();
305        cc.set_section("sink");
306        let err = cc.resolved_chunk().unwrap_err().to_string();
307        assert!(err.contains("chunk.target_bytes"), "{err}");
308    }
309}