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