1use 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#[derive(Clone, PartialEq)]
28pub struct ComponentConfig {
29 type_tag: String,
30 raw: serde_yaml::Value,
31 chunk: Option<serde_yaml::Value>,
38 section: Option<&'static str>,
41}
42
43impl 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
62fn 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 #[must_use]
72 pub fn type_tag(&self) -> &str {
73 &self.type_tag
74 }
75
76 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 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 pub(crate) fn has_chunk(&self) -> bool {
105 self.chunk.is_some()
106 }
107
108 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 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 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 #[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 #[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 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 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 let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
370 assert_eq!(typed.brokers, "b");
371 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 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 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}