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