oa_forge_emitter_valibot/
lib.rs1use std::fmt::Write;
2
3use oa_forge_ir::*;
4
5pub 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
76fn 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 ¶ms {
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
304fn append_format(schema: &str, format: Option<&str>, used: &mut UsedFunctions) -> String {
306 let Some(fmt) = format else {
307 return schema.to_string();
308 };
309 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
327fn 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}