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)]
26pub struct ComponentConfig {
27 type_tag: String,
28 raw: serde_yaml::Value,
29 chunk: Option<serde_yaml::Value>,
36 section: Option<&'static str>,
39}
40
41fn 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 #[must_use]
51 pub fn type_tag(&self) -> &str {
52 &self.type_tag
53 }
54
55 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 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 pub(crate) fn has_chunk(&self) -> bool {
83 self.chunk.is_some()
84 }
85
86 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 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 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 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 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 let typed: FakeKafkaConfig = cc.deserialize_into().unwrap();
266 assert_eq!(typed.brokers, "b");
267 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 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 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}