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