1use anyhow::{Context, Result};
10use heck::{ToLowerCamelCase, ToPascalCase};
11use openapiv3::{OpenAPI, ReferenceOr, Schema, SchemaKind, Type};
12use std::collections::BTreeMap;
13
14const RESERVED_KEYWORDS: &[&str] = &[
16 "type", "let", "module", "open", "include", "external", "if", "else",
17 "switch", "when", "rec", "and", "as", "exception", "try", "catch",
18 "while", "for", "in", "to", "downto", "assert", "lazy", "private",
19 "mutable", "constraint", "of", "true", "false", "or", "not", "mod",
20 "land", "lor", "lxor", "lsl", "lsr", "asr", "await", "async",
21];
22
23fn sanitize_field_name(name: &str) -> String {
25 let lower_name = name.to_lower_camel_case();
26 if RESERVED_KEYWORDS.contains(&lower_name.as_str()) {
27 format!("{}_", lower_name)
28 } else {
29 lower_name
30 }
31}
32
33#[derive(Debug)]
35pub struct ApiSpec {
36 pub title: String,
37 pub version: String,
38 pub description: Option<String>,
39 pub types: Vec<TypeDef>,
40 pub endpoints: Vec<Endpoint>,
41}
42
43#[derive(Debug, Clone)]
45pub enum TypeDef {
46 Record {
48 name: String,
49 doc: Option<String>,
50 fields: Vec<Field>,
51 },
52 Variant {
54 name: String,
55 doc: Option<String>,
56 cases: Vec<VariantCase>,
57 },
58 Alias {
60 name: String,
61 doc: Option<String>,
62 target: RsType,
63 },
64}
65
66impl TypeDef {
67 pub fn name(&self) -> &str {
68 match self {
69 TypeDef::Record { name, .. } => name,
70 TypeDef::Variant { name, .. } => name,
71 TypeDef::Alias { name, .. } => name,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct Field {
79 pub name: String,
80 pub original_name: String,
81 pub ty: RsType,
82 pub optional: bool,
83 pub doc: Option<String>,
84}
85
86#[derive(Debug, Clone)]
88pub struct VariantCase {
89 pub name: String,
90 pub original_name: String,
91 pub payload: Option<RsType>,
92}
93
94#[derive(Debug, Clone)]
96pub enum RsType {
97 String,
98 Int,
99 Float,
100 Bool,
101 Unit,
102 DateTime,
103 Date,
104 Option(Box<RsType>),
105 Array(Box<RsType>),
106 Dict(Box<RsType>),
107 Json,
108 Named(String),
109 Tuple(Vec<RsType>),
110 StringEnum(Vec<String>),
112}
113
114impl RsType {
115 pub fn to_rescript(&self) -> String {
117 match self {
118 RsType::String => "string".to_string(),
119 RsType::Int => "int".to_string(),
120 RsType::Float => "float".to_string(),
121 RsType::Bool => "bool".to_string(),
122 RsType::Unit => "unit".to_string(),
123 RsType::DateTime | RsType::Date => "Date.t".to_string(),
124 RsType::Option(inner) => format!("option<{}>", inner.to_rescript()),
125 RsType::Array(inner) => format!("array<{}>", inner.to_rescript()),
126 RsType::Dict(inner) => format!("Dict.t<{}>", inner.to_rescript()),
127 RsType::Json => "JSON.t".to_string(),
128 RsType::Named(name) => name.to_lower_camel_case(),
129 RsType::Tuple(types) => {
130 let inner: Vec<_> = types.iter().map(|t| t.to_rescript()).collect();
131 format!("({})", inner.join(", "))
132 }
133 RsType::StringEnum(values) => {
134 let cases: Vec<_> = values
137 .iter()
138 .map(|v| format!("#\"{}\"", v))
139 .collect();
140 format!("[{}]", cases.join(" | "))
141 }
142 }
143 }
144
145 pub fn to_schema(&self) -> String {
147 match self {
148 RsType::String => "S.string".to_string(),
149 RsType::Int => "S.int".to_string(),
150 RsType::Float => "S.float".to_string(),
151 RsType::Bool => "S.bool".to_string(),
152 RsType::Unit => "S.unit".to_string(),
153 RsType::DateTime => "S.datetime".to_string(),
154 RsType::Date => "S.string".to_string(),
155 RsType::Option(inner) => format!("S.option({})", inner.to_schema()),
156 RsType::Array(inner) => format!("S.array({})", inner.to_schema()),
157 RsType::Dict(inner) => format!("S.dict({})", inner.to_schema()),
158 RsType::Json => "S.json".to_string(),
159 RsType::Named(name) => format!("{}Schema", name.to_lower_camel_case()),
160 RsType::Tuple(types) => {
161 let schemas: Vec<_> = types.iter().map(|t| t.to_schema()).collect();
162 format!("S.tuple(s => ({}))", schemas.join(", "))
163 }
164 RsType::StringEnum(values) => {
165 let literals: Vec<_> = values
166 .iter()
167 .map(|v| format!("S.literal(#\"{}\")", v))
168 .collect();
169 format!("S.union([{}])", literals.join(", "))
170 }
171 }
172 }
173}
174
175#[derive(Debug)]
177pub struct Endpoint {
178 pub operation_id: String,
179 pub method: HttpMethod,
180 pub path: String,
181 pub doc: Option<String>,
182 pub parameters: Vec<Parameter>,
183 pub request_body: Option<RequestBody>,
184 pub responses: Vec<Response>,
185}
186
187#[derive(Debug, Clone, Copy)]
188pub enum HttpMethod {
189 Get,
190 Post,
191 Put,
192 Patch,
193 Delete,
194 Head,
195 Options,
196}
197
198impl HttpMethod {
199 pub fn as_str(&self) -> &'static str {
200 match self {
201 HttpMethod::Get => "GET",
202 HttpMethod::Post => "POST",
203 HttpMethod::Put => "PUT",
204 HttpMethod::Patch => "PATCH",
205 HttpMethod::Delete => "DELETE",
206 HttpMethod::Head => "HEAD",
207 HttpMethod::Options => "OPTIONS",
208 }
209 }
210}
211
212#[derive(Debug)]
213pub struct Parameter {
214 pub name: String,
215 pub location: ParameterLocation,
216 pub ty: RsType,
217 pub required: bool,
218 pub doc: Option<String>,
219}
220
221#[derive(Debug, Clone, Copy)]
222pub enum ParameterLocation {
223 Path,
224 Query,
225 Header,
226 Cookie,
227}
228
229#[derive(Debug)]
230pub struct RequestBody {
231 pub ty: RsType,
232 pub required: bool,
233 pub content_type: String,
234}
235
236#[derive(Debug)]
237pub struct Response {
238 pub status: u16,
239 pub ty: Option<RsType>,
240 pub doc: Option<String>,
241}
242
243pub fn lower(spec: &OpenAPI) -> Result<ApiSpec> {
245 let mut lowerer = Lowerer::new(spec);
246 lowerer.lower()
247}
248
249struct Lowerer<'a> {
250 spec: &'a OpenAPI,
251 types: BTreeMap<String, TypeDef>,
252}
253
254impl<'a> Lowerer<'a> {
255 fn new(spec: &'a OpenAPI) -> Self {
256 Self {
257 spec,
258 types: BTreeMap::new(),
259 }
260 }
261
262 fn lower(&mut self) -> Result<ApiSpec> {
263 if let Some(components) = &self.spec.components {
265 for (name, schema) in &components.schemas {
266 if let ReferenceOr::Item(schema) = schema {
267 let type_def = self
268 .lower_schema(name, schema)
269 .with_context(|| format!("Failed to lower schema '{}'", name))?;
270 self.types.insert(name.clone(), type_def);
271 }
272 }
273 }
274
275 let mut endpoints = Vec::new();
277 for (path, item) in self.spec.paths.iter() {
278 if let ReferenceOr::Item(path_item) = item {
279 for (method, op) in path_item.iter() {
280 let endpoint = self.lower_operation(path, method, op)?;
281 endpoints.push(endpoint);
282 }
283 }
284 }
285
286 Ok(ApiSpec {
287 title: self.spec.info.title.clone(),
288 version: self.spec.info.version.clone(),
289 description: self.spec.info.description.clone(),
290 types: self.types.values().cloned().collect(),
291 endpoints,
292 })
293 }
294
295 fn lower_schema(&self, name: &str, schema: &Schema) -> Result<TypeDef> {
296 let doc = schema.schema_data.description.clone();
297 let rs_name = name.to_pascal_case();
298
299 match &schema.schema_kind {
300 SchemaKind::Type(Type::Object(obj)) => {
301 let mut fields = Vec::new();
302
303 for (prop_name, prop_schema) in &obj.properties {
304 let required = obj.required.contains(prop_name);
305 let ty = self.boxed_schema_to_type(prop_schema)?;
306 let field_ty = if required {
307 ty
308 } else {
309 RsType::Option(Box::new(ty))
310 };
311
312 let field_doc = if let ReferenceOr::Item(s) = prop_schema {
313 s.schema_data.description.clone()
314 } else {
315 None
316 };
317
318 fields.push(Field {
319 name: sanitize_field_name(prop_name),
320 original_name: prop_name.clone(),
321 ty: field_ty,
322 optional: !required,
323 doc: field_doc,
324 });
325 }
326
327 Ok(TypeDef::Record {
328 name: rs_name,
329 doc,
330 fields,
331 })
332 }
333
334 SchemaKind::Type(Type::String(string_type)) => {
335 if !string_type.enumeration.is_empty() {
336 let cases = string_type
338 .enumeration
339 .iter()
340 .filter_map(|v| v.as_ref())
341 .map(|v| VariantCase {
342 name: v.to_pascal_case(),
343 original_name: v.clone(),
344 payload: None,
345 })
346 .collect();
347
348 Ok(TypeDef::Variant {
349 name: rs_name,
350 doc,
351 cases,
352 })
353 } else {
354 Ok(TypeDef::Alias {
355 name: rs_name,
356 doc,
357 target: RsType::String,
358 })
359 }
360 }
361
362 SchemaKind::OneOf { one_of } => {
363 let cases = self.lower_variant_cases(one_of);
364 Ok(TypeDef::Variant {
365 name: rs_name,
366 doc,
367 cases,
368 })
369 }
370
371 SchemaKind::AnyOf { any_of } => {
372 let cases = self.lower_variant_cases(any_of);
373 Ok(TypeDef::Variant {
374 name: rs_name,
375 doc,
376 cases,
377 })
378 }
379
380 _ => {
381 let target = self.schema_kind_to_type(&schema.schema_kind)?;
383 Ok(TypeDef::Alias {
384 name: rs_name,
385 doc,
386 target,
387 })
388 }
389 }
390 }
391
392 fn schema_to_type(&self, schema: &ReferenceOr<Schema>) -> Result<RsType> {
393 match schema {
394 ReferenceOr::Reference { reference } => {
395 let name = reference
396 .strip_prefix("#/components/schemas/")
397 .unwrap_or(reference);
398 Ok(RsType::Named(name.to_pascal_case()))
399 }
400 ReferenceOr::Item(schema) => self.schema_kind_to_type(&schema.schema_kind),
401 }
402 }
403
404 fn boxed_schema_to_type(&self, schema: &ReferenceOr<Box<Schema>>) -> Result<RsType> {
405 match schema {
406 ReferenceOr::Reference { reference } => {
407 let name = reference
408 .strip_prefix("#/components/schemas/")
409 .unwrap_or(reference);
410 Ok(RsType::Named(name.to_pascal_case()))
411 }
412 ReferenceOr::Item(schema) => self.schema_kind_to_type(&schema.schema_kind),
413 }
414 }
415
416 fn lower_variant_cases(&self, schemas: &[ReferenceOr<Schema>]) -> Vec<VariantCase> {
421 let mut cases = Vec::new();
422 let mut fallback_index = 1;
423
424 for schema in schemas {
425 let (case_name, original_name, payload) = match schema {
426 ReferenceOr::Reference { reference } => {
427 let ref_name = reference
429 .strip_prefix("#/components/schemas/")
430 .unwrap_or(reference);
431 let name = ref_name.to_pascal_case();
432 let ty = RsType::Named(name.clone());
433 (name, ref_name.to_string(), Some(ty))
434 }
435 ReferenceOr::Item(inline_schema) => {
436 let original_name = inline_schema
439 .schema_data
440 .title
441 .as_ref()
442 .map(|t| t.to_string())
443 .unwrap_or_else(|| {
444 let name = format!("Case{}", fallback_index);
445 fallback_index += 1;
446 name
447 });
448
449 let name = original_name.to_pascal_case();
450 let ty = self.schema_kind_to_type(&inline_schema.schema_kind).ok();
451 (name, original_name, ty)
452 }
453 };
454
455 cases.push(VariantCase {
456 name: case_name,
457 original_name,
458 payload,
459 });
460 }
461
462 cases
463 }
464
465 fn schema_kind_to_type(&self, kind: &SchemaKind) -> Result<RsType> {
466 match kind {
467 SchemaKind::Type(Type::String(string_type)) => {
468 if !string_type.enumeration.is_empty() {
470 let values: Vec<String> = string_type
471 .enumeration
472 .iter()
473 .filter_map(|v| v.clone())
474 .collect();
475 Ok(RsType::StringEnum(values))
476 } else {
477 match &string_type.format {
479 openapiv3::VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::DateTime) => {
480 Ok(RsType::DateTime)
481 }
482 openapiv3::VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) => {
483 Ok(RsType::Date)
484 }
485 _ => Ok(RsType::String),
486 }
487 }
488 }
489 SchemaKind::Type(Type::Integer(_)) => Ok(RsType::Int),
490 SchemaKind::Type(Type::Number(_)) => Ok(RsType::Float),
491 SchemaKind::Type(Type::Boolean(_)) => Ok(RsType::Bool),
492 SchemaKind::Type(Type::Array(arr)) => {
493 let item_type = arr
494 .items
495 .as_ref()
496 .map(|i| self.boxed_schema_to_type(i))
497 .transpose()?
498 .unwrap_or(RsType::Json);
499 Ok(RsType::Array(Box::new(item_type)))
500 }
501 SchemaKind::Type(Type::Object(_)) => Ok(RsType::Json),
502 SchemaKind::Any(_) => Ok(RsType::Json),
503 _ => Ok(RsType::Json),
504 }
505 }
506
507 fn lower_operation(
508 &self,
509 path: &str,
510 method: &str,
511 op: &openapiv3::Operation,
512 ) -> Result<Endpoint> {
513 let operation_id = op
514 .operation_id
515 .clone()
516 .unwrap_or_else(|| format!("{}_{}", method, path.replace('/', "_")));
517
518 let http_method = match method.to_uppercase().as_str() {
519 "GET" => HttpMethod::Get,
520 "POST" => HttpMethod::Post,
521 "PUT" => HttpMethod::Put,
522 "PATCH" => HttpMethod::Patch,
523 "DELETE" => HttpMethod::Delete,
524 "HEAD" => HttpMethod::Head,
525 "OPTIONS" => HttpMethod::Options,
526 _ => HttpMethod::Get,
527 };
528
529 let mut parameters = Vec::new();
530 for param in &op.parameters {
531 if let ReferenceOr::Item(param) = param {
532 let location = match ¶m.parameter_data_ref() {
533 openapiv3::ParameterData {
534 name: _,
535 description: _,
536 required: _,
537 deprecated: _,
538 format:
539 openapiv3::ParameterSchemaOrContent::Schema(ReferenceOr::Item(_schema)),
540 example: _,
541 examples: _,
542 explode: _,
543 extensions: _,
544 } => match param {
545 openapiv3::Parameter::Path { .. } => ParameterLocation::Path,
546 openapiv3::Parameter::Query { .. } => ParameterLocation::Query,
547 openapiv3::Parameter::Header { .. } => ParameterLocation::Header,
548 openapiv3::Parameter::Cookie { .. } => ParameterLocation::Cookie,
549 },
550 _ => continue,
551 };
552
553 let param_data = param.parameter_data_ref();
554 let ty = if let openapiv3::ParameterSchemaOrContent::Schema(schema) =
555 ¶m_data.format
556 {
557 self.schema_to_type(schema)?
558 } else {
559 RsType::String
560 };
561
562 parameters.push(Parameter {
563 name: param_data.name.to_lower_camel_case(),
564 location,
565 ty,
566 required: param_data.required,
567 doc: param_data.description.clone(),
568 });
569 }
570 }
571
572 let request_body = if let Some(ReferenceOr::Item(body)) = &op.request_body {
573 body.content.get("application/json").map(|media| {
574 let ty = media
575 .schema
576 .as_ref()
577 .and_then(|s| self.schema_to_type(s).ok())
578 .unwrap_or(RsType::Json);
579 RequestBody {
580 ty,
581 required: body.required,
582 content_type: "application/json".to_string(),
583 }
584 })
585 } else {
586 None
587 };
588
589 let mut responses = Vec::new();
590 for (status, response) in &op.responses.responses {
591 if let ReferenceOr::Item(response) = response {
592 let status_code = match status {
593 openapiv3::StatusCode::Code(code) => *code,
594 openapiv3::StatusCode::Range(_) => continue,
595 };
596
597 let ty = response.content.get("application/json").and_then(|media| {
598 media
599 .schema
600 .as_ref()
601 .and_then(|s| self.schema_to_type(s).ok())
602 });
603
604 responses.push(Response {
605 status: status_code,
606 ty,
607 doc: Some(response.description.clone()),
608 });
609 }
610 }
611
612 Ok(Endpoint {
613 operation_id: operation_id.to_lower_camel_case(),
614 method: http_method,
615 path: path.to_string(),
616 doc: op.description.clone().or(op.summary.clone()),
617 parameters,
618 request_body,
619 responses,
620 })
621 }
622}