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}