Skip to main content

gts/
schema.rs

1//! Runtime schema generation traits for GTS types.
2//!
3//! This module provides the `GtsSchema` trait which enables runtime schema
4//! composition for nested generic types like `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`.
5
6use serde_json::Value;
7
8use crate::GTS_ID_URI_PREFIX;
9
10/// The JSON Schema **draft-07** dialect URI that GTS Type Schemas declare via
11/// `$schema`. Single source of truth for the value emitted by the schema
12/// generators (the `struct_to_gts_schema` macro, the CLI generator).
13pub const JSON_SCHEMA_DRAFT_07: &str = "http://json-schema.org/draft-07/schema#";
14
15/// Chain-aggregated state of a type's `x-gts-traits-schema`, under JSON Schema
16/// `allOf` composition over the `$id` chain. Drives the macro's compile-time
17/// "traits values need a usable schema" guard.
18#[derive(Clone, Copy, PartialEq, Eq, Debug)]
19pub enum TraitSchemaState {
20    /// No `x-gts-traits-schema` declared anywhere in the chain.
21    Absent,
22    /// A satisfiable trait shape exists (`true`, an object subschema, or a
23    /// `$ref`); trait values are permitted and their *contents* are left to
24    /// runtime validation (OP#13), not checked here.
25    Open,
26    /// Some layer declares `false`, so the composed `allOf` is unsatisfiable —
27    /// any trait values are prohibited.
28    Prohibited,
29}
30
31impl TraitSchemaState {
32    /// Compose this (ancestor-side) state with a descendant layer's own state
33    /// under `allOf` semantics: `false` anywhere wins (`Prohibited`); otherwise
34    /// any satisfiable schema makes the chain `Open`; otherwise `Absent`.
35    #[must_use]
36    pub const fn join(self, own: TraitSchemaState) -> TraitSchemaState {
37        match (self, own) {
38            (TraitSchemaState::Prohibited, _) | (_, TraitSchemaState::Prohibited) => {
39                TraitSchemaState::Prohibited
40            }
41            (TraitSchemaState::Open, _) | (_, TraitSchemaState::Open) => TraitSchemaState::Open,
42            _ => TraitSchemaState::Absent,
43        }
44    }
45}
46
47/// Trait for types that have a GTS schema.
48///
49/// This trait enables runtime schema composition for nested generic types.
50/// When you have `BaseEventV1<P>` where `P: GtsSchema`, the composed schema
51/// can be generated at runtime with proper nesting.
52///
53/// # Example
54///
55/// ```ignore
56/// use gts::GtsSchema;
57///
58/// // Get the composed schema for a nested type
59/// let schema = BaseEventV1::<AuditPayloadV1<PlaceOrderDataV1>>::gts_schema();
60/// // The schema will have payload field containing AuditPayloadV1's schema,
61/// // which in turn has data field containing PlaceOrderDataV1's schema
62/// ```
63pub trait GtsSchema {
64    /// The GTS type ID for this type (formerly `SCHEMA_ID`).
65    const TYPE_ID: &'static str;
66
67    /// Deprecated alias for [`Self::TYPE_ID`].
68    ///
69    /// Defaults to `Self::TYPE_ID` so existing implementations that only set
70    /// `TYPE_ID` keep working. Reading `T::SCHEMA_ID` from downstream code
71    /// raises a deprecation warning pointing at the new name.
72    #[deprecated(since = "0.10.0", note = "use `TYPE_ID` instead")]
73    const SCHEMA_ID: &'static str = Self::TYPE_ID;
74
75    /// The name of the field that contains the generic type parameter, if any.
76    /// For example, `BaseEventV1<P>` has `payload` as the generic field.
77    const GENERIC_FIELD: Option<&'static str> = None;
78
79    /// `true` if this type declares `x-gts-final` (not inheritable). Set by
80    /// `#[struct_to_gts_schema]` from `gts_final = true`; read by the
81    /// derive-from-final compile-time guard.
82    const GTS_FINAL: bool = false;
83
84    /// `true` if this type declares `x-gts-abstract` (not directly
85    /// instantiable). Set by `#[struct_to_gts_schema]` from `gts_abstract =
86    /// true`; read by the `gts_instance!` compile-time guard.
87    const GTS_ABSTRACT: bool = false;
88
89    /// Chain-aggregated `x-gts-traits-schema` state (this type's own layer
90    /// `allOf`-composed with its ancestors'). Set by `#[struct_to_gts_schema]`;
91    /// read by the compile-time guard that rejects `traits` values when the
92    /// chain has no usable trait shape ([`TraitSchemaState::Absent`]) or
93    /// prohibits traits ([`TraitSchemaState::Prohibited`]).
94    const TRAIT_SCHEMA: TraitSchemaState = TraitSchemaState::Absent;
95
96    /// Returns the JSON schema for this type with $ref references intact.
97    fn gts_schema_with_refs() -> Value;
98
99    /// Returns the composed JSON schema for this type.
100    /// For types with generic parameters that implement `GtsSchema`,
101    /// this returns the schema with the generic field's type replaced
102    /// by the nested type's schema.
103    #[must_use]
104    fn gts_schema() -> Value {
105        Self::gts_schema_with_refs()
106    }
107
108    /// Generate a GTS-style schema with allOf and $ref to base type.
109    ///
110    /// This produces a schema like:
111    /// ```json
112    /// {
113    ///   "$id": "gts://innermost_type_id",
114    ///   "allOf": [
115    ///     { "$ref": "gts://base_type_id" },
116    ///     { "properties": { "payload": { nested_schema } } }
117    ///   ]
118    /// }
119    /// ```
120    #[must_use]
121    fn gts_schema_with_refs_allof() -> Value {
122        Self::gts_schema_with_refs()
123    }
124
125    /// Get the innermost type ID in a nested generic chain.
126    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `PlaceOrderDataV1`'s ID.
127    #[must_use]
128    fn innermost_type_id() -> &'static str {
129        Self::TYPE_ID
130    }
131
132    /// Deprecated alias for [`Self::innermost_type_id`].
133    #[deprecated(since = "0.10.0", note = "renamed to `innermost_type_id`")]
134    #[must_use]
135    fn innermost_schema_id() -> &'static str {
136        Self::innermost_type_id()
137    }
138
139    /// Get the innermost (leaf) type's raw schema.
140    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `PlaceOrderDataV1`'s schema.
141    #[must_use]
142    fn innermost_schema() -> Value {
143        Self::gts_schema_with_refs()
144    }
145
146    /// This type's *own* declared `x-gts-traits-schema`, or `None` if it
147    /// declares none. The single layer this type contributes — not the
148    /// chain-aggregated effective trait-schema (the registry composes those
149    /// along the `$id` chain via `allOf`). Overridden by
150    /// `#[struct_to_gts_schema]` when `traits_schema = …` is set.
151    #[must_use]
152    fn gts_traits_schema() -> Option<Value> {
153        None
154    }
155
156    /// This type's *own* declared `x-gts-traits` values, or `None` if it
157    /// resolves none. The single layer this type contributes — not the
158    /// chain-merged effective traits object. Overridden by
159    /// `#[struct_to_gts_schema]` when `traits = …` is set.
160    #[must_use]
161    fn gts_traits() -> Option<Value> {
162        None
163    }
164
165    /// Collect the nesting path (generic field names) from outer to inner types.
166    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `["payload", "data"]`.
167    #[must_use]
168    fn collect_nesting_path() -> Vec<&'static str> {
169        Vec::new()
170    }
171
172    /// Path of generic-slot field names from the document root (the
173    /// outermost base type in the chain) down to where this type's own
174    /// properties live in a composed instance.
175    ///
176    /// For a base type (no parent): `[]`.
177    /// For each derived level: parent's path plus the parent's
178    /// `GENERIC_FIELD`. So for the chain
179    /// `BaseEventV1<P> -> AuditPayloadV1<D> -> PlaceOrderDataV1<E> -> PlaceOrderDataPayloadV1`:
180    ///
181    /// | Type | `outer_generic_path()` |
182    /// |---|---|
183    /// | `BaseEventV1` | `[]` |
184    /// | `AuditPayloadV1` | `["payload"]` |
185    /// | `PlaceOrderDataV1` | `["payload", "data"]` |
186    /// | `PlaceOrderDataPayloadV1` | `["payload", "data", "last"]` |
187    ///
188    /// Used by derived emitters to wrap their overlay properties at the
189    /// correct depth, so a derived schema's `allOf` overlay declares its
190    /// fields nested under the parent chain's generic slots rather than
191    /// at the top level (which would violate the base's
192    /// `additionalProperties: false` per gts-spec sec 3.1).
193    #[must_use]
194    fn outer_generic_path() -> Vec<&'static str> {
195        Vec::new()
196    }
197
198    /// Wrap properties in a nested structure following the nesting path.
199    /// For path `["payload", "data"]` and properties `{order_id, product_id, last}`,
200    /// returns `{ "payload": { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": false, "properties": {...}, "required": [...] } } } }`
201    ///
202    /// The `additionalProperties: false` is placed on the object that contains the current type's
203    /// own properties. Generic fields that will be extended by children are just `{"type": "object"}`.
204    ///
205    /// # Arguments
206    /// * `path` - The nesting path from outer to inner (e.g., `["payload", "data"]`)
207    /// * `properties` - The properties of the current type
208    /// * `required` - The required fields of the current type
209    /// * `generic_field` - The name of the generic field in the current type (if any), which should NOT have additionalProperties: false
210    #[must_use]
211    fn wrap_in_nesting_path(
212        path: &[&str],
213        properties: Value,
214        required: Value,
215        generic_field: Option<&str>,
216    ) -> Value {
217        if path.is_empty() {
218            return properties;
219        }
220
221        // Build the innermost schema - this contains the current type's own properties
222        // Set additionalProperties: false on this level (the object containing our properties)
223        let mut current = serde_json::json!({
224            "type": "object",
225            "additionalProperties": false,
226            "properties": properties,
227            "required": required
228        });
229
230        // If we have a generic field, ensure it's just {"type": "object"} without additionalProperties
231        // This field will be extended by child schemas
232        if let Some(gf) = generic_field
233            && let Some(props) = current
234                .get_mut("properties")
235                .and_then(|v| v.as_object_mut())
236            && props.contains_key(gf)
237        {
238            props.insert(gf.to_owned(), serde_json::json!({"type": "object"}));
239        }
240
241        // Wrap from inner to outer - parent levels don't need additionalProperties: false
242        for field in path.iter().rev() {
243            current = serde_json::json!({
244                "type": "object",
245                "properties": {
246                    *field: current
247                }
248            });
249        }
250
251        // Extract just the properties object from the outermost wrapper
252        // since the caller will put this in a "properties" field
253        if let Some(props) = current.get("properties") {
254            return props.clone();
255        }
256
257        current
258    }
259}
260
261/// Marker implementation for () to allow `BaseEventV1<()>` etc.
262impl GtsSchema for () {
263    const TYPE_ID: &'static str = "";
264
265    fn gts_schema_with_refs() -> Value {
266        serde_json::json!({
267            "type": "object"
268        })
269    }
270}
271
272/// Marker implementation for [`serde_json::Value`] — the same "I am a
273/// placeholder" protocol as `impl GtsSchema for ()`, except the carrier
274/// holds actual JSON data rather than being empty.
275///
276/// Use as the default generic parameter in `Base<P>` types whose payload
277/// is heterogeneous at runtime — e.g. a multi-provider catalog where the
278/// concrete leaf shape is selected by an `info.gts_type` field on the
279/// data, not by the Rust type parameter. Consumers narrow to a typed
280/// view (`Base<ConcreteLeaf>` or `Base<Intermediate<ConcreteLeaf>>`)
281/// by matching on the runtime `gts_type` and deserialising the JSON
282/// payload into the chosen target.
283///
284/// Like `impl GtsSchema for ()`, `TYPE_ID` is the empty sentinel that
285/// signals "no own identity — read the real id from data".
286impl GtsSchema for Value {
287    const TYPE_ID: &'static str = "";
288
289    fn gts_schema_with_refs() -> Value {
290        serde_json::json!({
291            "type": "object",
292            "description": "Opaque JSON payload; the concrete schema is \
293                            identified by the carrying type's runtime \
294                            discriminator field (typically `gts_type`)."
295        })
296    }
297}
298
299/// Private trait for nested GTS struct serialization.
300///
301/// Nested structs implement this instead of `serde::Serialize` to prevent
302/// direct serialization (which would produce incomplete JSON without base struct fields).
303/// Base structs use `#[serde(serialize_with)]` to call this trait internally.
304pub trait GtsSerialize {
305    /// Serialize this value using the GTS serialization protocol.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if serialization fails.
310    fn gts_serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
311    where
312        S: serde::Serializer;
313}
314
315/// Private trait for nested GTS struct deserialization.
316///
317/// Nested structs implement this instead of `serde::Deserialize` to prevent
318/// direct deserialization.
319pub trait GtsDeserialize<'de>: Sized {
320    /// Deserialize this value using the GTS deserialization protocol.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if deserialization fails.
325    fn gts_deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
326    where
327        __D: serde::Deserializer<'de>;
328}
329
330/// Internal marker trait to block direct serde serialization on nested GTS structs.
331///
332/// The macro implements this for nested structs; any direct `Serialize` impl then
333/// conflicts with the blanket impl below, producing a compile-time error.
334#[doc(hidden)]
335pub trait GtsNoDirectSerialize {}
336
337/// Internal marker trait to block direct serde deserialization on nested GTS structs.
338#[doc(hidden)]
339pub trait GtsNoDirectDeserialize {}
340
341impl<T: serde::Serialize> GtsNoDirectSerialize for T {}
342
343impl<T> GtsNoDirectDeserialize for T where for<'de> T: serde::Deserialize<'de> {}
344
345/// Blanket impl: anything with Serialize also has `GtsSerialize`.
346/// This allows standard serde types (String, i32, etc.) to be used in GTS structs.
347impl<T: serde::Serialize> GtsSerialize for T {
348    fn gts_serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
349    where
350        S: serde::Serializer,
351    {
352        serde::Serialize::serialize(self, serializer)
353    }
354}
355
356/// Blanket impl: anything with Deserialize also has `GtsDeserialize`.
357impl<'de, T: serde::Deserialize<'de>> GtsDeserialize<'de> for T {
358    fn gts_deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
359    where
360        __D: serde::Deserializer<'de>,
361    {
362        <T as serde::Deserialize<'de>>::deserialize(deserializer)
363    }
364}
365
366/// Serialize a value via `GtsSerialize` trait.
367///
368/// Used with `#[serde(serialize_with = "gts::serialize_gts")]` on generic fields in base structs.
369///
370/// # Errors
371///
372/// Returns an error if serialization fails.
373pub fn serialize_gts<T: GtsSerialize, S: serde::Serializer>(
374    value: &T,
375    serializer: S,
376) -> Result<S::Ok, S::Error> {
377    value.gts_serialize(serializer)
378}
379
380/// Deserialize a value via `GtsDeserialize` trait.
381///
382/// Used with `#[serde(deserialize_with = "gts::deserialize_gts")]` on generic fields in base structs.
383///
384/// # Errors
385///
386/// Returns an error if deserialization fails.
387pub fn deserialize_gts<'de, T: GtsDeserialize<'de>, D: serde::Deserializer<'de>>(
388    deserializer: D,
389) -> Result<T, D::Error> {
390    T::gts_deserialize(deserializer)
391}
392
393/// Wrapper to serialize a GtsSerialize type using serde's Serialize trait.
394///
395/// This is used internally by the macro to serialize generic fields in nested structs.
396/// Generic fields may not implement Serialize directly (only GtsSerialize), so this
397/// wrapper bridges the gap.
398#[doc(hidden)]
399pub struct GtsSerializeWrapper<'a, T: GtsSerialize + ?Sized>(pub &'a T);
400
401impl<T: GtsSerialize + ?Sized> serde::Serialize for GtsSerializeWrapper<'_, T> {
402    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
403    where
404        S: serde::Serializer,
405    {
406        self.0.gts_serialize(serializer)
407    }
408}
409
410/// Wrapper for deserializing into a GtsDeserialize type.
411///
412/// Used internally by the macro for generic field deserialization in nested structs.
413#[doc(hidden)]
414pub struct GtsDeserializeWrapper<T>(pub T);
415
416impl<'de, T: GtsDeserialize<'de>> serde::Deserialize<'de> for GtsDeserializeWrapper<T> {
417    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
418    where
419        D: serde::Deserializer<'de>,
420    {
421        T::gts_deserialize(deserializer).map(GtsDeserializeWrapper)
422    }
423}
424
425/// Generate a GTS-style schema for a nested type with allOf and $ref to base.
426///
427/// This macro generates a schema where:
428/// - `$id` is the innermost type's schema ID
429/// - `allOf` contains a `$ref` to the base (outermost) type's schema ID
430/// - The nested types' properties are placed in the payload fields
431///
432/// # Example
433///
434/// ```ignore
435/// use gts::gts_schema_for;
436///
437/// let schema = gts_schema_for!(BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>);
438/// // Produces:
439/// // {
440/// //   "$id": "gts://...PlaceOrderDataV1...",
441/// //   "allOf": [
442/// //     { "$ref": "gts://BaseEventV1..." },
443/// //     { "properties": { "payload": { ... } } }
444/// //   ]
445/// // }
446/// ```
447#[macro_export]
448macro_rules! gts_schema_for {
449    ($base:ty) => {{
450        use $crate::GtsSchema;
451        <$base as GtsSchema>::gts_schema_with_refs_allof()
452    }};
453}
454
455/// Strip schema metadata fields ($id, $schema, title, description) for cleaner nested schemas.
456#[must_use]
457pub fn strip_schema_metadata(schema: &Value) -> Value {
458    let mut result = schema.clone();
459    if let Some(obj) = result.as_object_mut() {
460        obj.remove("$id");
461        obj.remove("$schema");
462        obj.remove("title");
463        obj.remove("description");
464
465        // Recursively strip from nested properties
466        if let Some(props) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
467            let keys: Vec<String> = props.keys().cloned().collect();
468            for key in keys {
469                if let Some(prop_value) = props.get(&key) {
470                    let cleaned = strip_schema_metadata(prop_value);
471                    props.insert(key, cleaned);
472                }
473            }
474        }
475    }
476    result
477}
478
479/// Build a GTS schema with allOf structure referencing base type.
480///
481/// # Arguments
482/// * `innermost_type_id` - The $id for the generated schema (innermost type)
483/// * `base_type_id` - The $ref target (base/outermost type)
484/// * `title` - Schema title
485/// * `own_properties` - Properties specific to this composed type
486/// * `required` - Required fields
487#[must_use]
488pub fn build_gts_allof_schema(
489    innermost_type_id: &str,
490    base_type_id: &str,
491    title: &str,
492    own_properties: &Value,
493    required: &[&str],
494) -> Value {
495    serde_json::json!({
496        "$id": format!("{GTS_ID_URI_PREFIX}{}", innermost_type_id),
497        "$schema": "http://json-schema.org/draft-07/schema#",
498        "title": title,
499        "type": "object",
500        "allOf": [
501            { "$ref": format!("{GTS_ID_URI_PREFIX}{}", base_type_id) },
502            {
503                "type": "object",
504                "properties": own_properties,
505                "required": required
506            }
507        ]
508    })
509}
510
511#[cfg(test)]
512#[allow(clippy::unwrap_used, clippy::expect_used)]
513mod tests {
514    use super::*;
515    use serde_json::json;
516
517    #[test]
518    fn trait_schema_state_join_truth_table() {
519        use TraitSchemaState::{Absent, Open, Prohibited};
520
521        // Full 3×3 lattice under `allOf` composition:
522        // - `Prohibited` (a `false` subschema) annihilates anything → Prohibited.
523        // - otherwise any `Open` (satisfiable schema) makes the chain Open.
524        // - otherwise (both sides Absent) the chain stays Absent.
525        let cases = [
526            (Absent, Absent, Absent),
527            (Absent, Open, Open),
528            (Absent, Prohibited, Prohibited),
529            (Open, Absent, Open),
530            (Open, Open, Open),
531            (Open, Prohibited, Prohibited),
532            (Prohibited, Absent, Prohibited),
533            (Prohibited, Open, Prohibited),
534            (Prohibited, Prohibited, Prohibited),
535        ];
536
537        for (ancestor, own, expected) in cases {
538            assert_eq!(
539                ancestor.join(own),
540                expected,
541                "join({ancestor:?}, {own:?}) should be {expected:?}"
542            );
543        }
544    }
545
546    #[test]
547    fn test_unit_type_properties() {
548        // Test all unit type properties in one test
549        let schema = <()>::gts_schema();
550        assert_eq!(schema, json!({"type": "object"}));
551        assert_eq!(<()>::TYPE_ID, "");
552        assert_eq!(<()>::GENERIC_FIELD, None);
553    }
554
555    #[test]
556    fn test_wrap_in_nesting_path_empty_path() {
557        let properties = json!({"field1": {"type": "string"}});
558        let required = json!(["field1"]);
559
560        let result = <()>::wrap_in_nesting_path(&[], properties.clone(), required, None);
561
562        assert_eq!(result, properties);
563    }
564
565    #[test]
566    fn test_wrap_in_nesting_path_single_level() {
567        let properties = json!({"field1": {"type": "string"}});
568        let required = json!(["field1"]);
569
570        let result = <()>::wrap_in_nesting_path(&["payload"], properties, required.clone(), None);
571
572        assert_eq!(
573            result,
574            json!({
575                "payload": {
576                    "type": "object",
577                    "additionalProperties": false,
578                    "properties": {"field1": {"type": "string"}},
579                    "required": required
580                }
581            })
582        );
583    }
584
585    #[test]
586    fn test_wrap_in_nesting_path_multi_level() {
587        let properties = json!({"field1": {"type": "string"}});
588        let required = json!(["field1"]);
589
590        let result =
591            <()>::wrap_in_nesting_path(&["payload", "data"], properties, required.clone(), None);
592
593        assert_eq!(
594            result,
595            json!({
596                "payload": {
597                    "type": "object",
598                    "properties": {
599                        "data": {
600                            "type": "object",
601                            "additionalProperties": false,
602                            "properties": {"field1": {"type": "string"}},
603                            "required": required
604                        }
605                    }
606                }
607            })
608        );
609    }
610
611    #[test]
612    fn test_wrap_in_nesting_path_with_generic_field() {
613        let properties = json!({
614            "field1": {"type": "string"},
615            "generic_field": {"type": "number"}
616        });
617        let required = json!(["field1"]);
618
619        let result =
620            <()>::wrap_in_nesting_path(&["payload"], properties, required, Some("generic_field"));
621
622        let result_obj = result.as_object().unwrap();
623        let payload = result_obj.get("payload").unwrap();
624        let props = payload.get("properties").unwrap();
625
626        // Generic field should be just {"type": "object"}
627        assert_eq!(
628            props.get("generic_field").unwrap(),
629            &json!({"type": "object"})
630        );
631        // Other fields should be preserved
632        assert_eq!(props.get("field1").unwrap(), &json!({"type": "string"}));
633    }
634
635    #[test]
636    fn test_strip_schema_metadata_removes_all_metadata() {
637        // Test removal of all metadata fields including $id, $schema, title, description
638        let schema = json!({
639            "$id": "gts://test",
640            "$schema": "http://json-schema.org/draft-07/schema#",
641            "title": "Test Schema",
642            "description": "A test",
643            "type": "object",
644            "properties": {"field": {"type": "string"}}
645        });
646
647        let result = strip_schema_metadata(&schema);
648
649        // All metadata should be removed
650        assert!(result.get("$id").is_none());
651        assert!(result.get("$schema").is_none());
652        assert!(result.get("title").is_none());
653        assert!(result.get("description").is_none());
654        // Non-metadata should be preserved
655        assert_eq!(result.get("type").unwrap(), "object");
656        assert!(result.get("properties").is_some());
657    }
658
659    #[test]
660    fn test_strip_schema_metadata_recursive() {
661        let schema = json!({
662            "$id": "gts://test",
663            "properties": {
664                "nested": {
665                    "$id": "gts://nested",
666                    "type": "string",
667                    "description": "Nested field"
668                }
669            }
670        });
671
672        let result = strip_schema_metadata(&schema);
673
674        assert!(result.get("$id").is_none());
675        let props = result.get("properties").unwrap();
676        let nested = props.get("nested").unwrap();
677        assert!(nested.get("$id").is_none());
678        assert!(nested.get("description").is_none());
679        assert_eq!(nested.get("type").unwrap(), "string");
680    }
681
682    #[test]
683    fn test_strip_schema_metadata_preserves_non_metadata() {
684        let schema = json!({
685            "$id": "gts://test",
686            "type": "object",
687            "properties": {"field": {"type": "string"}},
688            "required": ["field"],
689            "additionalProperties": false
690        });
691
692        let result = strip_schema_metadata(&schema);
693
694        assert_eq!(result.get("type").unwrap(), "object");
695        assert!(result.get("properties").is_some());
696        assert!(result.get("required").is_some());
697        assert_eq!(result.get("additionalProperties").unwrap(), &json!(false));
698    }
699
700    #[test]
701    fn test_build_gts_allof_schema_structure() {
702        let properties = json!({"field1": {"type": "string"}});
703        let required = vec!["field1"];
704
705        let result = build_gts_allof_schema(
706            "vendor.package.namespace.child.1",
707            "vendor.package.namespace.base.1",
708            "Child Schema",
709            &properties,
710            &required,
711        );
712
713        assert_eq!(
714            result.get("$id").unwrap(),
715            "gts://vendor.package.namespace.child.1"
716        );
717        assert_eq!(
718            result.get("$schema").unwrap(),
719            "http://json-schema.org/draft-07/schema#"
720        );
721        assert_eq!(result.get("title").unwrap(), "Child Schema");
722        assert_eq!(result.get("type").unwrap(), "object");
723
724        let allof = result.get("allOf").unwrap().as_array().unwrap();
725        assert_eq!(allof.len(), 2);
726    }
727
728    #[test]
729    fn test_build_gts_allof_schema_ref_format() {
730        let properties = json!({"field1": {"type": "string"}});
731        let required = vec!["field1"];
732
733        let result = build_gts_allof_schema(
734            "vendor.package.namespace.child.1",
735            "vendor.package.namespace.base.1",
736            "Child Schema",
737            &properties,
738            &required,
739        );
740
741        let allof = result.get("allOf").unwrap().as_array().unwrap();
742        let ref_obj = &allof[0];
743
744        assert_eq!(
745            ref_obj.get("$ref").unwrap(),
746            "gts://vendor.package.namespace.base.1"
747        );
748    }
749
750    #[test]
751    fn test_build_gts_allof_schema_properties_in_allof() {
752        let properties = json!({"field1": {"type": "string"}, "field2": {"type": "number"}});
753        let required = vec!["field1", "field2"];
754
755        let result = build_gts_allof_schema(
756            "vendor.package.namespace.child.1",
757            "vendor.package.namespace.base.1",
758            "Child Schema",
759            &properties,
760            &required,
761        );
762
763        let allof = result.get("allOf").unwrap().as_array().unwrap();
764        let props_obj = &allof[1];
765
766        assert_eq!(props_obj.get("type").unwrap(), "object");
767        assert_eq!(props_obj.get("properties").unwrap(), &properties);
768        assert_eq!(props_obj.get("required").unwrap(), &json!(required));
769    }
770}