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