Skip to main content

camel_api/
component_metadata.rs

1//! Component metadata schema types.
2//!
3//! Defines the core metadata structures used by component registries and
4//! schema generation: option definitions, capability declarations, and the
5//! top-level component descriptor.
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// OptionKind — closed enum of supported option value types
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
15#[cfg_attr(feature = "schema", ts(rename_all = "snake_case"))]
16#[serde(rename_all = "snake_case")]
17pub enum OptionKind {
18    String,
19    Int,
20    Bool,
21    Float,
22    Duration,
23    Enum(Vec<String>),
24    List(Box<OptionKind>),
25}
26
27// ---------------------------------------------------------------------------
28// UriOption — a single URI-parameter definition with builder
29// ---------------------------------------------------------------------------
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
33#[cfg_attr(feature = "schema", ts(rename_all = "snake_case"))]
34pub struct UriOption {
35    pub name: String,
36    pub description: String,
37    pub kind: OptionKind,
38    #[cfg_attr(feature = "schema", schemars(default))]
39    #[serde(default)]
40    pub required: bool,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub default_value: Option<String>,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub aliases: Vec<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub deprecated: Option<String>,
47    #[cfg_attr(feature = "schema", schemars(default))]
48    #[serde(default)]
49    pub secret: bool,
50}
51
52impl UriOption {
53    pub fn new(name: &str, description: &str, kind: OptionKind) -> Self {
54        Self {
55            name: name.to_string(),
56            description: description.to_string(),
57            kind,
58            required: false,
59            default_value: None,
60            aliases: Vec::new(),
61            deprecated: None,
62            secret: false,
63        }
64    }
65
66    #[must_use]
67    pub fn required(mut self) -> Self {
68        self.required = true;
69        self
70    }
71
72    #[must_use]
73    pub fn with_default(mut self, value: &str) -> Self {
74        self.default_value = Some(value.to_string());
75        self
76    }
77
78    #[must_use]
79    pub fn with_alias(mut self, alias: &str) -> Self {
80        self.aliases.push(alias.to_string());
81        self
82    }
83
84    #[must_use]
85    pub fn deprecated(mut self, reason: &str) -> Self {
86        self.deprecated = Some(reason.to_string());
87        self
88    }
89
90    #[must_use]
91    pub fn secret(mut self) -> Self {
92        self.secret = true;
93        self
94    }
95}
96
97// ---------------------------------------------------------------------------
98// ComponentCapabilities — named boolean flags + query matching
99// ---------------------------------------------------------------------------
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
103pub struct ComponentCapabilities {
104    pub supports_consumer: bool,
105    pub supports_producer: bool,
106    pub supports_polling_consumer: bool,
107    pub supports_streaming: bool,
108}
109
110impl ComponentCapabilities {
111    /// Returns `true` when every field present in `query` matches this
112    /// capability set.  Fields set to `None` in the query are ignored
113    /// (no constraint).
114    pub fn matches_query(&self, query: &CapabilityQuery) -> bool {
115        query
116            .supports_consumer
117            .is_none_or(|v| self.supports_consumer == v)
118            && query
119                .supports_producer
120                .is_none_or(|v| self.supports_producer == v)
121            && query
122                .supports_polling_consumer
123                .is_none_or(|v| self.supports_polling_consumer == v)
124            && query
125                .supports_streaming
126                .is_none_or(|v| self.supports_streaming == v)
127    }
128}
129
130// ---------------------------------------------------------------------------
131// CapabilityQuery — tri-state query for filtering components
132// ---------------------------------------------------------------------------
133
134/// Tri-state query struct.  Each field, when `Some(v)`, constrains the
135/// corresponding [`ComponentCapabilities`] field to equal `v`.  `None`
136/// means "don't care".
137#[derive(Debug, Clone, Default)]
138pub struct CapabilityQuery {
139    pub supports_consumer: Option<bool>,
140    pub supports_producer: Option<bool>,
141    pub supports_polling_consumer: Option<bool>,
142    pub supports_streaming: Option<bool>,
143}
144
145// ---------------------------------------------------------------------------
146// ComponentMetadata — top-level component descriptor
147// ---------------------------------------------------------------------------
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
151pub struct ComponentMetadata {
152    pub scheme: String,
153    pub schema_version: String,
154    pub version: String,
155    pub description: String,
156    pub uri_syntax: String,
157    pub capabilities: ComponentCapabilities,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub uri_options: Vec<UriOption>,
160}
161
162impl ComponentMetadata {
163    /// Current schema version string.
164    pub const SCHEMA_VERSION: &'static str = "1";
165
166    /// Build a minimal metadata entry for `scheme`.
167    ///
168    /// Sets `schema_version` to [`SCHEMA_VERSION`].  The `version` field is
169    /// left empty — components that override [`Component::metadata`] should
170    /// supply their own `env!("CARGO_PKG_VERSION")`.
171    pub fn minimal(scheme: &str) -> Self {
172        Self {
173            scheme: scheme.to_string(),
174            schema_version: Self::SCHEMA_VERSION.to_string(),
175            version: String::new(),
176            description: String::new(),
177            uri_syntax: String::new(),
178            capabilities: ComponentCapabilities::default(),
179            uri_options: Vec::new(),
180        }
181    }
182
183    /// Validate that this metadata's `scheme` matches the given
184    /// `component_scheme`.  Returns `Err` with a descriptive message on
185    /// mismatch.
186    pub fn validate_scheme(&self, component_scheme: &str) -> Result<(), String> {
187        if self.scheme == component_scheme {
188            Ok(())
189        } else {
190            Err(format!(
191                "Scheme mismatch: component metadata scheme is '{}' but component scheme is '{}'",
192                self.scheme, component_scheme
193            ))
194        }
195    }
196}
197
198// ---------------------------------------------------------------------------
199// ComponentMetadataCatalog — runtime query interface
200// ---------------------------------------------------------------------------
201
202/// Query interface for component metadata at runtime.
203///
204/// Implementations store [`ComponentMetadata`] entries keyed by URI scheme
205/// and provide lookup and capability-filtering operations.
206pub trait ComponentMetadataCatalog: Send + Sync {
207    /// Look up metadata for a component by its URI scheme.
208    fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata>;
209
210    /// Return all schemes that have registered metadata.
211    fn schemes(&self) -> Vec<String>;
212
213    /// Return metadata for all registered components.
214    fn all_metadata(&self) -> Vec<ComponentMetadata>;
215
216    /// Filter components by capability query. Returns all metadata
217    /// entries whose capabilities match every field in the query.
218    fn query_capabilities(&self, query: &CapabilityQuery) -> Vec<ComponentMetadata> {
219        self.all_metadata()
220            .into_iter()
221            .filter(|m| m.capabilities.matches_query(query))
222            .collect()
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn uri_option_builder_chain() {
236        let opt = UriOption::new("my_param", "A test parameter", OptionKind::String)
237            .required()
238            .with_default("default_val")
239            .with_alias("mp")
240            .with_alias("my-param")
241            .deprecated("Use new_param instead")
242            .secret();
243
244        assert_eq!(opt.name, "my_param");
245        assert_eq!(opt.description, "A test parameter");
246        assert_eq!(opt.kind, OptionKind::String);
247        assert!(opt.required);
248        assert_eq!(opt.default_value, Some("default_val".to_string()));
249        assert_eq!(opt.aliases, vec!["mp".to_string(), "my-param".to_string()]);
250        assert_eq!(opt.deprecated, Some("Use new_param instead".to_string()));
251        assert!(opt.secret);
252    }
253
254    #[test]
255    fn enum_kind_holds_variants() {
256        let kind = OptionKind::Enum(vec!["a".to_string(), "b".to_string()]);
257        match &kind {
258            OptionKind::Enum(variants) => {
259                assert_eq!(variants.len(), 2);
260                assert_eq!(variants[0], "a");
261                assert_eq!(variants[1], "b");
262            }
263            other => panic!("Expected Enum variant, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn component_capabilities_default_all_false() {
269        let caps = ComponentCapabilities::default();
270        assert!(!caps.supports_consumer);
271        assert!(!caps.supports_producer);
272        assert!(!caps.supports_polling_consumer);
273        assert!(!caps.supports_streaming);
274    }
275
276    #[test]
277    fn minimal_metadata_has_scheme_and_empty_fields() {
278        let meta = ComponentMetadata::minimal("timer");
279        assert_eq!(meta.scheme, "timer");
280        assert_eq!(meta.schema_version, "1");
281        assert!(meta.description.is_empty());
282        assert!(meta.uri_syntax.is_empty());
283        assert!(meta.uri_options.is_empty());
284        assert!(meta.version.is_empty());
285    }
286
287    #[test]
288    fn validate_scheme_mismatch_returns_err() {
289        let meta = ComponentMetadata::minimal("timer");
290        let result = meta.validate_scheme("log");
291        assert!(result.is_err());
292        let err_msg = result.unwrap_err();
293        assert!(err_msg.contains("timer"));
294        assert!(err_msg.contains("log"));
295    }
296
297    #[test]
298    fn capability_query_tri_state_matching() {
299        let caps = ComponentCapabilities {
300            supports_consumer: true,
301            ..Default::default()
302        };
303
304        // supports_consumer = true matches query asking for Some(true)
305        assert!(caps.matches_query(&CapabilityQuery {
306            supports_consumer: Some(true),
307            ..Default::default()
308        }));
309
310        // supports_producer = false does NOT match query asking for
311        // Some(true)
312        assert!(!caps.matches_query(&CapabilityQuery {
313            supports_producer: Some(true),
314            ..Default::default()
315        }));
316
317        // supports_producer = false DOES match query asking for
318        // Some(false)
319        assert!(caps.matches_query(&CapabilityQuery {
320            supports_producer: Some(false),
321            ..Default::default()
322        }));
323    }
324
325    // -----------------------------------------------------------------------
326    // Mock catalog for testing
327    // -----------------------------------------------------------------------
328
329    struct MockCatalog {
330        entries: std::collections::HashMap<String, ComponentMetadata>,
331    }
332
333    impl ComponentMetadataCatalog for MockCatalog {
334        fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata> {
335            self.entries.get(scheme).cloned()
336        }
337
338        fn schemes(&self) -> Vec<String> {
339            self.entries.keys().cloned().collect()
340        }
341
342        fn all_metadata(&self) -> Vec<ComponentMetadata> {
343            self.entries.values().cloned().collect()
344        }
345    }
346
347    #[test]
348    fn catalog_trait_object_safety() {
349        let mut entries = std::collections::HashMap::new();
350        entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
351        let catalog = MockCatalog { entries };
352        let dyn_catalog: &dyn ComponentMetadataCatalog = &catalog;
353        assert_eq!(dyn_catalog.schemes(), vec!["timer".to_string()]);
354    }
355
356    #[test]
357    fn catalog_query_capabilities_default_impl() {
358        let consumer = ComponentMetadata {
359            scheme: "timer".to_string(),
360            capabilities: ComponentCapabilities {
361                supports_consumer: true,
362                ..Default::default()
363            },
364            ..ComponentMetadata::minimal("timer")
365        };
366        let producer = ComponentMetadata {
367            scheme: "log".to_string(),
368            capabilities: ComponentCapabilities {
369                supports_producer: true,
370                ..Default::default()
371            },
372            ..ComponentMetadata::minimal("log")
373        };
374        let mut entries = std::collections::HashMap::new();
375        entries.insert("timer".to_string(), consumer);
376        entries.insert("log".to_string(), producer);
377        let catalog = MockCatalog { entries };
378
379        let query_consumer = CapabilityQuery {
380            supports_consumer: Some(true),
381            ..Default::default()
382        };
383        let results = catalog.query_capabilities(&query_consumer);
384        assert_eq!(results.len(), 1);
385        assert_eq!(results[0].scheme, "timer");
386
387        let query_producer = CapabilityQuery {
388            supports_producer: Some(true),
389            ..Default::default()
390        };
391        let results = catalog.query_capabilities(&query_producer);
392        assert_eq!(results.len(), 1);
393        assert_eq!(results[0].scheme, "log");
394
395        // Query with no constraints returns all
396        let query_none = CapabilityQuery::default();
397        let results = catalog.query_capabilities(&query_none);
398        assert_eq!(results.len(), 2);
399    }
400
401    #[test]
402    fn catalog_schemes_and_all_metadata() {
403        let mut entries = std::collections::HashMap::new();
404        entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
405        entries.insert("log".to_string(), ComponentMetadata::minimal("log"));
406        let catalog = MockCatalog { entries };
407
408        let mut schemes = catalog.schemes();
409        schemes.sort();
410        assert_eq!(schemes, vec!["log".to_string(), "timer".to_string()]);
411
412        let all = catalog.all_metadata();
413        assert_eq!(all.len(), 2);
414        let schemes_from_meta: std::collections::BTreeSet<&str> =
415            all.iter().map(|m| m.scheme.as_str()).collect();
416        assert!(schemes_from_meta.contains("timer"));
417        assert!(schemes_from_meta.contains("log"));
418    }
419}