Skip to main content

rescript_openapi/
ir.rs

1// SPDX-License-Identifier: PMPL-1.0-or-later
2// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
3
4//! Intermediate Representation for ReScript codegen
5//!
6//! Transforms OpenAPI structures into a codegen-friendly IR that maps
7//! directly to ReScript constructs.
8
9use anyhow::{Context, Result};
10use heck::{ToLowerCamelCase, ToPascalCase};
11use openapiv3::{OpenAPI, ReferenceOr, Schema, SchemaKind, Type};
12use std::collections::BTreeMap;
13
14/// ReScript reserved keywords that cannot be used as field names
15const 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
23/// Sanitize a field name to avoid ReScript reserved keywords
24fn 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/// Root IR node representing the entire API
34#[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/// A ReScript type definition
44#[derive(Debug, Clone)]
45pub enum TypeDef {
46    /// Record type: type user = { name: string, age: int }
47    Record {
48        name: String,
49        doc: Option<String>,
50        fields: Vec<Field>,
51    },
52    /// Variant type: type status = | Active | Inactive
53    Variant {
54        name: String,
55        doc: Option<String>,
56        cases: Vec<VariantCase>,
57    },
58    /// Alias: type userId = string
59    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/// A field in a record type
77#[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/// A case in a variant type
87#[derive(Debug, Clone)]
88pub struct VariantCase {
89    pub name: String,
90    pub original_name: String,
91    pub payload: Option<RsType>,
92}
93
94/// ReScript type representation
95#[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    /// Inline string enum (polymorphic variant)
111    StringEnum(Vec<String>),
112}
113
114impl RsType {
115    /// Generate a ReScript type expression for this type
116    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                // Inline string enums always use polymorphic variants regardless of variant_mode,
135                // since standard variants require a named type definition.
136                let cases: Vec<_> = values
137                    .iter()
138                    .map(|v| format!("#\"{}\"", v))
139                    .collect();
140                format!("[{}]", cases.join(" | "))
141            }
142        }
143    }
144
145    /// Generate a rescript-schema expression for this type
146    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/// HTTP endpoint definition
176#[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
243/// Lower OpenAPI spec to IR
244pub 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        // First pass: collect all schema types
264        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        // Second pass: collect endpoints
276        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                    // String enum -> variant type
337                    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                // Default to alias
382                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    /// Lower oneOf/anyOf schemas into variant cases
417    ///
418    /// Extracts meaningful names from $ref references (e.g., Cat from #/components/schemas/Cat)
419    /// and falls back to Case1, Case2, etc. for inline schemas.
420    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                    // Extract type name from $ref (e.g., #/components/schemas/Cat -> Cat)
428                    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                    // For inline schemas, try to get a meaningful name from the title
437                    // or fall back to Case1, Case2, etc.
438                    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                // Check for inline string enum
469                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                    // Check for specialized formats
478                    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 &param.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                    &param_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}