Skip to main content

oa_forge_emitter_valibot/
lib.rs

1use std::fmt::Write;
2
3use oa_forge_ir::*;
4
5/// Emit Valibot schemas from the API spec.
6pub fn emit(api: &ApiSpec, out: &mut String) -> Result<(), std::fmt::Error> {
7    writeln!(out, "// Generated by oa-forge. Do not edit.")?;
8
9    let mut used = UsedFunctions::default();
10    let mut body = String::new();
11
12    for typedef in api.types.values() {
13        emit_schema_def(typedef, &mut body, &mut used)?;
14        writeln!(body)?;
15    }
16
17    for endpoint in &api.endpoints {
18        emit_endpoint_schemas(endpoint, &mut body, &mut used)?;
19    }
20
21    let imports: Vec<&str> = used.collect();
22    writeln!(
23        out,
24        "import {{ type InferOutput, {} }} from 'valibot';",
25        imports.join(", ")
26    )?;
27    writeln!(out)?;
28
29    out.push_str(&body);
30    Ok(())
31}
32
33#[derive(Default)]
34struct UsedFunctions {
35    fns: std::collections::BTreeSet<&'static str>,
36}
37
38impl UsedFunctions {
39    fn mark(&mut self, name: &'static str) {
40        self.fns.insert(name);
41    }
42
43    fn collect(&self) -> Vec<&str> {
44        self.fns.iter().copied().collect()
45    }
46}
47
48fn emit_schema_def(
49    typedef: &TypeDef,
50    out: &mut String,
51    used: &mut UsedFunctions,
52) -> Result<(), std::fmt::Error> {
53    let base = type_repr_to_valibot(&typedef.repr, used);
54    let with_format = append_format(&base, typedef.format.as_deref(), used);
55    let schema = append_constraints(&with_format, &typedef.constraints, used);
56
57    match &typedef.repr {
58        TypeRepr::Ref { name } => {
59            used.mark("lazy");
60            writeln!(out, "export const {name}Schema = lazy(() => {name}Schema);")?;
61        }
62        _ => {
63            writeln!(out, "export const {}Schema = {schema};", typedef.name)?;
64        }
65    }
66
67    writeln!(
68        out,
69        "export type {} = InferOutput<typeof {}Schema>;",
70        typedef.name, typedef.name
71    )?;
72
73    Ok(())
74}
75
76/// Emit a Valibot object schema for params at the given location.
77/// When `use_optional` is true, non-required params get `optional()` wrapping.
78fn emit_params_schema(
79    id: &str,
80    suffix: &str,
81    location: ParamLocation,
82    use_optional: bool,
83    endpoint: &Endpoint,
84    out: &mut String,
85    used: &mut UsedFunctions,
86) -> Result<(), std::fmt::Error> {
87    let params: Vec<&EndpointParam> = endpoint
88        .parameters
89        .iter()
90        .filter(|p| p.location == location)
91        .collect();
92
93    if params.is_empty() {
94        return Ok(());
95    }
96
97    used.mark("object");
98    write!(out, "export const {id}{suffix}Schema = object({{")?;
99    for p in &params {
100        let schema = type_repr_to_valibot(&p.repr, used);
101        if use_optional && !p.required {
102            used.mark("optional");
103            write!(out, " {}: optional({schema}),", p.name)?;
104        } else {
105            write!(out, " {}: {schema},", p.name)?;
106        }
107    }
108    writeln!(out, " }});")?;
109    writeln!(out)?;
110    Ok(())
111}
112
113fn emit_endpoint_schemas(
114    endpoint: &Endpoint,
115    out: &mut String,
116    used: &mut UsedFunctions,
117) -> Result<(), std::fmt::Error> {
118    let id = &endpoint.operation_id;
119
120    emit_params_schema(
121        id,
122        "PathParams",
123        ParamLocation::Path,
124        false,
125        endpoint,
126        out,
127        used,
128    )?;
129    emit_params_schema(
130        id,
131        "QueryParams",
132        ParamLocation::Query,
133        true,
134        endpoint,
135        out,
136        used,
137    )?;
138    emit_params_schema(
139        id,
140        "HeaderParams",
141        ParamLocation::Header,
142        true,
143        endpoint,
144        out,
145        used,
146    )?;
147    emit_params_schema(
148        id,
149        "CookieParams",
150        ParamLocation::Cookie,
151        true,
152        endpoint,
153        out,
154        used,
155    )?;
156
157    if let Some(response) = &endpoint.response {
158        let schema = type_repr_to_valibot(response, used);
159        writeln!(out, "export const {id}ResponseSchema = {schema};")?;
160        writeln!(out)?;
161    }
162
163    if let Some(body) = &endpoint.request_body {
164        let schema = if endpoint.method == HttpMethod::Patch {
165            match body {
166                TypeRepr::Ref { name } => {
167                    used.mark("lazy");
168                    used.mark("partial");
169                    format!("partial(lazy(() => {name}Schema))")
170                }
171                other => type_repr_to_valibot(other, used),
172            }
173        } else {
174            type_repr_to_valibot(body, used)
175        };
176        writeln!(out, "export const {id}BodySchema = {schema};")?;
177        writeln!(out)?;
178    }
179
180    if let Some(error) = &endpoint.error_response {
181        let schema = type_repr_to_valibot(error, used);
182        writeln!(out, "export const {id}ErrorSchema = {schema};")?;
183        writeln!(out)?;
184    }
185
186    Ok(())
187}
188
189fn type_repr_to_valibot(repr: &TypeRepr, used: &mut UsedFunctions) -> String {
190    match repr {
191        TypeRepr::Primitive(p) => match p {
192            PrimitiveType::String => {
193                used.mark("string");
194                "string()".to_string()
195            }
196            PrimitiveType::Number | PrimitiveType::Integer => {
197                used.mark("number");
198                "number()".to_string()
199            }
200            PrimitiveType::Boolean => {
201                used.mark("boolean");
202                "boolean()".to_string()
203            }
204        },
205        TypeRepr::Object { properties } => {
206            if properties.is_empty() {
207                used.mark("record");
208                used.mark("string");
209                used.mark("unknown");
210                return "record(string(), unknown())".to_string();
211            }
212            used.mark("object");
213            let fields: Vec<String> = properties
214                .values()
215                .map(|p| {
216                    let base = type_repr_to_valibot(&p.repr, used);
217                    let schema = append_constraints(&base, &p.constraints, used);
218                    if p.required {
219                        format!("{}: {schema}", p.name)
220                    } else {
221                        used.mark("optional");
222                        format!("{}: optional({schema})", p.name)
223                    }
224                })
225                .collect();
226            format!("object({{ {} }})", fields.join(", "))
227        }
228        TypeRepr::Array { items } => {
229            used.mark("array");
230            format!("array({})", type_repr_to_valibot(items, used))
231        }
232        TypeRepr::Union { variants, .. } => {
233            if variants.len() == 1 {
234                return type_repr_to_valibot(&variants[0], used);
235            }
236            used.mark("union");
237            let schemas: Vec<String> = variants
238                .iter()
239                .map(|v| type_repr_to_valibot(v, used))
240                .collect();
241            format!("union([{}])", schemas.join(", "))
242        }
243        TypeRepr::Ref { name } => {
244            used.mark("lazy");
245            format!("lazy(() => {name}Schema)")
246        }
247        TypeRepr::Enum { values } => {
248            let all_strings = values.iter().all(|v| matches!(v, EnumValue::String(_)));
249            if all_strings && !values.is_empty() {
250                used.mark("enum_");
251                let vals: Vec<String> = values
252                    .iter()
253                    .map(|v| match v {
254                        EnumValue::String(s) => format!("'{s}'"),
255                        EnumValue::Integer(n) => n.to_string(),
256                    })
257                    .collect();
258                format!("enum_([{}])", vals.join(", "))
259            } else {
260                used.mark("union");
261                used.mark("literal");
262                let literals: Vec<String> = values
263                    .iter()
264                    .map(|v| match v {
265                        EnumValue::String(s) => format!("literal('{s}')"),
266                        EnumValue::Integer(n) => format!("literal({n})"),
267                    })
268                    .collect();
269                format!("union([{}])", literals.join(", "))
270            }
271        }
272        TypeRepr::Tuple { items } => {
273            used.mark("tuple");
274            let parts: Vec<String> = items
275                .iter()
276                .map(|i| type_repr_to_valibot(i, used))
277                .collect();
278            format!("tuple([{}])", parts.join(", "))
279        }
280        TypeRepr::Nullable(inner) => {
281            used.mark("nullable");
282            format!("nullable({})", type_repr_to_valibot(inner, used))
283        }
284        TypeRepr::Map { value } => {
285            used.mark("record");
286            used.mark("string");
287            format!("record(string(), {})", type_repr_to_valibot(value, used))
288        }
289        TypeRepr::Intersection { members } => {
290            used.mark("intersect");
291            let parts: Vec<String> = members
292                .iter()
293                .map(|m| type_repr_to_valibot(m, used))
294                .collect();
295            format!("intersect([{}])", parts.join(", "))
296        }
297        TypeRepr::Any => {
298            used.mark("unknown");
299            "unknown()".to_string()
300        }
301    }
302}
303
304/// Map OpenAPI `format` to Valibot validators via pipe().
305fn append_format(schema: &str, format: Option<&str>, used: &mut UsedFunctions) -> String {
306    let Some(fmt) = format else {
307        return schema.to_string();
308    };
309    // Only append format validators to string() schemas
310    if !schema.starts_with("string()") {
311        return schema.to_string();
312    }
313    let (import, action) = match fmt {
314        "email" => ("email", "email()"),
315        "uri" | "url" => ("url", "url()"),
316        "uuid" => ("uuid", "uuid()"),
317        "date-time" => ("isoDateTime", "isoDateTime()"),
318        "date" => ("isoDate", "isoDate()"),
319        "ip" | "ipv4" => ("ip", "ip()"),
320        _ => return schema.to_string(),
321    };
322    used.mark("pipe");
323    used.mark(import);
324    format!("pipe({schema}, {action})")
325}
326
327/// Valibot v1 uses pipe() for constraints: `pipe(string(), minLength(3), maxLength(50))`
328fn append_constraints(schema: &str, c: &Constraints, used: &mut UsedFunctions) -> String {
329    let mut actions = Vec::new();
330
331    if let Some(v) = c.min_length {
332        used.mark("minLength");
333        actions.push(format!("minLength({v})"));
334    }
335    if let Some(v) = c.max_length {
336        used.mark("maxLength");
337        actions.push(format!("maxLength({v})"));
338    }
339    if let Some(v) = &c.pattern {
340        used.mark("regex");
341        actions.push(format!("regex(/{v}/)"));
342    }
343    if let Some(v) = c.minimum {
344        used.mark("minValue");
345        actions.push(format!("minValue({v})"));
346    }
347    if let Some(v) = c.maximum {
348        used.mark("maxValue");
349        actions.push(format!("maxValue({v})"));
350    }
351    if let Some(v) = c.exclusive_minimum {
352        used.mark("gtValue");
353        actions.push(format!("gtValue({v})"));
354    }
355    if let Some(v) = c.exclusive_maximum {
356        used.mark("ltValue");
357        actions.push(format!("ltValue({v})"));
358    }
359    if let Some(v) = c.multiple_of {
360        used.mark("multipleOf");
361        actions.push(format!("multipleOf({v})"));
362    }
363    if let Some(v) = c.min_items {
364        used.mark("minLength");
365        actions.push(format!("minLength({v})"));
366    }
367    if let Some(v) = c.max_items {
368        used.mark("maxLength");
369        actions.push(format!("maxLength({v})"));
370    }
371
372    if actions.is_empty() {
373        schema.to_string()
374    } else {
375        used.mark("pipe");
376        format!("pipe({schema}, {})", actions.join(", "))
377    }
378}