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}