Skip to main content

spate_core/config/
component.rs

1//! Opaque per-component configuration passthrough.
2
3use super::ConfigError;
4use super::chunk::ChunkSection;
5use super::redact;
6use crate::ops::ChunkConfig;
7use serde::Deserialize;
8use serde::de::DeserializeOwned;
9use std::fmt;
10
11/// An opaque component section: `{ <type_tag>: { ...connector config... } }`.
12///
13/// The framework records which component type the section selects (`kafka`,
14/// `clickhouse`, `memory`, ...) and hands the body to that component's
15/// factory, which deserializes it into its own typed config via
16/// [`deserialize_into`](Self::deserialize_into). **`chunk` is
17/// framework-reserved** and is the one exception to that opacity. It
18/// configures the chain terminal, not the connector, and is peeled out of the
19/// body at construction. A connector must not declare its own `chunk` field;
20/// it would never receive a value (on a sink section the framework consumes
21/// the key, and on a `source`/`deserializer` section the key is rejected
22/// outright).
23///
24/// The nested-block shape (exactly one key) lets every typed struct in the
25/// tree keep `deny_unknown_fields`. A flattened shape would disable that
26/// check.
27#[derive(Clone, PartialEq)]
28pub struct ComponentConfig {
29    type_tag: String,
30    raw: serde_yaml::Value,
31    /// The framework-reserved `chunk:` block, peeled out of `raw` at
32    /// construction so the connector's `deny_unknown_fields` never sees it.
33    /// Stored unparsed and resolved lazily by
34    /// [`resolved_chunk`](Self::resolved_chunk). Construction, including the
35    /// infallible [`new`](Self::new), must not fail on a malformed block; it
36    /// surfaces at assembly/validation instead.
37    chunk: Option<serde_yaml::Value>,
38    /// Where this section sits in the pipeline config (`source`, `sink`,
39    /// `deserializer`). Set after parsing, used to prefix error paths.
40    section: Option<&'static str>,
41}
42
43// Hand-written: `raw` holds interpolated credentials. The destructure lists
44// every field so a new one cannot reach `Debug` unredacted.
45impl fmt::Debug for ComponentConfig {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        let ComponentConfig {
48            type_tag,
49            raw,
50            chunk,
51            section,
52        } = self;
53        f.debug_struct("ComponentConfig")
54            .field("type_tag", type_tag)
55            .field("raw", &redact::yaml(raw))
56            .field("chunk", &chunk.as_ref().map(redact::yaml))
57            .field("section", section)
58            .finish()
59    }
60}
61
62/// Remove the framework-reserved `chunk` key from a component body so the
63/// connector never sees it. A non-mapping body (a `memory:` null, a scalar)
64/// has nothing to peel.
65fn peel_chunk(raw: &mut serde_yaml::Value) -> Option<serde_yaml::Value> {
66    raw.as_mapping_mut().and_then(|m| m.remove("chunk"))
67}
68
69impl ComponentConfig {
70    /// Which component implementation this section selects.
71    #[must_use]
72    pub fn type_tag(&self) -> &str {
73        &self.type_tag
74    }
75
76    /// Deserialize the opaque body into the component's typed config.
77    ///
78    /// Errors carry the full dotted path from the pipeline config root,
79    /// e.g. `source.kafka.brokers: missing field \`brokers\``, and name the
80    /// expected type without the value.
81    pub fn deserialize_into<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
82        serde_path_to_error::deserialize(self.raw.clone())
83            .map_err(|e| self.component_error(None, e))
84    }
85
86    /// Resolve the framework-reserved `chunk:` block, if present, into a
87    /// runtime [`ChunkConfig`]. `Ok(None)` when the section declared no
88    /// `chunk:`. Errors carry the dotted path (`sink.clickhouse.chunk.…`).
89    ///
90    /// Chunk configures the chain terminal, so this is only meaningful on a
91    /// sink section; [`PipelineConfig::validate`](super::PipelineConfig::validate)
92    /// rejects a stray `chunk:` on a `source`/`deserializer` section.
93    pub(crate) fn resolved_chunk(&self) -> Result<Option<ChunkConfig>, ConfigError> {
94        let Some(value) = &self.chunk else {
95            return Ok(None);
96        };
97        let section: ChunkSection = serde_path_to_error::deserialize(value.clone())
98            .map_err(|e| self.component_error(Some("chunk"), e))?;
99        section.resolve().map(Some)
100    }
101
102    /// Whether this section declared a `chunk:` block (without resolving it).
103    /// Lets assembly warn about a block that nothing will ever read.
104    pub(crate) fn has_chunk(&self) -> bool {
105        self.chunk.is_some()
106    }
107
108    /// Dotted location of this component in the config, for error messages.
109    fn prefix(&self) -> String {
110        match self.section {
111            Some(section) => format!("{section}.{}", self.type_tag),
112            None => self.type_tag.clone(),
113        }
114    }
115
116    /// A [`ConfigError::Component`] anchored at this section's dotted path,
117    /// with an optional sub-block `suffix` (e.g. `chunk`) and the error's own
118    /// inner path appended.
119    fn component_error(
120        &self,
121        suffix: Option<&str>,
122        e: serde_path_to_error::Error<serde_yaml::Error>,
123    ) -> ConfigError {
124        let mut context = self.prefix();
125        if let Some(suffix) = suffix {
126            context.push('.');
127            context.push_str(suffix);
128        }
129        let inner_path = e.path().to_string();
130        if inner_path != "." && !inner_path.is_empty() {
131            context.push('.');
132            context.push_str(&inner_path);
133        }
134        ConfigError::Component {
135            context,
136            message: redact::error_message(&e.into_inner().to_string()).into_owned(),
137        }
138    }
139
140    pub(super) fn set_section(&mut self, section: &'static str) {
141        self.section = Some(section);
142    }
143
144    /// Build a component config programmatically (primarily for tests and
145    /// `spate-test` pipelines that skip YAML).
146    ///
147    /// `raw` is the opaque connector body as a [`YamlValue`](super::YamlValue)
148    /// (an `spate-core` re-export of `serde_yaml::Value`; see its docs for the
149    /// dependency-policy exemption). The framework-reserved `chunk` key is
150    /// peeled out of `raw` here, as on the YAML path. See the struct docs.
151    pub fn new(type_tag: impl Into<String>, mut raw: super::YamlValue) -> Self {
152        let chunk = peel_chunk(&mut raw);
153        ComponentConfig {
154            type_tag: type_tag.into(),
155            raw,
156            chunk,
157            section: None,
158        }
159    }
160}
161
162impl<'de> Deserialize<'de> for ComponentConfig {
163    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
164    where
165        D: serde::Deserializer<'de>,
166    {
167        use serde::de::Error as _;
168        let mapping = serde_yaml::Mapping::deserialize(deserializer)?;
169        if mapping.len() != 1 {
170            return Err(D::Error::custom(format!(
171                "a component section must be a single-key mapping selecting the \
172                 component type, e.g. `kafka: {{ ... }}` — found {} keys",
173                mapping.len()
174            )));
175        }
176        let (key, mut value) = mapping.into_iter().next().expect("len checked above");
177        let type_tag = key.as_str().ok_or_else(|| {
178            D::Error::custom("component type tag must be a string, e.g. `kafka:`")
179        })?;
180        let chunk = peel_chunk(&mut value);
181        Ok(ComponentConfig {
182            type_tag: type_tag.to_owned(),
183            raw: value,
184            chunk,
185            section: None,
186        })
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[derive(Debug, PartialEq, Deserialize)]
195    #[serde(deny_unknown_fields)]
196    struct FakeKafkaConfig {
197        brokers: String,
198        topic: String,
199        #[serde(default)]
200        batch: Batch,
201    }
202
203    #[derive(Debug, PartialEq, Deserialize, Default)]
204    #[serde(deny_unknown_fields)]
205    struct Batch {
206        max_rows: Option<u64>,
207    }
208
209    fn parse(yaml: &str) -> Result<ComponentConfig, serde_yaml::Error> {
210        serde_yaml::from_str(yaml)
211    }
212
213    #[test]
214    fn parses_single_key_section_and_deserializes_body() {
215        let cc = parse("kafka:\n  brokers: k1:9092\n  topic: orders\n").unwrap();
216        assert_eq!(cc.type_tag(), "kafka");
217        let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
218        assert_eq!(typed.brokers, "k1:9092");
219        assert_eq!(typed.topic, "orders");
220    }
221
222    #[test]
223    fn rejects_zero_and_multiple_keys() {
224        let err = parse("{}").unwrap_err().to_string();
225        assert!(err.contains("single-key mapping"), "{err}");
226        assert!(err.contains("0 keys"), "{err}");
227
228        let err = parse("kafka: {}\nmemory: {}\n").unwrap_err().to_string();
229        assert!(err.contains("2 keys"), "{err}");
230    }
231
232    #[test]
233    fn rejects_non_string_tag() {
234        let err = parse("7: {}").unwrap_err().to_string();
235        assert!(err.contains("type tag must be a string"), "{err}");
236    }
237
238    /// `Debug` shows the section's keys and shape and none of its values.
239    #[test]
240    fn debug_never_prints_component_values() {
241        let cc = parse(
242            "clickhouse:\n  password: hunter2\n  shards: [{replicas: [http://u:hunter2@ch]}]\n  \
243             chunk: {max_bytes: 1MiB}\n",
244        )
245        .unwrap();
246        let printed = format!("{cc:?}");
247        assert!(!printed.contains("hunter2"), "{printed}");
248        assert!(!printed.contains("1MiB"), "{printed}");
249        for visible in ["clickhouse", "password", "replicas", "max_bytes"] {
250            assert!(printed.contains(visible), "{visible}: {printed}");
251        }
252    }
253
254    #[test]
255    fn error_paths_include_section_tag_and_field() {
256        let mut cc = parse("kafka:\n  brokers: k1:9092\n").unwrap();
257        cc.set_section("source");
258        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
259        let text = err.to_string();
260        assert!(text.starts_with("source.kafka"), "{text}");
261        assert!(text.contains("topic"), "{text}");
262    }
263
264    /// A type error names the field and the expected type and does not quote
265    /// the value. Regression for #764.
266    #[test]
267    fn type_errors_omit_the_value() {
268        #[derive(Debug, Deserialize)]
269        #[serde(rename_all = "snake_case")]
270        enum Mechanism {
271            Plain,
272            Scram,
273        }
274
275        #[derive(Debug, Deserialize)]
276        #[serde(deny_unknown_fields)]
277        #[allow(dead_code)]
278        struct Credentials {
279            password: Option<String>,
280            port: Option<u16>,
281            mechanism: Option<Mechanism>,
282            #[serde(default, with = "humantime_serde")]
283            timeout: Option<std::time::Duration>,
284        }
285
286        for (body, secret, path, expected) in [
287            (
288                "password: 918273645",
289                "918273645",
290                "password",
291                "expected a string",
292            ),
293            ("password: true", "true", "password", "expected a string"),
294            ("password: 1.5e3", "1500", "password", "expected a string"),
295            ("port: hunter2", "hunter2", "port", "expected u16"),
296            ("port: 918273645", "918273645", "port", "expected u16"),
297            (
298                "mechanism: hunter2",
299                "hunter2",
300                "mechanism",
301                "expected `plain` or `scram`",
302            ),
303            (
304                "mechanism: !hunter2 ~",
305                "hunter2",
306                "mechanism",
307                "expected `plain` or `scram`",
308            ),
309            (
310                "timeout: hunter2",
311                "hunter2",
312                "timeout",
313                "expected a duration",
314            ),
315        ] {
316            let mut cc = parse(&format!("clickhouse:\n  {body}\n")).unwrap();
317            cc.set_section("sink");
318            let err = cc.deserialize_into::<Credentials>().unwrap_err();
319            for shown in [err.to_string(), format!("{err:?}")] {
320                assert!(!shown.contains(secret), "{body}: {shown}");
321                assert!(
322                    shown.contains(&format!("sink.clickhouse.{path}")),
323                    "{body}: {shown}"
324                );
325                assert!(shown.contains(expected), "{body}: {shown}");
326            }
327        }
328    }
329
330    #[test]
331    fn nested_error_paths_point_at_the_field() {
332        let mut cc =
333            parse("kafka:\n  brokers: b\n  topic: t\n  batch:\n    max_rows: lots\n").unwrap();
334        cc.set_section("source");
335        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
336        let text = err.to_string();
337        assert!(text.contains("source.kafka.batch.max_rows"), "{text}");
338    }
339
340    #[test]
341    fn unknown_field_in_component_body_is_rejected_with_path() {
342        let cc = parse("kafka:\n  brokers: b\n  topic: t\n  bogus: 1\n").unwrap();
343        let err = cc.deserialize_into::<FakeKafkaConfig>().unwrap_err();
344        assert!(err.to_string().contains("bogus"), "{err}");
345    }
346
347    #[test]
348    fn empty_body_deserializes_into_defaultable_types() {
349        #[derive(Debug, Deserialize, Default)]
350        struct Empty {}
351        let cc = parse("memory:\n").unwrap();
352        assert_eq!(cc.type_tag(), "memory");
353        // `memory:` with no body is a null value; struct with no required
354        // fields must accept it.
355        let _typed: Option<Empty> = cc.deserialize_into().unwrap();
356    }
357
358    #[test]
359    fn reserved_chunk_is_peeled_so_the_connector_never_sees_it() {
360        // A `chunk:` block sits beside the connector's own keys. It must be
361        // stripped before the body reaches the (deny_unknown_fields) connector
362        // config, and be resolvable by the framework.
363        let cc = parse(
364            "kafka:\n  brokers: b\n  topic: t\n  chunk:\n    target_bytes: 256KiB\n    encode_policy: fail\n",
365        )
366        .unwrap();
367        assert_eq!(cc.type_tag(), "kafka");
368        // The connector's strict config still deserializes; `chunk` is gone.
369        let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
370        assert_eq!(typed.brokers, "b");
371        // The framework resolves the peeled block.
372        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
373        assert_eq!(chunk.target_bytes, 256 * 1024);
374        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
375    }
376
377    #[test]
378    fn programmatic_new_also_peels_chunk() {
379        let body: super::super::YamlValue =
380            serde_yaml::from_str("brokers: b\ntopic: t\nchunk:\n  target_bytes: 32KiB\n").unwrap();
381        let cc = ComponentConfig::new("kafka", body);
382        let _typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
383        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
384        assert_eq!(chunk.target_bytes, 32 * 1024);
385    }
386
387    #[test]
388    fn no_chunk_block_resolves_to_none() {
389        let cc = parse("kafka:\n  brokers: b\n  topic: t\n").unwrap();
390        assert!(cc.resolved_chunk().unwrap().is_none());
391        // A non-mapping body has nothing to peel.
392        let cc = parse("memory:\n").unwrap();
393        assert!(cc.resolved_chunk().unwrap().is_none());
394    }
395
396    #[test]
397    fn bare_chunk_key_resolves_to_the_defaults() {
398        // `chunk:` with no body (a natural "defaults, please"). The null value
399        // deserializes as an empty mapping, i.e. 64 KiB / Skip, matching
400        // `chunk: {}`. Pinned so a serde change can't turn it into an error.
401        let cc = parse("clickhouse:\n  chunk:\n").unwrap();
402        let chunk = cc.resolved_chunk().unwrap().expect("chunk present");
403        assert_eq!(chunk.target_bytes, 64 * 1024);
404        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Skip);
405    }
406
407    #[test]
408    fn malformed_chunk_surfaces_at_resolve_with_a_dotted_path() {
409        let mut cc = parse("clickhouse:\n  chunk:\n    target_bytes: 0B\n").unwrap();
410        cc.set_section("sink");
411        let err = cc.resolved_chunk().unwrap_err().to_string();
412        assert!(err.contains("chunk.target_bytes"), "{err}");
413    }
414}