spate_core/config/
component.rs1use super::ConfigError;
4use super::chunk::ChunkSection;
5use crate::ops::ChunkConfig;
6use serde::Deserialize;
7use serde::de::DeserializeOwned;
8
9#[derive(Debug, Clone, PartialEq)]
25pub struct ComponentConfig {
26 type_tag: String,
27 raw: serde_yaml::Value,
28 chunk: Option<serde_yaml::Value>,
34 section: Option<&'static str>,
37}
38
39fn 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 #[must_use]
49 pub fn type_tag(&self) -> &str {
50 &self.type_tag
51 }
52
53 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 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 pub(crate) fn has_chunk(&self) -> bool {
81 self.chunk.is_some()
82 }
83
84 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 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 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 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 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 let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
265 assert_eq!(typed.brokers, "b");
266 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 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 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}