Skip to main content

vld/openapi/
mod.rs

1//! JSON Schema / OpenAPI generation from `vld` schemas.
2//!
3//! The [`JsonSchema`] trait is implemented by schema types that can produce a
4//! [JSON Schema](https://json-schema.org/) representation. The output is
5//! compatible with **OpenAPI 3.1** (which uses JSON Schema directly).
6//!
7//! Nullable/optional values and unions are emitted as `anyOf` (not `oneOf`):
8//! `vld` unions are first-match, and an inner schema that already accepts
9//! `null` would make a `oneOf` with `{"type": "null"}` unsatisfiable.
10//!
11//! # Example
12//!
13//! ```
14//! use vld::prelude::*;
15//! use vld::openapi::JsonSchema;
16//!
17//! let schema = vld::string().min(2).max(50).email();
18//! let js = schema.json_schema();
19//! assert_eq!(js["type"], "string");
20//! assert_eq!(js["minLength"], 2);
21//! assert_eq!(js["format"], "email");
22//! ```
23
24use serde_json::{Value, json};
25
26#[cfg(feature = "diff")]
27#[cfg_attr(docsrs, doc(cfg(feature = "diff")))]
28pub mod diff;
29
30pub use crate::schema::NestedSchemaEntry;
31
32/// Trait for types that can produce a JSON Schema representation.
33///
34/// Implemented for all core `vld` schemas. The output is a `serde_json::Value`
35/// representing the JSON Schema object, compatible with OpenAPI 3.1.
36pub trait JsonSchema {
37    /// Generate a JSON Schema representation.
38    fn json_schema(&self) -> Value;
39
40    /// Whether this schema should be included in an object's `required` array.
41    ///
42    /// By default, fields are required. Schemas that accept a missing key
43    /// return `false`: `.optional()`, `.nullable()` (a missing key parses as
44    /// `None`), `.nullish()`, `.with_default(...)`, `.catch(...)`, and the
45    /// null-accepting `vld::any()`, `vld::null()` and an unconstrained
46    /// `vld::json_value()`.
47    fn is_required(&self) -> bool {
48        true
49    }
50}
51
52/// Collects [`NestedSchemaEntry`] pairs from nested schemas.
53///
54/// Used by `schema!` / `#[derive(Validate)]` to discover named nested types
55/// (`vld::nested!(T)`) that must be registered as OpenAPI components so the
56/// emitted `$ref`s resolve.
57pub trait CollectNestedSchemas {
58    /// Add the named schemas this schema references to `_out` (for `components.schemas`).
59    fn collect_nested_schemas(&self, _out: &mut Vec<NestedSchemaEntry>) {}
60}
61
62/// Hint for OpenAPI parameter location when a struct is used with `utoipa::IntoParams`.
63///
64/// Set via utoipa's `#[into_params(parameter_in = Query)]` attribute on a struct
65/// defined with [`schema!`](crate::schema!) or `#[derive(Validate)]`.
66pub trait OpenApiParameterIn {
67    /// Returns `"query"`, `"path"`, `"header"`, or `"cookie"` when the struct is
68    /// annotated with `#[into_params(parameter_in = ...)]`, otherwise `None`.
69    fn parameter_in() -> Option<&'static str> {
70        None
71    }
72}
73
74/// Wrap a schema so it also accepts `null`: `{"anyOf": [inner, {"type": "null"}]}`.
75///
76/// Returns `inner` unchanged if it is already such a nullable `anyOf`.
77pub fn nullable_schema(inner: Value) -> Value {
78    let already_nullable = inner
79        .get("anyOf")
80        .and_then(Value::as_array)
81        .is_some_and(|items| items.iter().any(|i| i.get("type") == Some(&json!("null"))));
82    if already_nullable {
83        return inner;
84    }
85    json!({ "anyOf": [inner, { "type": "null" }] })
86}
87
88// ---------------------------------------------------------------------------
89// Impl helpers for wrapper schemas (used next to each type)
90// ---------------------------------------------------------------------------
91
92/// `JsonSchema` + `CollectNestedSchemas` forwarding to `inner_schema()`.
93///
94/// `impl[<generics>] Type, inner = S, where [extra bounds];`
95macro_rules! forward_inner_schema {
96    ($( impl[$($g:tt)*] $t:ty, inner = $S:ident $(, where [$($w:tt)*])? ; )*) => {$(
97        impl<$($g)*> $crate::openapi::JsonSchema for $t
98        where
99            $S: $crate::schema::VldSchema + $crate::openapi::JsonSchema,
100            $($($w)*)?
101        {
102            fn json_schema(&self) -> serde_json::Value {
103                $crate::openapi::JsonSchema::json_schema(self.inner_schema())
104            }
105            fn is_required(&self) -> bool {
106                $crate::openapi::JsonSchema::is_required(self.inner_schema())
107            }
108        }
109
110        impl<$($g)*> $crate::openapi::CollectNestedSchemas for $t
111        where
112            $S: $crate::schema::VldSchema + $crate::openapi::CollectNestedSchemas,
113            $($($w)*)?
114        {
115            fn collect_nested_schemas(&self, out: &mut Vec<$crate::schema::NestedSchemaEntry>) {
116                $crate::openapi::CollectNestedSchemas::collect_nested_schemas(self.inner_schema(), out);
117            }
118        }
119    )*};
120}
121
122pub(crate) use forward_inner_schema;
123
124/// Collect-only forwarding for wrappers whose `JsonSchema` impl is custom.
125macro_rules! collect_inner_schemas {
126    ($( impl[$($g:tt)*] $t:ty, inner = $S:ident $(, where [$($w:tt)*])? ; )*) => {$(
127        impl<$($g)*> $crate::openapi::CollectNestedSchemas for $t
128        where
129            $S: $crate::schema::VldSchema + $crate::openapi::CollectNestedSchemas,
130            $($($w)*)?
131        {
132            fn collect_nested_schemas(&self, out: &mut Vec<$crate::schema::NestedSchemaEntry>) {
133                $crate::openapi::CollectNestedSchemas::collect_nested_schemas(self.inner_schema(), out);
134            }
135        }
136    )*};
137}
138pub(crate) use collect_inner_schemas;
139
140// ---------------------------------------------------------------------------
141// Helpers
142// ---------------------------------------------------------------------------
143
144/// Wrap a JSON Schema in a minimal OpenAPI 3.1 document structure.
145///
146/// The schema is placed under `#/components/schemas/{name}`. Nested `$ref`
147/// targets are **not** added; types generated by `schema!` /
148/// `#[derive(Validate)]` have a `to_openapi_document()` that includes them.
149///
150/// # Example
151///
152/// ```
153/// use vld::openapi::{JsonSchema, to_openapi_document};
154///
155/// let schema = vld::string().email();
156/// let doc = to_openapi_document("Email", &schema.json_schema());
157/// assert_eq!(doc["openapi"], "3.1.0");
158/// ```
159pub fn to_openapi_document(name: &str, schema: &Value) -> Value {
160    to_openapi_document_multi(&[(name, schema.clone())])
161}
162
163/// Wrap multiple schemas in an OpenAPI 3.1 document.
164///
165/// # Example
166///
167/// ```
168/// use vld::openapi::{JsonSchema, to_openapi_document_multi};
169///
170/// let schemas = vec![
171///     ("Name", vld::string().min(1).json_schema()),
172///     ("Age", vld::number().int().min(0).json_schema()),
173/// ];
174/// let doc = to_openapi_document_multi(&schemas);
175/// assert!(doc["components"]["schemas"]["Name"]["type"] == "string");
176/// ```
177pub fn to_openapi_document_multi(schemas: &[(&str, Value)]) -> Value {
178    let map: serde_json::Map<String, Value> = schemas
179        .iter()
180        .map(|(name, schema)| (name.to_string(), schema.clone()))
181        .collect();
182    json!({
183        "openapi": "3.1.0",
184        "info": { "title": "API", "version": "1.0.0" },
185        "paths": {},
186        "components": { "schemas": Value::Object(map) }
187    })
188}