rustapi_openapi/v31/
schema.rs

1//! JSON Schema 2020-12 support for OpenAPI 3.1
2//!
3//! OpenAPI 3.1 uses JSON Schema 2020-12 directly, which has some key differences
4//! from the JSON Schema draft used in OpenAPI 3.0.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Type array for nullable types in JSON Schema 2020-12
10///
11/// In OpenAPI 3.1/JSON Schema 2020-12, nullable types are represented as:
12/// `"type": ["string", "null"]` instead of `"type": "string", "nullable": true`
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(untagged)]
15pub enum TypeArray {
16    /// Single type (e.g., "string")
17    Single(String),
18    /// Multiple types (e.g., ["string", "null"])
19    Array(Vec<String>),
20}
21
22impl TypeArray {
23    /// Create a single type
24    pub fn single(ty: impl Into<String>) -> Self {
25        Self::Single(ty.into())
26    }
27
28    /// Create a nullable type
29    pub fn nullable(ty: impl Into<String>) -> Self {
30        Self::Array(vec![ty.into(), "null".to_string()])
31    }
32
33    /// Create a type array from multiple types
34    pub fn array(types: Vec<String>) -> Self {
35        if types.len() == 1 {
36            Self::Single(types.into_iter().next().unwrap())
37        } else {
38            Self::Array(types)
39        }
40    }
41
42    /// Check if this type is nullable
43    pub fn is_nullable(&self) -> bool {
44        match self {
45            Self::Single(_) => false,
46            Self::Array(types) => types.iter().any(|t| t == "null"),
47        }
48    }
49
50    /// Add null to make this type nullable
51    pub fn make_nullable(self) -> Self {
52        match self {
53            Self::Single(ty) => Self::Array(vec![ty, "null".to_string()]),
54            Self::Array(mut types) => {
55                if !types.iter().any(|t| t == "null") {
56                    types.push("null".to_string());
57                }
58                Self::Array(types)
59            }
60        }
61    }
62}
63
64/// JSON Schema 2020-12 schema definition
65#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct JsonSchema2020 {
68    /// Schema dialect identifier
69    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
70    pub schema: Option<String>,
71
72    /// Schema identifier
73    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
74    pub id: Option<String>,
75
76    /// Reference to another schema
77    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
78    pub reference: Option<String>,
79
80    /// Dynamic reference
81    #[serde(rename = "$dynamicRef", skip_serializing_if = "Option::is_none")]
82    pub dynamic_ref: Option<String>,
83
84    /// Type of the schema
85    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
86    pub schema_type: Option<TypeArray>,
87
88    /// Title of the schema
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub title: Option<String>,
91
92    /// Description of the schema
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub description: Option<String>,
95
96    /// Default value
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub default: Option<serde_json::Value>,
99
100    /// Constant value
101    #[serde(rename = "const", skip_serializing_if = "Option::is_none")]
102    pub const_value: Option<serde_json::Value>,
103
104    /// Enum values
105    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
106    pub enum_values: Option<Vec<serde_json::Value>>,
107
108    // String constraints
109    /// Minimum length for strings
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub min_length: Option<u64>,
112
113    /// Maximum length for strings
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub max_length: Option<u64>,
116
117    /// Pattern for strings
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub pattern: Option<String>,
120
121    /// Format hint
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub format: Option<String>,
124
125    // Number constraints
126    /// Minimum value (inclusive)
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub minimum: Option<f64>,
129
130    /// Maximum value (inclusive)
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub maximum: Option<f64>,
133
134    /// Exclusive minimum (JSON Schema 2020-12 uses number, not boolean)
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub exclusive_minimum: Option<f64>,
137
138    /// Exclusive maximum (JSON Schema 2020-12 uses number, not boolean)
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub exclusive_maximum: Option<f64>,
141
142    /// Multiple of constraint
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub multiple_of: Option<f64>,
145
146    // Array constraints
147    /// Items schema
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub items: Option<Box<JsonSchema2020>>,
150
151    /// Prefix items (replaces "items" array in draft-07)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub prefix_items: Option<Vec<JsonSchema2020>>,
154
155    /// Contains constraint
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub contains: Option<Box<JsonSchema2020>>,
158
159    /// Minimum items
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub min_items: Option<u64>,
162
163    /// Maximum items
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub max_items: Option<u64>,
166
167    /// Unique items
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub unique_items: Option<bool>,
170
171    // Object constraints
172    /// Object properties
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub properties: Option<HashMap<String, JsonSchema2020>>,
175
176    /// Pattern properties
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub pattern_properties: Option<HashMap<String, JsonSchema2020>>,
179
180    /// Additional properties
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub additional_properties: Option<Box<AdditionalProperties>>,
183
184    /// Required properties
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub required: Option<Vec<String>>,
187
188    /// Property names schema
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub property_names: Option<Box<JsonSchema2020>>,
191
192    /// Minimum properties
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub min_properties: Option<u64>,
195
196    /// Maximum properties
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub max_properties: Option<u64>,
199
200    // Composition
201    /// All of (must match all schemas)
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub all_of: Option<Vec<JsonSchema2020>>,
204
205    /// Any of (must match at least one schema)
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub any_of: Option<Vec<JsonSchema2020>>,
208
209    /// One of (must match exactly one schema)
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub one_of: Option<Vec<JsonSchema2020>>,
212
213    /// Not (must not match schema)
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub not: Option<Box<JsonSchema2020>>,
216
217    // Conditionals
218    /// If condition
219    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
220    pub if_schema: Option<Box<JsonSchema2020>>,
221
222    /// Then schema
223    #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
224    pub then_schema: Option<Box<JsonSchema2020>>,
225
226    /// Else schema
227    #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
228    pub else_schema: Option<Box<JsonSchema2020>>,
229
230    // OpenAPI extensions
231    /// Whether this property is deprecated
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub deprecated: Option<bool>,
234
235    /// Whether this property is read-only
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub read_only: Option<bool>,
238
239    /// Whether this property is write-only
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub write_only: Option<bool>,
242
243    /// Example value
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub example: Option<serde_json::Value>,
246
247    /// Examples (JSON Schema 2020-12)
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub examples: Option<Vec<serde_json::Value>>,
250
251    /// OpenAPI discriminator
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub discriminator: Option<Discriminator>,
254
255    /// External documentation
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub external_docs: Option<ExternalDocumentation>,
258
259    /// XML metadata
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub xml: Option<Xml>,
262}
263
264/// Additional properties can be a boolean or a schema
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
266#[serde(untagged)]
267pub enum AdditionalProperties {
268    Bool(bool),
269    Schema(Box<JsonSchema2020>),
270}
271
272/// Discriminator for polymorphism
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274pub struct Discriminator {
275    /// Property name for discriminator
276    #[serde(rename = "propertyName")]
277    pub property_name: String,
278
279    /// Mapping of values to schema references
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub mapping: Option<HashMap<String, String>>,
282}
283
284/// External documentation
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
286pub struct ExternalDocumentation {
287    /// URL to external documentation
288    pub url: String,
289
290    /// Description of external documentation
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub description: Option<String>,
293}
294
295/// XML metadata
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
297pub struct Xml {
298    /// XML element name
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub name: Option<String>,
301
302    /// XML namespace
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub namespace: Option<String>,
305
306    /// XML prefix
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub prefix: Option<String>,
309
310    /// Whether to use attribute (vs element)
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub attribute: Option<bool>,
313
314    /// Whether to wrap array elements
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub wrapped: Option<bool>,
317}
318
319impl JsonSchema2020 {
320    /// Create a new empty schema
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Create a string schema
326    pub fn string() -> Self {
327        Self {
328            schema_type: Some(TypeArray::single("string")),
329            ..Default::default()
330        }
331    }
332
333    /// Create a number schema
334    pub fn number() -> Self {
335        Self {
336            schema_type: Some(TypeArray::single("number")),
337            ..Default::default()
338        }
339    }
340
341    /// Create an integer schema
342    pub fn integer() -> Self {
343        Self {
344            schema_type: Some(TypeArray::single("integer")),
345            ..Default::default()
346        }
347    }
348
349    /// Create a boolean schema
350    pub fn boolean() -> Self {
351        Self {
352            schema_type: Some(TypeArray::single("boolean")),
353            ..Default::default()
354        }
355    }
356
357    /// Create an array schema
358    pub fn array(items: JsonSchema2020) -> Self {
359        Self {
360            schema_type: Some(TypeArray::single("array")),
361            items: Some(Box::new(items)),
362            ..Default::default()
363        }
364    }
365
366    /// Create an object schema
367    pub fn object() -> Self {
368        Self {
369            schema_type: Some(TypeArray::single("object")),
370            ..Default::default()
371        }
372    }
373
374    /// Create a null schema
375    pub fn null() -> Self {
376        Self {
377            schema_type: Some(TypeArray::single("null")),
378            ..Default::default()
379        }
380    }
381
382    /// Create a reference schema
383    pub fn reference(ref_path: impl Into<String>) -> Self {
384        Self {
385            reference: Some(ref_path.into()),
386            ..Default::default()
387        }
388    }
389
390    /// Make this schema nullable
391    pub fn nullable(mut self) -> Self {
392        self.schema_type = self.schema_type.map(|t| t.make_nullable());
393        self
394    }
395
396    /// Add a title
397    pub fn with_title(mut self, title: impl Into<String>) -> Self {
398        self.title = Some(title.into());
399        self
400    }
401
402    /// Add a description
403    pub fn with_description(mut self, description: impl Into<String>) -> Self {
404        self.description = Some(description.into());
405        self
406    }
407
408    /// Add a format
409    pub fn with_format(mut self, format: impl Into<String>) -> Self {
410        self.format = Some(format.into());
411        self
412    }
413
414    /// Add a property to an object schema
415    pub fn with_property(mut self, name: impl Into<String>, schema: JsonSchema2020) -> Self {
416        let properties = self.properties.get_or_insert_with(HashMap::new);
417        properties.insert(name.into(), schema);
418        self
419    }
420
421    /// Add a required property
422    pub fn with_required(mut self, name: impl Into<String>) -> Self {
423        let required = self.required.get_or_insert_with(Vec::new);
424        required.push(name.into());
425        self
426    }
427
428    /// Add an example
429    pub fn with_example(mut self, example: serde_json::Value) -> Self {
430        self.example = Some(example);
431        self
432    }
433}
434
435/// Transformer for converting OpenAPI 3.0 schemas to 3.1
436pub struct SchemaTransformer;
437
438impl SchemaTransformer {
439    /// Transform an OpenAPI 3.0 schema (serde_json::Value) to OpenAPI 3.1 format
440    ///
441    /// Key transformations:
442    /// - `nullable: true` becomes `type: ["<type>", "null"]`
443    /// - `exclusiveMinimum: true` with `minimum: X` becomes `exclusiveMinimum: X`
444    /// - `exclusiveMaximum: true` with `maximum: X` becomes `exclusiveMaximum: X`
445    pub fn transform_30_to_31(schema: serde_json::Value) -> serde_json::Value {
446        match schema {
447            serde_json::Value::Object(mut map) => {
448                // Transform nullable
449                if map.get("nullable") == Some(&serde_json::Value::Bool(true)) {
450                    map.remove("nullable");
451                    if let Some(serde_json::Value::String(ty)) = map.get("type") {
452                        let type_array = serde_json::json!([ty.clone(), "null"]);
453                        map.insert("type".to_string(), type_array);
454                    }
455                }
456
457                // Transform exclusiveMinimum
458                if map.get("exclusiveMinimum") == Some(&serde_json::Value::Bool(true)) {
459                    if let Some(min) = map.remove("minimum") {
460                        map.insert("exclusiveMinimum".to_string(), min);
461                    }
462                }
463
464                // Transform exclusiveMaximum
465                if map.get("exclusiveMaximum") == Some(&serde_json::Value::Bool(true)) {
466                    if let Some(max) = map.remove("maximum") {
467                        map.insert("exclusiveMaximum".to_string(), max);
468                    }
469                }
470
471                // Recursively transform nested schemas
472                for key in [
473                    "items",
474                    "additionalProperties",
475                    "not",
476                    "if",
477                    "then",
478                    "else",
479                    "contains",
480                    "propertyNames",
481                ] {
482                    if let Some(nested) = map.remove(key) {
483                        map.insert(key.to_string(), Self::transform_30_to_31(nested));
484                    }
485                }
486
487                // Transform arrays
488                for key in ["allOf", "anyOf", "oneOf", "prefixItems"] {
489                    if let Some(serde_json::Value::Array(arr)) = map.remove(key) {
490                        let transformed: Vec<_> =
491                            arr.into_iter().map(Self::transform_30_to_31).collect();
492                        map.insert(key.to_string(), serde_json::Value::Array(transformed));
493                    }
494                }
495
496                // Transform properties
497                if let Some(serde_json::Value::Object(props)) = map.remove("properties") {
498                    let transformed: serde_json::Map<String, serde_json::Value> = props
499                        .into_iter()
500                        .map(|(k, v)| (k, Self::transform_30_to_31(v)))
501                        .collect();
502                    map.insert(
503                        "properties".to_string(),
504                        serde_json::Value::Object(transformed),
505                    );
506                }
507
508                // Transform patternProperties
509                if let Some(serde_json::Value::Object(props)) = map.remove("patternProperties") {
510                    let transformed: serde_json::Map<String, serde_json::Value> = props
511                        .into_iter()
512                        .map(|(k, v)| (k, Self::transform_30_to_31(v)))
513                        .collect();
514                    map.insert(
515                        "patternProperties".to_string(),
516                        serde_json::Value::Object(transformed),
517                    );
518                }
519
520                serde_json::Value::Object(map)
521            }
522            serde_json::Value::Array(arr) => {
523                serde_json::Value::Array(arr.into_iter().map(Self::transform_30_to_31).collect())
524            }
525            other => other,
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_type_array_single() {
536        let ty = TypeArray::single("string");
537        assert!(!ty.is_nullable());
538        assert_eq!(serde_json::to_string(&ty).unwrap(), r#""string""#);
539    }
540
541    #[test]
542    fn test_type_array_nullable() {
543        let ty = TypeArray::nullable("string");
544        assert!(ty.is_nullable());
545        assert_eq!(serde_json::to_string(&ty).unwrap(), r#"["string","null"]"#);
546    }
547
548    #[test]
549    fn test_make_nullable() {
550        let ty = TypeArray::single("integer").make_nullable();
551        assert!(ty.is_nullable());
552
553        // Making nullable again should not add duplicate null
554        let ty2 = ty.make_nullable();
555        if let TypeArray::Array(types) = ty2 {
556            assert_eq!(types.iter().filter(|t| *t == "null").count(), 1);
557        }
558    }
559
560    #[test]
561    fn test_schema_transformer_nullable() {
562        let schema30 = serde_json::json!({
563            "type": "string",
564            "nullable": true
565        });
566
567        let schema31 = SchemaTransformer::transform_30_to_31(schema30);
568
569        assert_eq!(
570            schema31,
571            serde_json::json!({
572                "type": ["string", "null"]
573            })
574        );
575    }
576
577    #[test]
578    fn test_schema_transformer_exclusive_minimum() {
579        let schema30 = serde_json::json!({
580            "type": "integer",
581            "minimum": 0,
582            "exclusiveMinimum": true
583        });
584
585        let schema31 = SchemaTransformer::transform_30_to_31(schema30);
586
587        assert_eq!(
588            schema31,
589            serde_json::json!({
590                "type": "integer",
591                "exclusiveMinimum": 0
592            })
593        );
594    }
595
596    #[test]
597    fn test_json_schema_2020_builder() {
598        let schema = JsonSchema2020::object()
599            .with_property("name", JsonSchema2020::string())
600            .with_property("age", JsonSchema2020::integer().nullable())
601            .with_required("name");
602
603        assert!(schema.properties.is_some());
604        assert_eq!(schema.required, Some(vec!["name".to_string()]));
605    }
606}