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}