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/// Trait for types that have a GTS schema.
9///
10/// This trait enables runtime schema composition for nested generic types.
11/// When you have `BaseEventV1<P>` where `P: GtsSchema`, the composed schema
12/// can be generated at runtime with proper nesting.
13///
14/// # Example
15///
16/// ```ignore
17/// use gts::GtsSchema;
18///
19/// // Get the composed schema for a nested type
20/// let schema = BaseEventV1::<AuditPayloadV1<PlaceOrderDataV1>>::gts_schema();
21/// // The schema will have payload field containing AuditPayloadV1's schema,
22/// // which in turn has data field containing PlaceOrderDataV1's schema
23/// ```
24pub trait GtsSchema {
25    /// The GTS schema ID for this type.
26    const SCHEMA_ID: &'static str;
27
28    /// The name of the field that contains the generic type parameter, if any.
29    /// For example, `BaseEventV1<P>` has `payload` as the generic field.
30    const GENERIC_FIELD: Option<&'static str> = None;
31
32    /// Returns the JSON schema for this type with $ref references intact.
33    fn gts_schema_with_refs() -> Value;
34
35    /// Returns the composed JSON schema for this type.
36    /// For types with generic parameters that implement `GtsSchema`,
37    /// this returns the schema with the generic field's type replaced
38    /// by the nested type's schema.
39    #[must_use]
40    fn gts_schema() -> Value {
41        Self::gts_schema_with_refs()
42    }
43
44    /// Generate a GTS-style schema with allOf and $ref to base type.
45    ///
46    /// This produces a schema like:
47    /// ```json
48    /// {
49    ///   "$id": "gts://innermost_type_id",
50    ///   "allOf": [
51    ///     { "$ref": "gts://base_type_id" },
52    ///     { "properties": { "payload": { nested_schema } } }
53    ///   ]
54    /// }
55    /// ```
56    #[must_use]
57    fn gts_schema_with_refs_allof() -> Value {
58        Self::gts_schema_with_refs()
59    }
60
61    /// Get the innermost schema ID in a nested generic chain.
62    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `PlaceOrderDataV1`'s ID.
63    #[must_use]
64    fn innermost_schema_id() -> &'static str {
65        Self::SCHEMA_ID
66    }
67
68    /// Get the innermost (leaf) type's raw schema.
69    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `PlaceOrderDataV1`'s schema.
70    #[must_use]
71    fn innermost_schema() -> Value {
72        Self::gts_schema_with_refs()
73    }
74
75    /// Collect the nesting path (generic field names) from outer to inner types.
76    /// For `BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>`, returns `["payload", "data"]`.
77    #[must_use]
78    fn collect_nesting_path() -> Vec<&'static str> {
79        Vec::new()
80    }
81
82    /// Wrap properties in a nested structure following the nesting path.
83    /// For path `["payload", "data"]` and properties `{order_id, product_id, last}`,
84    /// returns `{ "payload": { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": false, "properties": {...}, "required": [...] } } } }`
85    ///
86    /// The `additionalProperties: false` is placed on the object that contains the current type's
87    /// own properties. Generic fields that will be extended by children are just `{"type": "object"}`.
88    ///
89    /// # Arguments
90    /// * `path` - The nesting path from outer to inner (e.g., `["payload", "data"]`)
91    /// * `properties` - The properties of the current type
92    /// * `required` - The required fields of the current type
93    /// * `generic_field` - The name of the generic field in the current type (if any), which should NOT have additionalProperties: false
94    #[must_use]
95    fn wrap_in_nesting_path(
96        path: &[&str],
97        properties: Value,
98        required: Value,
99        generic_field: Option<&str>,
100    ) -> Value {
101        if path.is_empty() {
102            return properties;
103        }
104
105        // Build the innermost schema - this contains the current type's own properties
106        // Set additionalProperties: false on this level (the object containing our properties)
107        let mut current = serde_json::json!({
108            "type": "object",
109            "additionalProperties": false,
110            "properties": properties,
111            "required": required
112        });
113
114        // If we have a generic field, ensure it's just {"type": "object"} without additionalProperties
115        // This field will be extended by child schemas
116        if let Some(gf) = generic_field
117            && let Some(props) = current
118                .get_mut("properties")
119                .and_then(|v| v.as_object_mut())
120            && props.contains_key(gf)
121        {
122            props.insert(gf.to_owned(), serde_json::json!({"type": "object"}));
123        }
124
125        // Wrap from inner to outer - parent levels don't need additionalProperties: false
126        for field in path.iter().rev() {
127            current = serde_json::json!({
128                "type": "object",
129                "properties": {
130                    *field: current
131                }
132            });
133        }
134
135        // Extract just the properties object from the outermost wrapper
136        // since the caller will put this in a "properties" field
137        if let Some(props) = current.get("properties") {
138            return props.clone();
139        }
140
141        current
142    }
143}
144
145/// Marker implementation for () to allow `BaseEventV1<()>` etc.
146impl GtsSchema for () {
147    const SCHEMA_ID: &'static str = "";
148
149    fn gts_schema_with_refs() -> Value {
150        serde_json::json!({
151            "type": "object"
152        })
153    }
154
155    fn gts_schema() -> Value {
156        Self::gts_schema_with_refs()
157    }
158}
159
160/// Private trait for nested GTS struct serialization.
161///
162/// Nested structs implement this instead of `serde::Serialize` to prevent
163/// direct serialization (which would produce incomplete JSON without base struct fields).
164/// Base structs use `#[serde(serialize_with)]` to call this trait internally.
165pub trait GtsSerialize {
166    /// Serialize this value using the GTS serialization protocol.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if serialization fails.
171    fn gts_serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
172    where
173        S: serde::Serializer;
174}
175
176/// Private trait for nested GTS struct deserialization.
177///
178/// Nested structs implement this instead of `serde::Deserialize` to prevent
179/// direct deserialization.
180pub trait GtsDeserialize<'de>: Sized {
181    /// Deserialize this value using the GTS deserialization protocol.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if deserialization fails.
186    fn gts_deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
187    where
188        __D: serde::Deserializer<'de>;
189}
190
191/// Internal marker trait to block direct serde serialization on nested GTS structs.
192///
193/// The macro implements this for nested structs; any direct `Serialize` impl then
194/// conflicts with the blanket impl below, producing a compile-time error.
195#[doc(hidden)]
196pub trait GtsNoDirectSerialize {}
197
198/// Internal marker trait to block direct serde deserialization on nested GTS structs.
199#[doc(hidden)]
200pub trait GtsNoDirectDeserialize {}
201
202impl<T: serde::Serialize> GtsNoDirectSerialize for T {}
203
204impl<T> GtsNoDirectDeserialize for T where for<'de> T: serde::Deserialize<'de> {}
205
206/// Blanket impl: anything with Serialize also has `GtsSerialize`.
207/// This allows standard serde types (String, i32, etc.) to be used in GTS structs.
208impl<T: serde::Serialize> GtsSerialize for T {
209    fn gts_serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
210    where
211        S: serde::Serializer,
212    {
213        serde::Serialize::serialize(self, serializer)
214    }
215}
216
217/// Blanket impl: anything with Deserialize also has `GtsDeserialize`.
218impl<'de, T: serde::Deserialize<'de>> GtsDeserialize<'de> for T {
219    fn gts_deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
220    where
221        __D: serde::Deserializer<'de>,
222    {
223        <T as serde::Deserialize<'de>>::deserialize(deserializer)
224    }
225}
226
227/// Serialize a value via `GtsSerialize` trait.
228///
229/// Used with `#[serde(serialize_with = "gts::serialize_gts")]` on generic fields in base structs.
230///
231/// # Errors
232///
233/// Returns an error if serialization fails.
234pub fn serialize_gts<T: GtsSerialize, S: serde::Serializer>(
235    value: &T,
236    serializer: S,
237) -> Result<S::Ok, S::Error> {
238    value.gts_serialize(serializer)
239}
240
241/// Deserialize a value via `GtsDeserialize` trait.
242///
243/// Used with `#[serde(deserialize_with = "gts::deserialize_gts")]` on generic fields in base structs.
244///
245/// # Errors
246///
247/// Returns an error if deserialization fails.
248pub fn deserialize_gts<'de, T: GtsDeserialize<'de>, D: serde::Deserializer<'de>>(
249    deserializer: D,
250) -> Result<T, D::Error> {
251    T::gts_deserialize(deserializer)
252}
253
254/// Wrapper to serialize a GtsSerialize type using serde's Serialize trait.
255///
256/// This is used internally by the macro to serialize generic fields in nested structs.
257/// Generic fields may not implement Serialize directly (only GtsSerialize), so this
258/// wrapper bridges the gap.
259#[doc(hidden)]
260pub struct GtsSerializeWrapper<'a, T: GtsSerialize + ?Sized>(pub &'a T);
261
262impl<T: GtsSerialize + ?Sized> serde::Serialize for GtsSerializeWrapper<'_, T> {
263    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
264    where
265        S: serde::Serializer,
266    {
267        self.0.gts_serialize(serializer)
268    }
269}
270
271/// Wrapper for deserializing into a GtsDeserialize type.
272///
273/// Used internally by the macro for generic field deserialization in nested structs.
274#[doc(hidden)]
275pub struct GtsDeserializeWrapper<T>(pub T);
276
277impl<'de, T: GtsDeserialize<'de>> serde::Deserialize<'de> for GtsDeserializeWrapper<T> {
278    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279    where
280        D: serde::Deserializer<'de>,
281    {
282        T::gts_deserialize(deserializer).map(GtsDeserializeWrapper)
283    }
284}
285
286/// Generate a GTS-style schema for a nested type with allOf and $ref to base.
287///
288/// This macro generates a schema where:
289/// - `$id` is the innermost type's schema ID
290/// - `allOf` contains a `$ref` to the base (outermost) type's schema ID
291/// - The nested types' properties are placed in the payload fields
292///
293/// # Example
294///
295/// ```ignore
296/// use gts::gts_schema_for;
297///
298/// let schema = gts_schema_for!(BaseEventV1<AuditPayloadV1<PlaceOrderDataV1>>);
299/// // Produces:
300/// // {
301/// //   "$id": "gts://...PlaceOrderDataV1...",
302/// //   "allOf": [
303/// //     { "$ref": "gts://BaseEventV1..." },
304/// //     { "properties": { "payload": { ... } } }
305/// //   ]
306/// // }
307/// ```
308#[macro_export]
309macro_rules! gts_schema_for {
310    ($base:ty) => {{
311        use $crate::GtsSchema;
312        <$base as GtsSchema>::gts_schema_with_refs_allof()
313    }};
314}
315
316/// Strip schema metadata fields ($id, $schema, title, description) for cleaner nested schemas.
317#[must_use]
318pub fn strip_schema_metadata(schema: &Value) -> Value {
319    let mut result = schema.clone();
320    if let Some(obj) = result.as_object_mut() {
321        obj.remove("$id");
322        obj.remove("$schema");
323        obj.remove("title");
324        obj.remove("description");
325
326        // Recursively strip from nested properties
327        if let Some(props) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
328            let keys: Vec<String> = props.keys().cloned().collect();
329            for key in keys {
330                if let Some(prop_value) = props.get(&key) {
331                    let cleaned = strip_schema_metadata(prop_value);
332                    props.insert(key, cleaned);
333                }
334            }
335        }
336    }
337    result
338}
339
340/// Build a GTS schema with allOf structure referencing base type.
341///
342/// # Arguments
343/// * `innermost_schema_id` - The $id for the generated schema (innermost type)
344/// * `base_schema_id` - The $ref target (base/outermost type)
345/// * `title` - Schema title
346/// * `own_properties` - Properties specific to this composed type
347/// * `required` - Required fields
348#[must_use]
349pub fn build_gts_allof_schema(
350    innermost_schema_id: &str,
351    base_schema_id: &str,
352    title: &str,
353    own_properties: &Value,
354    required: &[&str],
355) -> Value {
356    serde_json::json!({
357        "$id": format!("gts://{}", innermost_schema_id),
358        "$schema": "http://json-schema.org/draft-07/schema#",
359        "title": title,
360        "type": "object",
361        "allOf": [
362            { "$ref": format!("gts://{}", base_schema_id) },
363            {
364                "type": "object",
365                "properties": own_properties,
366                "required": required
367            }
368        ]
369    })
370}
371
372#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)]
374mod tests {
375    use super::*;
376    use serde_json::json;
377
378    #[test]
379    fn test_unit_type_properties() {
380        // Test all unit type properties in one test
381        let schema = <()>::gts_schema();
382        assert_eq!(schema, json!({"type": "object"}));
383        assert_eq!(<()>::SCHEMA_ID, "");
384        assert_eq!(<()>::GENERIC_FIELD, None);
385    }
386
387    #[test]
388    fn test_wrap_in_nesting_path_empty_path() {
389        let properties = json!({"field1": {"type": "string"}});
390        let required = json!(["field1"]);
391
392        let result = <()>::wrap_in_nesting_path(&[], properties.clone(), required, None);
393
394        assert_eq!(result, properties);
395    }
396
397    #[test]
398    fn test_wrap_in_nesting_path_single_level() {
399        let properties = json!({"field1": {"type": "string"}});
400        let required = json!(["field1"]);
401
402        let result = <()>::wrap_in_nesting_path(&["payload"], properties, required.clone(), None);
403
404        assert_eq!(
405            result,
406            json!({
407                "payload": {
408                    "type": "object",
409                    "additionalProperties": false,
410                    "properties": {"field1": {"type": "string"}},
411                    "required": required
412                }
413            })
414        );
415    }
416
417    #[test]
418    fn test_wrap_in_nesting_path_multi_level() {
419        let properties = json!({"field1": {"type": "string"}});
420        let required = json!(["field1"]);
421
422        let result =
423            <()>::wrap_in_nesting_path(&["payload", "data"], properties, required.clone(), None);
424
425        assert_eq!(
426            result,
427            json!({
428                "payload": {
429                    "type": "object",
430                    "properties": {
431                        "data": {
432                            "type": "object",
433                            "additionalProperties": false,
434                            "properties": {"field1": {"type": "string"}},
435                            "required": required
436                        }
437                    }
438                }
439            })
440        );
441    }
442
443    #[test]
444    fn test_wrap_in_nesting_path_with_generic_field() {
445        let properties = json!({
446            "field1": {"type": "string"},
447            "generic_field": {"type": "number"}
448        });
449        let required = json!(["field1"]);
450
451        let result =
452            <()>::wrap_in_nesting_path(&["payload"], properties, required, Some("generic_field"));
453
454        let result_obj = result.as_object().unwrap();
455        let payload = result_obj.get("payload").unwrap();
456        let props = payload.get("properties").unwrap();
457
458        // Generic field should be just {"type": "object"}
459        assert_eq!(
460            props.get("generic_field").unwrap(),
461            &json!({"type": "object"})
462        );
463        // Other fields should be preserved
464        assert_eq!(props.get("field1").unwrap(), &json!({"type": "string"}));
465    }
466
467    #[test]
468    fn test_strip_schema_metadata_removes_all_metadata() {
469        // Test removal of all metadata fields including $id, $schema, title, description
470        let schema = json!({
471            "$id": "gts://test",
472            "$schema": "http://json-schema.org/draft-07/schema#",
473            "title": "Test Schema",
474            "description": "A test",
475            "type": "object",
476            "properties": {"field": {"type": "string"}}
477        });
478
479        let result = strip_schema_metadata(&schema);
480
481        // All metadata should be removed
482        assert!(result.get("$id").is_none());
483        assert!(result.get("$schema").is_none());
484        assert!(result.get("title").is_none());
485        assert!(result.get("description").is_none());
486        // Non-metadata should be preserved
487        assert_eq!(result.get("type").unwrap(), "object");
488        assert!(result.get("properties").is_some());
489    }
490
491    #[test]
492    fn test_strip_schema_metadata_recursive() {
493        let schema = json!({
494            "$id": "gts://test",
495            "properties": {
496                "nested": {
497                    "$id": "gts://nested",
498                    "type": "string",
499                    "description": "Nested field"
500                }
501            }
502        });
503
504        let result = strip_schema_metadata(&schema);
505
506        assert!(result.get("$id").is_none());
507        let props = result.get("properties").unwrap();
508        let nested = props.get("nested").unwrap();
509        assert!(nested.get("$id").is_none());
510        assert!(nested.get("description").is_none());
511        assert_eq!(nested.get("type").unwrap(), "string");
512    }
513
514    #[test]
515    fn test_strip_schema_metadata_preserves_non_metadata() {
516        let schema = json!({
517            "$id": "gts://test",
518            "type": "object",
519            "properties": {"field": {"type": "string"}},
520            "required": ["field"],
521            "additionalProperties": false
522        });
523
524        let result = strip_schema_metadata(&schema);
525
526        assert_eq!(result.get("type").unwrap(), "object");
527        assert!(result.get("properties").is_some());
528        assert!(result.get("required").is_some());
529        assert_eq!(result.get("additionalProperties").unwrap(), &json!(false));
530    }
531
532    #[test]
533    fn test_build_gts_allof_schema_structure() {
534        let properties = json!({"field1": {"type": "string"}});
535        let required = vec!["field1"];
536
537        let result = build_gts_allof_schema(
538            "vendor.package.namespace.child.1",
539            "vendor.package.namespace.base.1",
540            "Child Schema",
541            &properties,
542            &required,
543        );
544
545        assert_eq!(
546            result.get("$id").unwrap(),
547            "gts://vendor.package.namespace.child.1"
548        );
549        assert_eq!(
550            result.get("$schema").unwrap(),
551            "http://json-schema.org/draft-07/schema#"
552        );
553        assert_eq!(result.get("title").unwrap(), "Child Schema");
554        assert_eq!(result.get("type").unwrap(), "object");
555
556        let allof = result.get("allOf").unwrap().as_array().unwrap();
557        assert_eq!(allof.len(), 2);
558    }
559
560    #[test]
561    fn test_build_gts_allof_schema_ref_format() {
562        let properties = json!({"field1": {"type": "string"}});
563        let required = vec!["field1"];
564
565        let result = build_gts_allof_schema(
566            "vendor.package.namespace.child.1",
567            "vendor.package.namespace.base.1",
568            "Child Schema",
569            &properties,
570            &required,
571        );
572
573        let allof = result.get("allOf").unwrap().as_array().unwrap();
574        let ref_obj = &allof[0];
575
576        assert_eq!(
577            ref_obj.get("$ref").unwrap(),
578            "gts://vendor.package.namespace.base.1"
579        );
580    }
581
582    #[test]
583    fn test_build_gts_allof_schema_properties_in_allof() {
584        let properties = json!({"field1": {"type": "string"}, "field2": {"type": "number"}});
585        let required = vec!["field1", "field2"];
586
587        let result = build_gts_allof_schema(
588            "vendor.package.namespace.child.1",
589            "vendor.package.namespace.base.1",
590            "Child Schema",
591            &properties,
592            &required,
593        );
594
595        let allof = result.get("allOf").unwrap().as_array().unwrap();
596        let props_obj = &allof[1];
597
598        assert_eq!(props_obj.get("type").unwrap(), "object");
599        assert_eq!(props_obj.get("properties").unwrap(), &properties);
600        assert_eq!(props_obj.get("required").unwrap(), &json!(required));
601    }
602}