domainstack_schema/
json_schema.rs

1//! JSON Schema (Draft 2020-12) generation for domainstack validation types.
2//!
3//! This module provides a trait-based approach to JSON Schema generation,
4//! complementing the CLI-based approach for cases where programmatic
5//! schema generation is preferred.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// JSON Schema document (Draft 2020-12)
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct JsonSchema {
14    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
15    pub schema: Option<String>,
16
17    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
18    pub id: Option<String>,
19
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub title: Option<String>,
22
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25
26    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
27    pub schema_type: Option<JsonSchemaType>,
28
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub format: Option<String>,
31
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub properties: Option<HashMap<String, JsonSchema>>,
34
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub required: Option<Vec<String>>,
37
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub items: Option<Box<JsonSchema>>,
40
41    #[serde(
42        rename = "additionalProperties",
43        skip_serializing_if = "Option::is_none"
44    )]
45    pub additional_properties: Option<AdditionalProperties>,
46
47    // String constraints
48    #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
49    pub min_length: Option<usize>,
50
51    #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
52    pub max_length: Option<usize>,
53
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub pattern: Option<String>,
56
57    // Numeric constraints
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub minimum: Option<f64>,
60
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub maximum: Option<f64>,
63
64    #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
65    pub exclusive_minimum: Option<f64>,
66
67    #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
68    pub exclusive_maximum: Option<f64>,
69
70    #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
71    pub multiple_of: Option<f64>,
72
73    // Array constraints
74    #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
75    pub min_items: Option<usize>,
76
77    #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
78    pub max_items: Option<usize>,
79
80    #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
81    pub unique_items: Option<bool>,
82
83    // Enum constraint
84    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
85    pub r#enum: Option<Vec<serde_json::Value>>,
86
87    #[serde(rename = "const", skip_serializing_if = "Option::is_none")]
88    pub r#const: Option<serde_json::Value>,
89
90    // Reference
91    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
92    pub reference: Option<String>,
93
94    // Composition
95    #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
96    pub any_of: Option<Vec<JsonSchema>>,
97
98    #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")]
99    pub all_of: Option<Vec<JsonSchema>>,
100
101    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
102    pub one_of: Option<Vec<JsonSchema>>,
103
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub not: Option<Box<JsonSchema>>,
106
107    // Metadata
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub default: Option<serde_json::Value>,
110
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub examples: Option<Vec<serde_json::Value>>,
113
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub deprecated: Option<bool>,
116
117    #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
118    pub read_only: Option<bool>,
119
120    #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
121    pub write_only: Option<bool>,
122
123    // $defs for schema definitions
124    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
125    pub defs: Option<HashMap<String, JsonSchema>>,
126}
127
128/// JSON Schema types
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum JsonSchemaType {
132    String,
133    Number,
134    Integer,
135    Boolean,
136    Array,
137    Object,
138    Null,
139}
140
141/// Additional properties can be a boolean or a schema
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(untagged)]
144pub enum AdditionalProperties {
145    Bool(bool),
146    Schema(Box<JsonSchema>),
147}
148
149impl JsonSchema {
150    /// Create a new empty schema
151    pub fn new() -> Self {
152        Self {
153            schema: None,
154            id: None,
155            title: None,
156            description: None,
157            schema_type: None,
158            format: None,
159            properties: None,
160            required: None,
161            items: None,
162            additional_properties: None,
163            min_length: None,
164            max_length: None,
165            pattern: None,
166            minimum: None,
167            maximum: None,
168            exclusive_minimum: None,
169            exclusive_maximum: None,
170            multiple_of: None,
171            min_items: None,
172            max_items: None,
173            unique_items: None,
174            r#enum: None,
175            r#const: None,
176            reference: None,
177            any_of: None,
178            all_of: None,
179            one_of: None,
180            not: None,
181            default: None,
182            examples: None,
183            deprecated: None,
184            read_only: None,
185            write_only: None,
186            defs: None,
187        }
188    }
189
190    /// Create a string schema
191    pub fn string() -> Self {
192        Self {
193            schema_type: Some(JsonSchemaType::String),
194            ..Self::new()
195        }
196    }
197
198    /// Create an integer schema
199    pub fn integer() -> Self {
200        Self {
201            schema_type: Some(JsonSchemaType::Integer),
202            ..Self::new()
203        }
204    }
205
206    /// Create a number schema
207    pub fn number() -> Self {
208        Self {
209            schema_type: Some(JsonSchemaType::Number),
210            ..Self::new()
211        }
212    }
213
214    /// Create a boolean schema
215    pub fn boolean() -> Self {
216        Self {
217            schema_type: Some(JsonSchemaType::Boolean),
218            ..Self::new()
219        }
220    }
221
222    /// Create an array schema
223    pub fn array(items: JsonSchema) -> Self {
224        Self {
225            schema_type: Some(JsonSchemaType::Array),
226            items: Some(Box::new(items)),
227            ..Self::new()
228        }
229    }
230
231    /// Create an object schema
232    pub fn object() -> Self {
233        Self {
234            schema_type: Some(JsonSchemaType::Object),
235            properties: Some(HashMap::new()),
236            additional_properties: Some(AdditionalProperties::Bool(false)),
237            ..Self::new()
238        }
239    }
240
241    /// Create a null schema
242    pub fn null() -> Self {
243        Self {
244            schema_type: Some(JsonSchemaType::Null),
245            ..Self::new()
246        }
247    }
248
249    /// Create a reference to another schema
250    pub fn reference(name: &str) -> Self {
251        Self {
252            reference: Some(format!("#/$defs/{}", name)),
253            ..Self::new()
254        }
255    }
256
257    /// Set the title
258    pub fn title(mut self, title: impl Into<String>) -> Self {
259        self.title = Some(title.into());
260        self
261    }
262
263    /// Set the description
264    pub fn description(mut self, desc: impl Into<String>) -> Self {
265        self.description = Some(desc.into());
266        self
267    }
268
269    /// Set the format
270    pub fn format(mut self, format: impl Into<String>) -> Self {
271        self.format = Some(format.into());
272        self
273    }
274
275    /// Add a property to an object schema
276    pub fn property(mut self, name: impl Into<String>, schema: JsonSchema) -> Self {
277        self.properties
278            .get_or_insert_with(HashMap::new)
279            .insert(name.into(), schema);
280        self
281    }
282
283    /// Set required fields
284    pub fn required(mut self, fields: &[&str]) -> Self {
285        self.required = Some(fields.iter().map(|s| s.to_string()).collect());
286        self
287    }
288
289    /// Set minimum length for strings
290    pub fn min_length(mut self, min: usize) -> Self {
291        self.min_length = Some(min);
292        self
293    }
294
295    /// Set maximum length for strings
296    pub fn max_length(mut self, max: usize) -> Self {
297        self.max_length = Some(max);
298        self
299    }
300
301    /// Set regex pattern for strings
302    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
303        self.pattern = Some(pattern.into());
304        self
305    }
306
307    /// Set minimum value for numbers
308    pub fn minimum(mut self, min: impl Into<f64>) -> Self {
309        self.minimum = Some(min.into());
310        self
311    }
312
313    /// Set maximum value for numbers
314    pub fn maximum(mut self, max: impl Into<f64>) -> Self {
315        self.maximum = Some(max.into());
316        self
317    }
318
319    /// Set exclusive minimum for numbers
320    pub fn exclusive_minimum(mut self, min: impl Into<f64>) -> Self {
321        self.exclusive_minimum = Some(min.into());
322        self
323    }
324
325    /// Set exclusive maximum for numbers
326    pub fn exclusive_maximum(mut self, max: impl Into<f64>) -> Self {
327        self.exclusive_maximum = Some(max.into());
328        self
329    }
330
331    /// Set multipleOf constraint for numbers
332    pub fn multiple_of(mut self, divisor: impl Into<f64>) -> Self {
333        self.multiple_of = Some(divisor.into());
334        self
335    }
336
337    /// Set minimum items for arrays
338    pub fn min_items(mut self, min: usize) -> Self {
339        self.min_items = Some(min);
340        self
341    }
342
343    /// Set maximum items for arrays
344    pub fn max_items(mut self, max: usize) -> Self {
345        self.max_items = Some(max);
346        self
347    }
348
349    /// Set unique items constraint for arrays
350    pub fn unique_items(mut self, unique: bool) -> Self {
351        self.unique_items = Some(unique);
352        self
353    }
354
355    /// Set enum values
356    pub fn enum_values<T: Serialize>(mut self, values: &[T]) -> Self {
357        self.r#enum = Some(
358            values
359                .iter()
360                .map(|v| serde_json::to_value(v).expect("Failed to serialize enum value"))
361                .collect(),
362        );
363        self
364    }
365
366    /// Set a const value
367    pub fn const_value<T: Serialize>(mut self, value: T) -> Self {
368        self.r#const = Some(serde_json::to_value(value).expect("Failed to serialize const value"));
369        self
370    }
371
372    /// Set a default value
373    pub fn default<T: Serialize>(mut self, value: T) -> Self {
374        self.default =
375            Some(serde_json::to_value(value).expect("Failed to serialize default value"));
376        self
377    }
378
379    /// Set example values
380    pub fn examples<T: Serialize>(mut self, values: Vec<T>) -> Self {
381        self.examples = Some(
382            values
383                .into_iter()
384                .map(|v| serde_json::to_value(v).expect("Failed to serialize example value"))
385                .collect(),
386        );
387        self
388    }
389
390    /// Mark as deprecated
391    pub fn deprecated(mut self, deprecated: bool) -> Self {
392        self.deprecated = Some(deprecated);
393        self
394    }
395
396    /// Mark as read-only
397    pub fn read_only(mut self, read_only: bool) -> Self {
398        self.read_only = Some(read_only);
399        self
400    }
401
402    /// Mark as write-only
403    pub fn write_only(mut self, write_only: bool) -> Self {
404        self.write_only = Some(write_only);
405        self
406    }
407
408    /// Create an anyOf schema
409    pub fn any_of(schemas: Vec<JsonSchema>) -> Self {
410        Self {
411            any_of: Some(schemas),
412            ..Self::new()
413        }
414    }
415
416    /// Create an allOf schema
417    pub fn all_of(schemas: Vec<JsonSchema>) -> Self {
418        Self {
419            all_of: Some(schemas),
420            ..Self::new()
421        }
422    }
423
424    /// Create a oneOf schema
425    pub fn one_of(schemas: Vec<JsonSchema>) -> Self {
426        Self {
427            one_of: Some(schemas),
428            ..Self::new()
429        }
430    }
431
432    /// Create a negation schema (not)
433    pub fn negation(schema: JsonSchema) -> Self {
434        Self {
435            not: Some(Box::new(schema)),
436            ..Self::new()
437        }
438    }
439}
440
441impl Default for JsonSchema {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447/// Types that can generate JSON Schema (Draft 2020-12).
448///
449/// This trait provides programmatic JSON Schema generation as an alternative
450/// to the CLI-based approach.
451///
452/// # Example
453///
454/// ```rust
455/// use domainstack_schema::{ToJsonSchema, JsonSchema};
456///
457/// struct User {
458///     email: String,
459///     age: u8,
460/// }
461///
462/// impl ToJsonSchema for User {
463///     fn schema_name() -> &'static str {
464///         "User"
465///     }
466///
467///     fn json_schema() -> JsonSchema {
468///         JsonSchema::object()
469///             .property("email", JsonSchema::string().format("email"))
470///             .property("age", JsonSchema::integer().minimum(0).maximum(150))
471///             .required(&["email", "age"])
472///     }
473/// }
474/// ```
475pub trait ToJsonSchema {
476    /// The name of this schema in the $defs section.
477    fn schema_name() -> &'static str;
478
479    /// Generate the JSON Schema for this type.
480    fn json_schema() -> JsonSchema;
481}
482
483// Implementations for primitive types
484impl ToJsonSchema for String {
485    fn schema_name() -> &'static str {
486        "string"
487    }
488
489    fn json_schema() -> JsonSchema {
490        JsonSchema::string()
491    }
492}
493
494impl ToJsonSchema for str {
495    fn schema_name() -> &'static str {
496        "string"
497    }
498
499    fn json_schema() -> JsonSchema {
500        JsonSchema::string()
501    }
502}
503
504impl ToJsonSchema for u8 {
505    fn schema_name() -> &'static str {
506        "integer"
507    }
508
509    fn json_schema() -> JsonSchema {
510        JsonSchema::integer().minimum(0).maximum(255)
511    }
512}
513
514impl ToJsonSchema for u16 {
515    fn schema_name() -> &'static str {
516        "integer"
517    }
518
519    fn json_schema() -> JsonSchema {
520        JsonSchema::integer().minimum(0).maximum(65535)
521    }
522}
523
524impl ToJsonSchema for u32 {
525    fn schema_name() -> &'static str {
526        "integer"
527    }
528
529    fn json_schema() -> JsonSchema {
530        JsonSchema::integer().minimum(0)
531    }
532}
533
534impl ToJsonSchema for i32 {
535    fn schema_name() -> &'static str {
536        "integer"
537    }
538
539    fn json_schema() -> JsonSchema {
540        JsonSchema::integer()
541    }
542}
543
544impl ToJsonSchema for i64 {
545    fn schema_name() -> &'static str {
546        "integer"
547    }
548
549    fn json_schema() -> JsonSchema {
550        JsonSchema::integer()
551    }
552}
553
554impl ToJsonSchema for f32 {
555    fn schema_name() -> &'static str {
556        "number"
557    }
558
559    fn json_schema() -> JsonSchema {
560        JsonSchema::number()
561    }
562}
563
564impl ToJsonSchema for f64 {
565    fn schema_name() -> &'static str {
566        "number"
567    }
568
569    fn json_schema() -> JsonSchema {
570        JsonSchema::number()
571    }
572}
573
574impl ToJsonSchema for bool {
575    fn schema_name() -> &'static str {
576        "boolean"
577    }
578
579    fn json_schema() -> JsonSchema {
580        JsonSchema::boolean()
581    }
582}
583
584impl<T: ToJsonSchema> ToJsonSchema for Vec<T> {
585    fn schema_name() -> &'static str {
586        "array"
587    }
588
589    fn json_schema() -> JsonSchema {
590        JsonSchema::array(T::json_schema())
591    }
592}
593
594impl<T: ToJsonSchema> ToJsonSchema for Option<T> {
595    fn schema_name() -> &'static str {
596        T::schema_name()
597    }
598
599    fn json_schema() -> JsonSchema {
600        T::json_schema()
601    }
602}
603
604/// Builder for creating JSON Schema documents with $defs
605pub struct JsonSchemaBuilder {
606    id: Option<String>,
607    title: Option<String>,
608    description: Option<String>,
609    defs: HashMap<String, JsonSchema>,
610}
611
612impl JsonSchemaBuilder {
613    /// Create a new JSON Schema builder
614    pub fn new() -> Self {
615        Self {
616            id: None,
617            title: None,
618            description: None,
619            defs: HashMap::new(),
620        }
621    }
622
623    /// Set the $id
624    pub fn id(mut self, id: impl Into<String>) -> Self {
625        self.id = Some(id.into());
626        self
627    }
628
629    /// Set the title
630    pub fn title(mut self, title: impl Into<String>) -> Self {
631        self.title = Some(title.into());
632        self
633    }
634
635    /// Set the description
636    pub fn description(mut self, desc: impl Into<String>) -> Self {
637        self.description = Some(desc.into());
638        self
639    }
640
641    /// Register a type that implements ToJsonSchema
642    pub fn register<T: ToJsonSchema>(mut self) -> Self {
643        self.defs
644            .insert(T::schema_name().to_string(), T::json_schema());
645        self
646    }
647
648    /// Add a schema with a custom name
649    pub fn add_schema(mut self, name: impl Into<String>, schema: JsonSchema) -> Self {
650        self.defs.insert(name.into(), schema);
651        self
652    }
653
654    /// Build the final JSON Schema document
655    pub fn build(self) -> JsonSchema {
656        JsonSchema {
657            schema: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
658            id: self.id,
659            title: self.title,
660            description: self.description,
661            defs: if self.defs.is_empty() {
662                None
663            } else {
664                Some(self.defs)
665            },
666            ..JsonSchema::new()
667        }
668    }
669
670    /// Build and serialize to JSON string
671    pub fn to_json(&self) -> Result<String, serde_json::Error> {
672        let schema = JsonSchema {
673            schema: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
674            id: self.id.clone(),
675            title: self.title.clone(),
676            description: self.description.clone(),
677            defs: if self.defs.is_empty() {
678                None
679            } else {
680                Some(self.defs.clone())
681            },
682            ..JsonSchema::new()
683        };
684        serde_json::to_string_pretty(&schema)
685    }
686}
687
688impl Default for JsonSchemaBuilder {
689    fn default() -> Self {
690        Self::new()
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn test_string_schema() {
700        let schema = JsonSchema::string().min_length(3).max_length(50);
701        assert!(matches!(schema.schema_type, Some(JsonSchemaType::String)));
702        assert_eq!(schema.min_length, Some(3));
703        assert_eq!(schema.max_length, Some(50));
704    }
705
706    #[test]
707    fn test_integer_schema() {
708        let schema = JsonSchema::integer().minimum(0).maximum(100);
709        assert!(matches!(schema.schema_type, Some(JsonSchemaType::Integer)));
710        assert_eq!(schema.minimum, Some(0.0));
711        assert_eq!(schema.maximum, Some(100.0));
712    }
713
714    #[test]
715    fn test_object_schema() {
716        let schema = JsonSchema::object()
717            .property("name", JsonSchema::string())
718            .property("age", JsonSchema::integer())
719            .required(&["name"]);
720
721        assert!(matches!(schema.schema_type, Some(JsonSchemaType::Object)));
722        assert_eq!(schema.properties.as_ref().unwrap().len(), 2);
723        assert!(schema
724            .required
725            .as_ref()
726            .unwrap()
727            .contains(&"name".to_string()));
728    }
729
730    #[test]
731    fn test_array_schema() {
732        let schema = JsonSchema::array(JsonSchema::string())
733            .min_items(1)
734            .max_items(10);
735        assert!(matches!(schema.schema_type, Some(JsonSchemaType::Array)));
736        assert!(schema.items.is_some());
737        assert_eq!(schema.min_items, Some(1));
738    }
739
740    #[test]
741    fn test_reference() {
742        let schema = JsonSchema::reference("User");
743        assert_eq!(schema.reference, Some("#/$defs/User".to_string()));
744    }
745
746    #[test]
747    fn test_any_of() {
748        let schema = JsonSchema::any_of(vec![JsonSchema::string(), JsonSchema::integer()]);
749        assert!(schema.any_of.is_some());
750        assert_eq!(schema.any_of.as_ref().unwrap().len(), 2);
751    }
752
753    #[test]
754    fn test_builder() {
755        struct User;
756        impl ToJsonSchema for User {
757            fn schema_name() -> &'static str {
758                "User"
759            }
760
761            fn json_schema() -> JsonSchema {
762                JsonSchema::object()
763                    .property("email", JsonSchema::string().format("email"))
764                    .required(&["email"])
765            }
766        }
767
768        let doc = JsonSchemaBuilder::new()
769            .title("My Schema")
770            .register::<User>()
771            .build();
772
773        assert!(doc.schema.is_some());
774        assert_eq!(doc.title, Some("My Schema".to_string()));
775        assert!(doc.defs.as_ref().unwrap().contains_key("User"));
776    }
777
778    #[test]
779    fn test_serialization() {
780        let schema = JsonSchema::object()
781            .property("email", JsonSchema::string().format("email"))
782            .property("age", JsonSchema::integer().minimum(0))
783            .required(&["email", "age"]);
784
785        let json = serde_json::to_string(&schema).unwrap();
786        assert!(json.contains("\"type\":\"object\""));
787        assert!(json.contains("\"email\""));
788    }
789}