Skip to main content

vld_utoipa/
lib.rs

1//! # vld-utoipa — Bridge between `vld` and `utoipa`
2//!
3//! This crate lets you use `vld` validation schemas as the **single source of truth**
4//! for both runtime validation and OpenAPI documentation generated by
5//! [utoipa](https://docs.rs/utoipa).
6//!
7//! Instead of duplicating schema definitions with `#[derive(ToSchema)]` **and**
8//! `vld::schema!`, you define validation rules once in `vld` and get `utoipa`
9//! compatibility for free.
10//!
11//! # Quick Start
12//!
13//! ```rust
14//! use vld::prelude::*;
15//! use vld_utoipa::impl_to_schema;
16//!
17//! // 1. Define your validated struct as usual
18//! vld::schema! {
19//!     #[derive(Debug)]
20//!     pub struct User {
21//!         pub name: String => vld::string().min(2).max(50),
22//!         pub email: String => vld::string().email(),
23//!     }
24//! }
25//!
26//! // 2. Bridge to utoipa — one line
27//! impl_to_schema!(User);
28//!
29//! // Now `User` implements `utoipa::ToSchema` and can be used in
30//! // `#[utoipa::path]` annotations.
31//! ```
32//!
33//! # Converting arbitrary JSON Schema
34//!
35//! ```rust
36//! use vld_utoipa::json_schema_to_schema;
37//!
38//! let json_schema = serde_json::json!({
39//!     "type": "object",
40//!     "required": ["name"],
41//!     "properties": {
42//!         "name": { "type": "string", "minLength": 1 }
43//!     }
44//! });
45//!
46//! let utoipa_schema = json_schema_to_schema(&json_schema);
47//! // Returns `utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>`
48//! ```
49
50use serde_json::Value;
51use utoipa::openapi::schema::{
52    AllOf, Array, Object, OneOf, Schema, SchemaFormat, SchemaType, Type,
53};
54use utoipa::openapi::{Ref, RefOr};
55
56/// Convert a `serde_json::Value` (JSON Schema) produced by `vld` into a
57/// `utoipa::openapi::RefOr<Schema>`.
58///
59/// This is the core conversion function. It handles:
60/// - Primitive types: `string`, `number`, `integer`, `boolean`, `null`
61/// - Objects with `properties` and `required`
62/// - Arrays with `items`
63/// - `oneOf`, `allOf` composites
64/// - `enum` values
65/// - Numeric constraints: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
66/// - String constraints: `minLength`, `maxLength`, `pattern`
67/// - Array constraints: `minItems`, `maxItems`
68/// - `format`, `description`, `default`, `example`
69/// - `$ref` references
70///
71/// # Example
72///
73/// ```rust
74/// use vld_utoipa::json_schema_to_schema;
75///
76/// let schema = json_schema_to_schema(&serde_json::json!({"type": "string", "format": "email"}));
77/// ```
78pub fn json_schema_to_schema(value: &Value) -> RefOr<Schema> {
79    match value {
80        Value::Object(map) => {
81            // Handle $ref
82            if let Some(Value::String(ref_path)) = map.get("$ref") {
83                return RefOr::Ref(Ref::new(ref_path.clone()));
84            }
85
86            // Handle oneOf
87            if let Some(Value::Array(items)) = map.get("oneOf") {
88                let schemas: Vec<RefOr<Schema>> = items.iter().map(json_schema_to_schema).collect();
89                let mut one_of = OneOf::new();
90                one_of.items = schemas;
91                return RefOr::T(Schema::OneOf(one_of));
92            }
93
94            // Handle allOf
95            if let Some(Value::Array(items)) = map.get("allOf") {
96                let schemas: Vec<RefOr<Schema>> = items.iter().map(json_schema_to_schema).collect();
97                let mut all_of = AllOf::new();
98                all_of.items = schemas;
99                return RefOr::T(Schema::AllOf(all_of));
100            }
101
102            // Determine type
103            let type_str = map.get("type").and_then(|v| v.as_str()).unwrap_or("object");
104
105            match type_str {
106                "array" => {
107                    let items_schema = map
108                        .get("items")
109                        .map(json_schema_to_schema)
110                        .unwrap_or_else(|| RefOr::T(Schema::Object(Object::default())));
111
112                    let mut arr = Array::new(items_schema);
113
114                    if let Some(n) = map.get("minItems").and_then(|v| v.as_u64()) {
115                        arr.min_items = Some(n as usize);
116                    }
117                    if let Some(n) = map.get("maxItems").and_then(|v| v.as_u64()) {
118                        arr.max_items = Some(n as usize);
119                    }
120
121                    RefOr::T(Schema::Array(arr))
122                }
123                "object" => convert_object(map),
124                _ => convert_primitive(map, type_str),
125            }
126        }
127        // Bare true/false schema
128        Value::Bool(true) => RefOr::T(Schema::Object(Object::new())),
129        _ => RefOr::T(Schema::Object(Object::default())),
130    }
131}
132
133fn convert_object(map: &serde_json::Map<String, Value>) -> RefOr<Schema> {
134    let mut obj = Object::with_type(SchemaType::Type(Type::Object));
135
136    // Properties
137    if let Some(Value::Object(props)) = map.get("properties") {
138        for (key, val) in props {
139            obj.properties
140                .insert(key.clone(), json_schema_to_schema(val));
141        }
142    }
143
144    // Required
145    if let Some(Value::Array(req)) = map.get("required") {
146        obj.required = req
147            .iter()
148            .filter_map(|v| v.as_str().map(String::from))
149            .collect();
150    }
151
152    // Description
153    if let Some(Value::String(desc)) = map.get("description") {
154        obj.description = Some(desc.clone());
155    }
156
157    // Title
158    if let Some(Value::String(title)) = map.get("title") {
159        obj.title = Some(title.clone());
160    }
161
162    // Default
163    if let Some(default) = map.get("default") {
164        obj.default = Some(default.clone());
165    }
166
167    // Example
168    if let Some(example) = map.get("example") {
169        obj.example = Some(example.clone());
170    }
171
172    RefOr::T(Schema::Object(obj))
173}
174
175fn to_number(n: f64) -> utoipa::Number {
176    if n.fract() == 0.0 {
177        utoipa::Number::Int(n as isize)
178    } else {
179        utoipa::Number::Float(n)
180    }
181}
182
183fn convert_primitive(map: &serde_json::Map<String, Value>, type_str: &str) -> RefOr<Schema> {
184    let schema_type = match type_str {
185        "string" => SchemaType::Type(Type::String),
186        "number" => SchemaType::Type(Type::Number),
187        "integer" => SchemaType::Type(Type::Integer),
188        "boolean" => SchemaType::Type(Type::Boolean),
189        "null" => SchemaType::Type(Type::Null),
190        _ => SchemaType::Type(Type::Object),
191    };
192
193    let mut obj = Object::with_type(schema_type);
194
195    // Format
196    if let Some(Value::String(fmt)) = map.get("format") {
197        obj.format = Some(SchemaFormat::Custom(fmt.clone()));
198    }
199
200    // Description
201    if let Some(Value::String(desc)) = map.get("description") {
202        obj.description = Some(desc.clone());
203    }
204
205    // Title
206    if let Some(Value::String(title)) = map.get("title") {
207        obj.title = Some(title.clone());
208    }
209
210    // Default
211    if let Some(default) = map.get("default") {
212        obj.default = Some(default.clone());
213    }
214
215    // Example
216    if let Some(example) = map.get("example") {
217        obj.example = Some(example.clone());
218    }
219
220    // Enum values
221    if let Some(Value::Array(vals)) = map.get("enum") {
222        obj.enum_values = Some(vals.clone());
223    }
224
225    // Pattern (string)
226    if let Some(Value::String(pat)) = map.get("pattern") {
227        obj.pattern = Some(pat.clone());
228    }
229
230    // String constraints
231    if let Some(n) = map.get("minLength").and_then(|v| v.as_u64()) {
232        obj.min_length = Some(n as usize);
233    }
234    if let Some(n) = map.get("maxLength").and_then(|v| v.as_u64()) {
235        obj.max_length = Some(n as usize);
236    }
237
238    // Numeric constraints
239    if let Some(n) = map.get("minimum").and_then(|v| v.as_f64()) {
240        obj.minimum = Some(to_number(n));
241    }
242    if let Some(n) = map.get("maximum").and_then(|v| v.as_f64()) {
243        obj.maximum = Some(to_number(n));
244    }
245    if let Some(n) = map.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
246        obj.exclusive_minimum = Some(to_number(n));
247    }
248    if let Some(n) = map.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
249        obj.exclusive_maximum = Some(to_number(n));
250    }
251    if let Some(n) = map.get("multipleOf").and_then(|v| v.as_f64()) {
252        obj.multiple_of = Some(to_number(n));
253    }
254
255    RefOr::T(Schema::Object(obj))
256}
257
258/// Implement `utoipa::PartialSchema` and `utoipa::ToSchema` for a type that
259/// has a `json_schema()` associated function (generated by `vld::schema!` with
260/// the `openapi` feature enabled).
261///
262/// Nested schemas (created via `vld::nested!`) are **automatically registered**
263/// in utoipa's component schemas — no need to list them manually in
264/// `#[openapi(components(schemas(...)))]`.
265///
266/// # Usage
267///
268/// ```rust
269/// use vld::prelude::*;
270/// use vld_utoipa::impl_to_schema;
271///
272/// vld::schema! {
273///     #[derive(Debug)]
274///     pub struct CreateUser {
275///         pub name: String => vld::string().min(2).max(100),
276///         pub email: String => vld::string().email(),
277///     }
278/// }
279///
280/// impl_to_schema!(CreateUser);
281///
282/// // Now you can use CreateUser in utoipa annotations:
283/// // #[utoipa::path(post, path = "/users", request_body = CreateUser)]
284/// ```
285///
286/// You can also pass a custom schema name:
287///
288/// ```rust
289/// # use vld::prelude::*;
290/// # use vld_utoipa::impl_to_schema;
291/// # vld::schema! {
292/// #     #[derive(Debug)]
293/// #     pub struct Req { pub x: String => vld::string() }
294/// # }
295/// impl_to_schema!(Req, "CreateUserRequest");
296/// ```
297#[macro_export]
298macro_rules! impl_to_schema {
299    ($ty:ty) => {
300        impl $crate::utoipa::PartialSchema for $ty {
301            fn schema() -> $crate::utoipa::openapi::RefOr<$crate::utoipa::openapi::schema::Schema> {
302                $crate::json_schema_to_schema(&<$ty>::json_schema())
303            }
304        }
305
306        impl $crate::utoipa::ToSchema for $ty {
307            fn schemas(
308                schemas: &mut ::std::vec::Vec<(
309                    ::std::string::String,
310                    $crate::utoipa::openapi::RefOr<$crate::utoipa::openapi::schema::Schema>,
311                )>,
312            ) {
313                #[allow(unused_imports)]
314                use $crate::__VldNestedSchemasFallback as _;
315                for (name, schema_fn) in <$ty>::__vld_nested_schemas() {
316                    let json = schema_fn();
317                    schemas.push((name.to_string(), $crate::json_schema_to_schema(&json)));
318                }
319            }
320        }
321    };
322    ($ty:ty, $name:expr) => {
323        impl $crate::utoipa::PartialSchema for $ty {
324            fn schema() -> $crate::utoipa::openapi::RefOr<$crate::utoipa::openapi::schema::Schema> {
325                $crate::json_schema_to_schema(&<$ty>::json_schema())
326            }
327        }
328
329        impl $crate::utoipa::ToSchema for $ty {
330            fn name() -> ::std::borrow::Cow<'static, str> {
331                ::std::borrow::Cow::Borrowed($name)
332            }
333
334            fn schemas(
335                schemas: &mut ::std::vec::Vec<(
336                    ::std::string::String,
337                    $crate::utoipa::openapi::RefOr<$crate::utoipa::openapi::schema::Schema>,
338                )>,
339            ) {
340                #[allow(unused_imports)]
341                use $crate::__VldNestedSchemasFallback as _;
342                for (name, schema_fn) in <$ty>::__vld_nested_schemas() {
343                    let json = schema_fn();
344                    schemas.push((name.to_string(), $crate::json_schema_to_schema(&json)));
345                }
346            }
347        }
348    };
349}
350
351/// Re-export utoipa for use in macros
352pub use utoipa;
353
354#[doc(hidden)]
355pub trait __VldNestedSchemasFallback {
356    #[allow(clippy::type_complexity)]
357    fn __vld_nested_schemas() -> Vec<(&'static str, fn() -> serde_json::Value)> {
358        Vec::new()
359    }
360}
361
362impl<T: ?Sized> __VldNestedSchemasFallback for T {}
363
364/// Prelude module — import everything you need.
365pub mod prelude {
366    pub use crate::impl_to_schema;
367    pub use crate::json_schema_to_schema;
368    pub use vld::prelude::*;
369}