Skip to main content

siderite_validation/
schema.rs

1//! JSON Schema metadata shared with OpenAPI 3.1 / JSON Schema 2020-12.
2//!
3//! Types implement [`Schema`]. Composite schemas obtain child schemas through
4//! [`SchemaRegistry::subschema`], which returns a `$ref` for named types and
5//! stores their definition exactly once. This keeps OpenAPI documents small
6//! (components are reused, not duplicated) and makes recursive types safe.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::any::TypeId;
11use std::collections::BTreeMap;
12use thiserror::Error;
13
14use crate::types::{BoundedI64, ConstrainedString, Email, PositiveInt, SecretString};
15
16/// Prefix of component references in OpenAPI documents.
17pub const COMPONENTS_REF_PREFIX: &str = "#/components/schemas/";
18
19/// A JSON Schema object: a map of schema keywords.
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct SchemaObject(
23    /// Keyword/value pairs making up the schema.
24    pub Map<String, Value>,
25);
26
27impl SchemaObject {
28    /// Schema object with a single `"type"` keyword.
29    pub fn of_type(ty: &str) -> Self {
30        Self::default().with("type", ty)
31    }
32
33    /// `{"$ref": "<target>"}`.
34    pub fn reference(target: impl Into<String>) -> Self {
35        Self::default().with("$ref", target.into())
36    }
37
38    /// Insert or replace a schema keyword.
39    #[must_use]
40    pub fn with(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
41        self.0.insert(key.into(), value.into());
42        self
43    }
44
45    /// Keyword lookup.
46    pub fn get(&self, key: &str) -> Option<&Value> {
47        self.0.get(key)
48    }
49
50    /// Convert into a JSON value.
51    pub fn into_value(self) -> Value {
52        Value::Object(self.0)
53    }
54}
55
56impl From<SchemaObject> for Value {
57    fn from(s: SchemaObject) -> Self {
58        s.into_value()
59    }
60}
61
62/// Types that can describe themselves as JSON Schema.
63pub trait Schema {
64    /// Stable component name. Named types are emitted once under
65    /// `components.schemas` and referenced with `$ref`.
66    fn schema_name() -> Option<&'static str> {
67        None
68    }
69
70    /// The schema *definition* of this type. Use
71    /// [`SchemaRegistry::subschema`] for nested types.
72    fn schema(registry: &mut SchemaRegistry) -> SchemaObject;
73}
74
75/// Two distinct Rust types claimed the same component name.
76#[derive(Debug, Clone, PartialEq, Eq, Error)]
77#[error("schema name `{name}` is used by both `{first}` and `{second}`")]
78pub struct SchemaConflict {
79    /// Conflicting component name.
80    pub name: String,
81    /// Type registered first.
82    pub first: &'static str,
83    /// Type registered second.
84    pub second: &'static str,
85}
86
87#[derive(Debug, Clone)]
88struct Entry {
89    type_id: TypeId,
90    type_name: &'static str,
91    /// `None` while the definition is being generated (recursion guard).
92    definition: Option<SchemaObject>,
93}
94
95/// Collects named schema definitions while schemas are generated.
96#[derive(Debug, Clone, Default)]
97pub struct SchemaRegistry {
98    entries: BTreeMap<String, Entry>,
99    conflicts: Vec<SchemaConflict>,
100    security_schemes: BTreeMap<String, serde_json::Value>,
101}
102
103impl SchemaRegistry {
104    /// Empty registry.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Schema to embed for `T`: a `$ref` if `T` is named, otherwise inline.
110    pub fn subschema<T: Schema + ?Sized + 'static>(&mut self) -> SchemaObject {
111        let Some(name) = T::schema_name() else {
112            return T::schema(self);
113        };
114        let reference = SchemaObject::reference(format!("{COMPONENTS_REF_PREFIX}{name}"));
115        let type_id = TypeId::of::<T>();
116        let type_name = std::any::type_name::<T>();
117        if let Some(existing) = self.entries.get(name) {
118            if existing.type_id != type_id {
119                self.conflicts.push(SchemaConflict {
120                    name: name.to_owned(),
121                    first: existing.type_name,
122                    second: type_name,
123                });
124            }
125            return reference;
126        }
127        // Placeholder first so recursive references terminate.
128        self.entries.insert(
129            name.to_owned(),
130            Entry {
131                type_id,
132                type_name,
133                definition: None,
134            },
135        );
136        let definition = T::schema(self);
137        if let Some(entry) = self.entries.get_mut(name) {
138            entry.definition = Some(definition);
139        }
140        reference
141    }
142
143    /// Register an OpenAPI security scheme under `name`
144    /// (`components.securitySchemes`).
145    ///
146    /// Registering the same definition twice is a no-op; a different
147    /// definition under an existing name is recorded as a [`SchemaConflict`].
148    pub fn add_security_scheme(&mut self, name: &str, scheme: serde_json::Value) {
149        match self.security_schemes.get(name) {
150            Some(existing) if *existing != scheme => self.conflicts.push(SchemaConflict {
151                name: name.to_owned(),
152                first: "security scheme",
153                second: "security scheme",
154            }),
155            Some(_) => {}
156            None => {
157                self.security_schemes.insert(name.to_owned(), scheme);
158            }
159        }
160    }
161
162    /// Remove and return every registered security scheme.
163    pub fn take_security_schemes(&mut self) -> BTreeMap<String, serde_json::Value> {
164        std::mem::take(&mut self.security_schemes)
165    }
166
167    /// Consume the registry, returning `components.schemas`.
168    ///
169    /// # Errors
170    /// Returns every [`SchemaConflict`] detected during generation.
171    pub fn into_components(self) -> Result<BTreeMap<String, SchemaObject>, Vec<SchemaConflict>> {
172        if !self.conflicts.is_empty() {
173            return Err(self.conflicts);
174        }
175        Ok(self
176            .entries
177            .into_iter()
178            .filter_map(|(name, e)| e.definition.map(|d| (name, d)))
179            .collect())
180    }
181}
182
183/// Generate a self-contained schema for `T`, with named definitions under `$defs`.
184///
185/// Useful outside OpenAPI; `$ref`s point into `#/components/schemas/`, so this
186/// helper rewrites nothing and is intended for inspection and tests.
187pub fn schema_for<T: Schema + ?Sized + 'static>() -> (SchemaObject, SchemaRegistry) {
188    let mut registry = SchemaRegistry::new();
189    let root = registry.subschema::<T>();
190    (root, registry)
191}
192
193macro_rules! primitive_schema {
194    ($($t:ty => $ty:literal $(, $k:literal = $v:expr)*);* $(;)?) => {$(
195        impl Schema for $t {
196            fn schema(_: &mut SchemaRegistry) -> SchemaObject {
197                SchemaObject::of_type($ty)$(.with($k, $v))*
198            }
199        }
200    )*};
201}
202
203primitive_schema! {
204    String => "string";
205    str => "string";
206    bool => "boolean";
207    i8 => "integer", "format" = "int8";
208    i16 => "integer", "format" = "int16";
209    i32 => "integer", "format" = "int32";
210    i64 => "integer", "format" = "int64";
211    u8 => "integer", "minimum" = 0_i64, "maximum" = 255_i64;
212    u16 => "integer", "minimum" = 0_i64, "maximum" = i64::from(u16::MAX);
213    u32 => "integer", "minimum" = 0_i64, "maximum" = i64::from(u32::MAX);
214    u64 => "integer", "minimum" = 0_i64;
215    f32 => "number", "format" = "float";
216    f64 => "number", "format" = "double";
217    // JSON numbers beyond the 64-bit range do not round-trip through
218    // `serde_json`, so the documented range is what is accepted.
219    i128 => "integer", "minimum" = i64::MIN, "maximum" = u64::MAX;
220    u128 => "integer", "minimum" = 0_i64, "maximum" = u64::MAX;
221    isize => "integer", "minimum" = isize::MIN as i64, "maximum" = isize::MAX as i64;
222    usize => "integer", "minimum" = 0_i64, "maximum" = usize::MAX as u64;
223}
224
225/// Any JSON value: the empty schema.
226impl Schema for serde_json::Value {
227    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
228        SchemaObject::default()
229    }
230}
231
232impl<T: Schema + ?Sized + 'static> Schema for &T {
233    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
234        registry.subschema::<T>()
235    }
236}
237
238impl<T: Schema + 'static> Schema for Option<T> {
239    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
240        let inner = registry.subschema::<T>();
241        SchemaObject::default().with(
242            "anyOf",
243            Value::Array(vec![
244                inner.into_value(),
245                SchemaObject::of_type("null").into_value(),
246            ]),
247        )
248    }
249}
250
251impl<T: Schema + 'static> Schema for Vec<T> {
252    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
253        SchemaObject::of_type("array").with("items", registry.subschema::<T>())
254    }
255}
256
257impl<T: Schema + 'static> Schema for [T] {
258    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
259        SchemaObject::of_type("array").with("items", registry.subschema::<T>())
260    }
261}
262
263impl<T: Schema + ?Sized + 'static> Schema for Box<T> {
264    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
265        registry.subschema::<T>()
266    }
267}
268
269impl<T: Schema + 'static> Schema for BTreeMap<String, T> {
270    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
271        SchemaObject::of_type("object").with("additionalProperties", registry.subschema::<T>())
272    }
273}
274
275impl<T: Schema + 'static, S: 'static> Schema for std::collections::HashMap<String, T, S> {
276    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
277        SchemaObject::of_type("object").with("additionalProperties", registry.subschema::<T>())
278    }
279}
280
281macro_rules! tuple_schema {
282    ($($t:ident),+) => {
283        impl<$($t: Schema + 'static),+> Schema for ($($t,)+) {
284            fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
285                let items = vec![$(registry.subschema::<$t>().into_value()),+];
286                let len = items.len() as u64;
287                SchemaObject::of_type("array")
288                    .with("prefixItems", Value::Array(items))
289                    .with("minItems", len)
290                    .with("maxItems", len)
291            }
292        }
293    };
294}
295tuple_schema!(A);
296tuple_schema!(A, B);
297tuple_schema!(A, B, C);
298tuple_schema!(A, B, C, D);
299
300impl<const MIN: usize, const MAX: usize> Schema for ConstrainedString<MIN, MAX> {
301    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
302        SchemaObject::of_type("string")
303            .with("minLength", MIN as u64)
304            .with("maxLength", MAX as u64)
305    }
306}
307
308impl<const MIN: i64, const MAX: i64> Schema for BoundedI64<MIN, MAX> {
309    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
310        SchemaObject::of_type("integer")
311            .with("minimum", MIN)
312            .with("maximum", MAX)
313    }
314}
315
316impl Schema for PositiveInt {
317    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
318        SchemaObject::of_type("integer").with("exclusiveMinimum", 0_i64)
319    }
320}
321
322impl Schema for Email {
323    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
324        SchemaObject::of_type("string").with("format", "email")
325    }
326}
327
328impl Schema for SecretString {
329    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
330        SchemaObject::of_type("string").with("format", "password")
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    fn inline<T: Schema + ?Sized + 'static>() -> Value {
340        schema_for::<T>().0.into_value()
341    }
342
343    #[test]
344    fn primitive_schemas() {
345        assert_eq!(inline::<String>(), json!({"type": "string"}));
346        assert_eq!(inline::<bool>(), json!({"type": "boolean"}));
347        assert_eq!(inline::<serde_json::Value>(), json!({}));
348        assert_eq!(
349            inline::<usize>(),
350            json!({"type": "integer", "minimum": 0, "maximum": usize::MAX})
351        );
352        assert_eq!(inline::<u128>()["maximum"], json!(u64::MAX));
353        assert_eq!(
354            inline::<i32>(),
355            json!({"type": "integer", "format": "int32"})
356        );
357        assert_eq!(
358            inline::<u8>(),
359            json!({"type": "integer", "minimum": 0, "maximum": 255})
360        );
361        assert_eq!(
362            inline::<u32>(),
363            json!({"type": "integer", "minimum": 0, "maximum": 4294967295_u64})
364        );
365        assert_eq!(
366            inline::<f64>(),
367            json!({"type": "number", "format": "double"})
368        );
369    }
370
371    #[test]
372    fn containers() {
373        assert_eq!(
374            inline::<Option<String>>(),
375            json!({"anyOf": [{"type": "string"}, {"type": "null"}]})
376        );
377        assert_eq!(
378            inline::<Vec<bool>>(),
379            json!({"type": "array", "items": {"type": "boolean"}})
380        );
381    }
382
383    #[test]
384    fn constrained_type_keywords() {
385        assert_eq!(
386            inline::<ConstrainedString<1, 16>>(),
387            json!({"type": "string", "minLength": 1, "maxLength": 16})
388        );
389        assert_eq!(
390            inline::<BoundedI64<0, 100>>(),
391            json!({"type": "integer", "minimum": 0, "maximum": 100})
392        );
393        assert_eq!(
394            inline::<PositiveInt>(),
395            json!({"type": "integer", "exclusiveMinimum": 0})
396        );
397        assert_eq!(
398            inline::<Email>(),
399            json!({"type": "string", "format": "email"})
400        );
401        assert_eq!(
402            inline::<SecretString>(),
403            json!({"type": "string", "format": "password"})
404        );
405    }
406
407    /// A named, recursive type: `Node { children: Vec<Node> }`.
408    struct Node;
409    impl Schema for Node {
410        fn schema_name() -> Option<&'static str> {
411            Some("Node")
412        }
413        fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
414            SchemaObject::of_type("object").with(
415                "properties",
416                json!({ "children": registry.subschema::<Vec<Node>>() }),
417            )
418        }
419    }
420
421    struct OtherNode;
422    impl Schema for OtherNode {
423        fn schema_name() -> Option<&'static str> {
424            Some("Node")
425        }
426        fn schema(_: &mut SchemaRegistry) -> SchemaObject {
427            SchemaObject::of_type("string")
428        }
429    }
430
431    #[test]
432    fn named_types_are_referenced_once_and_recursion_terminates() {
433        let mut registry = SchemaRegistry::new();
434        let a = registry.subschema::<Node>();
435        let b = registry.subschema::<Vec<Node>>();
436        assert_eq!(a.into_value(), json!({"$ref": "#/components/schemas/Node"}));
437        assert_eq!(
438            b.into_value(),
439            json!({"type": "array", "items": {"$ref": "#/components/schemas/Node"}})
440        );
441        let components = registry.into_components().unwrap();
442        assert_eq!(components.len(), 1);
443        assert_eq!(
444            components["Node"].clone().into_value(),
445            json!({"type": "object", "properties": {"children": {
446                "type": "array", "items": {"$ref": "#/components/schemas/Node"}}}})
447        );
448    }
449
450    #[test]
451    fn name_conflicts_are_reported() {
452        let mut registry = SchemaRegistry::new();
453        registry.subschema::<Node>();
454        registry.subschema::<OtherNode>();
455        let err = registry.into_components().unwrap_err();
456        assert_eq!(err.len(), 1);
457        assert_eq!(err[0].name, "Node");
458    }
459}