schemars_zod/
lib.rs

1//! convert JsonSchema to ZOD schema
2use std::collections::{BTreeMap, HashSet};
3
4use schemars::schema::{
5    ArrayValidation, InstanceType, ObjectValidation, RootSchema, Schema, SchemaObject, SingleOrVec,
6};
7
8/// Merge multiple [schemars::schema::RootSchema] into a single [schemars::schema::RootSchema].
9///
10/// Schemars macro [schemars::schema_for!] will always generate a RootSchema. This works if each
11/// type is independently processed. In ZOD schemas however, it's a common practice to define all
12/// types in a single file, and then import them as needed. This function will merge multiple
13/// results of the `schema_for!` macro into a single RootSchema, simplifying multiple definitions
14/// of the same schema.
15///
16/// # Arguments
17///
18/// * `schemas`: An iterator of RootSchema's to merge. BYOI (Bring Your Own Iterator)
19///
20/// returns: RootSchema - A single RootSchema containing all definitions from the input schemas.
21///
22/// # Examples
23///
24/// ```
25/// use schemars::schema_for;
26/// use schemars_zod::merge_schemas;
27///
28/// #[derive(schemars::JsonSchema)]
29/// struct MyStruct {/* ... */}
30///
31///#[derive(schemars::JsonSchema)]
32/// struct MyOtherStruct {/* ... */}
33/// let merged = merge_schemas(vec![schema_for!(MyStruct), schema_for!(MyOtherStruct)].into_iter());
34/// ```
35pub fn merge_schemas(schemas: impl Iterator<Item = RootSchema>) -> RootSchema {
36    let mut merged = RootSchema::default();
37    for schema in schemas {
38        for (id, definition) in schema.definitions {
39            merged.definitions.insert(id, definition);
40        }
41
42        let Some(id) = schema.schema.metadata.as_ref().and_then(|m| m.title.as_ref())
43        else { continue; };
44
45        merged
46            .definitions
47            .insert(id.to_owned(), Schema::Object(schema.schema));
48    }
49
50    merged
51}
52
53/// Convert a [schemars::schema::RootSchema] to a HashMap of stringified ZOD schemas.
54///
55/// Only definitions inside the RootSchema will be converted, the root schema itself will be ignored.
56///
57/// # Arguments
58///
59/// * `schema`: Schema to convert
60///
61/// returns: BTreeMap<String, String> - A BTreeMap of stringified ZOD schemas, keyed by the definition name.
62///
63/// # Examples
64///
65/// ```
66/// use schemars::schema_for;
67/// use schemars_zod::{convert, merge_schemas};
68///
69/// #[derive(schemars::JsonSchema)]
70/// struct MyStruct {/* ... */}
71///
72///#[derive(schemars::JsonSchema)]
73/// struct MyOtherStruct {/* ... */}
74///
75/// let converted = convert(merge_schemas(vec![schema_for!(MyStruct), schema_for!(MyOtherStruct)].into_iter()));
76/// ```
77pub fn convert(schema: RootSchema) -> BTreeMap<String, String> {
78    let mut definitions = BTreeMap::new();
79
80    for (id, definition) in schema.definitions {
81        if let Some(definition) = add_converted_schema(&id, definition.into_object()) {
82            definitions.insert(id, definition);
83        }
84    }
85
86    definitions
87}
88
89fn add_converted_schema(id: &str, schema: SchemaObject) -> Option<String> {
90    let mut rv = String::new();
91
92    let Some(generated) = convert_schema_object_to_zod(schema) else { panic!("could not generate {id}"); };
93
94    rv.push_str(&format!("export const {id} = memoizeOne(() => {generated});\n"));
95    rv.push_str(&format!("export type {id} = z.infer<ReturnType<typeof {id}>>;\n"));
96
97    Some(rv)
98}
99
100fn convert_schema_object_to_zod(schema: SchemaObject) -> Option<String> {
101    // handle references
102    if let Some(reference) = schema.reference.as_ref() {
103        let reference = reference.replace("#/definitions/", "");
104        return Some(format!("z.lazy({reference})"));
105    }
106
107    // handle ordinary value disjoint unions / enums
108    if let Some(enum_values) = schema.enum_values.as_ref() {
109        if enum_values.len() == 1 {
110            return Some(format!(
111                "z.literal({})",
112                serde_json::to_string_pretty(enum_values.first().unwrap()).unwrap()
113            ));
114        }
115
116        let mut rv = String::new();
117        rv.push_str("z.enum([");
118        for value in enum_values {
119            rv.push_str(&format!(
120                "{}, ",
121                serde_json::to_string_pretty(&value).unwrap()
122            ));
123        }
124        rv.push_str("])");
125
126        return Some(rv);
127    }
128
129    // handle tagged / untagged unions
130    let one_of = schema.subschemas.as_ref().and_then(|x| x.one_of.as_ref());
131    let any_of = schema.subschemas.as_ref().and_then(|x| x.any_of.as_ref());
132    let all_of = schema.subschemas.as_ref().and_then(|x| x.all_of.as_ref());
133
134    if let Some(one_of) = one_of.or(any_of).or(all_of) {
135        if one_of.len() == 1 {
136            return convert_schema_object_to_zod(one_of.first().unwrap().clone().into_object());
137        }
138
139        let mut rv = if let Some(field) = all_schemas_share_a_field(one_of) {
140            format!("z.discriminatedUnion('{field}', [")
141        } else {
142            format!("z.union([")
143        };
144
145        for schema in one_of {
146            let Some(generated) = convert_schema_object_to_zod(schema.clone().into_object()) else { continue; };
147            rv.push_str(&format!("{generated}, "));
148        }
149
150        rv.push_str("])");
151        return Some(rv);
152    }
153
154    let Some(instance_type) = schema.instance_type.as_ref() else {
155        // eprintln!("problematic schema {schema:#?}");
156        return Some("z.any()".to_string());
157    };
158
159    convert_schema_type_to_zod(instance_type, &schema)
160}
161
162fn all_schemas_share_a_field(any_of: &[Schema]) -> Option<String> {
163    let mut results = Vec::<HashSet<String>>::new();
164    for schema in any_of {
165        let schema = schema.clone().into_object();
166        if schema.instance_type.as_ref()
167            == Some(&SingleOrVec::Single(Box::new(InstanceType::Object)))
168        {
169            results.push(schema.object.unwrap().properties.keys().cloned().collect());
170        } else {
171            results.push(HashSet::default());
172        }
173    }
174
175    results.first().and_then(|first_props| {
176        let found = first_props
177            .iter()
178            .filter(|prop_name| {
179                results
180                    .iter()
181                    .skip(1)
182                    .all(|props| props.contains(*prop_name))
183            })
184            .cloned()
185            .collect::<HashSet<_>>();
186
187        if found.contains("type") {
188            Some("type".to_owned())
189        } else if found.contains("kind") {
190            Some("kind".to_owned())
191        } else {
192            found.iter().next().map(|x| x.to_owned())
193        }
194    })
195}
196
197fn convert_schema_type_to_zod(
198    instance_type: &SingleOrVec<InstanceType>,
199    schema: &SchemaObject,
200) -> Option<String> {
201    match instance_type {
202        SingleOrVec::Single(single_type) => {
203            convert_single_instance_type_schema_to_zod(single_type, &schema)
204        }
205        SingleOrVec::Vec(multiple_types) => {
206            convert_union_type_schema_to_zod(multiple_types, &schema)
207        }
208    }
209}
210
211fn convert_single_instance_type_schema_to_zod(
212    instance_type: &Box<InstanceType>,
213    schema: &SchemaObject,
214) -> Option<String> {
215    if let Some(literal_value) = schema.const_value.as_ref() {
216        return Some(format!(
217            "z.literal({})",
218            serde_json::to_string_pretty(literal_value).unwrap()
219        ));
220    }
221
222    match instance_type.as_ref() {
223        InstanceType::Null => Some(format!("z.null()")),
224        InstanceType::Boolean => Some(format!("z.boolean()")),
225        InstanceType::Object => convert_object_type_to_zod(schema.object.as_ref().unwrap(), schema),
226        InstanceType::Array => convert_array_type_to_zod(schema.array.as_ref().unwrap(), schema),
227        InstanceType::Number => Some(format!("z.number()")),
228        InstanceType::String => {
229            if matches!(schema.format.as_ref(), Some(format) if format == "date-time") {
230                return Some(format!("z.coerce.date()"));
231            }
232            Some(format!("z.string()"))
233        }
234        InstanceType::Integer => Some(format!("z.number().int()")),
235    }
236}
237
238fn convert_array_type_to_zod(
239    array_type: &Box<ArrayValidation>,
240    _schema: &SchemaObject,
241) -> Option<String> {
242    let Some(items) = array_type.items.as_ref() else { return None; };
243
244    if array_type.min_items.is_some() && array_type.min_items == array_type.max_items {
245        convert_schema_or_ref_to_zod(items, "tuple")
246    } else {
247        let mut rv = String::new();
248        rv.push_str("z.array(");
249        let Some(generated) = convert_schema_or_ref_to_zod(items, "union") else { return None; };
250        rv.push_str(&format!("{generated})"));
251        Some(rv)
252    }
253}
254
255fn convert_schema_or_ref_to_zod(schema: &SingleOrVec<Schema>, zod_mode: &str) -> Option<String> {
256    match schema {
257        SingleOrVec::Single(schema_or_ref) => {
258            convert_schema_object_to_zod(schema_or_ref.clone().into_object())
259        }
260        SingleOrVec::Vec(schemas) => {
261            if schemas.len() == 1 {
262                return convert_schema_object_to_zod(
263                    schemas.first().unwrap().clone().into_object(),
264                );
265            }
266
267            let mut rv = String::new();
268            rv.push_str(&format!("z.{zod_mode}(["));
269            for schema in schemas {
270                if let Some(schema) = convert_schema_object_to_zod(schema.clone().into_object()) {
271                    rv.push_str(&format!("{schema}, ",));
272                }
273            }
274            rv.push_str("])");
275            Some(rv)
276        }
277    }
278}
279
280fn convert_object_type_to_zod(
281    object_type: &Box<ObjectValidation>,
282    _schema: &SchemaObject,
283) -> Option<String> {
284    let mut rv = String::new();
285
286    // are we additional objects and no properties? if so, we are a record
287    if object_type.additional_properties.is_some() && object_type.properties.is_empty() {
288        let Some(additional_properties) = object_type.additional_properties.as_ref() else { return None; };
289        let Some(additional_properties) = convert_schema_object_to_zod(additional_properties.clone().into_object()) else { return None; };
290        return Some(format!("z.record({additional_properties})"));
291    }
292
293    rv.push_str("z.object({");
294
295    for (property_name, property) in &object_type.properties {
296        let Some(property_type) = convert_schema_object_to_zod(property.clone().into_object()) else { return None; };
297        rv.push_str(&format!("{property_name}: {property_type}, ",));
298    }
299
300    rv.push_str("})");
301
302    Some(rv)
303}
304
305fn convert_union_type_schema_to_zod(
306    instance_types: &Vec<InstanceType>,
307    schema: &SchemaObject,
308) -> Option<String> {
309    let mut rv = String::new();
310
311    rv.push_str("z.union([");
312    for instance_type in instance_types {
313        let Some(generated) = convert_single_instance_type_schema_to_zod(&Box::new(instance_type.clone()), schema) else { return None; };
314        rv.push_str(&format!("{generated}, "));
315    }
316
317    rv.push_str("])");
318
319    Some(rv)
320}