Skip to main content

openapi_trait_shared/codegen/
schemas.rs

1use heck::{ToPascalCase, ToSnakeCase};
2use openapiv3::{Components, OpenAPI, ReferenceOr, Schema, SchemaKind, Type};
3use proc_macro2::TokenStream;
4use quote::{format_ident, quote};
5
6use super::types::{is_string_enum, ref_to_ident, schema_to_rust_type, string_enum_values};
7
8/// Generate all schema structs and enums from `components/schemas`.
9#[must_use]
10pub fn generate_schemas(openapi: &OpenAPI) -> TokenStream {
11    let Some(components) = &openapi.components else {
12        return quote! {};
13    };
14
15    let items: Vec<TokenStream> = components
16        .schemas
17        .iter()
18        .map(|(name, ref_or)| generate_schema_item(name, ref_or, components))
19        .collect();
20
21    quote! { #(#items)* }
22}
23
24/// Generate a single schema item (enum, struct, or type alias).
25fn generate_schema_item(
26    name: &str,
27    ref_or: &ReferenceOr<Schema>,
28    components: &Components,
29) -> TokenStream {
30    let schema = match ref_or {
31        ReferenceOr::Item(s) => s,
32        ReferenceOr::Reference { reference } => {
33            // Unusual: a component schema that is itself a $ref; just emit a type alias.
34            let ident = format_ident!("{}", name);
35            let target = ref_to_ident(reference);
36            return quote! { pub type #ident = #target; };
37        }
38    };
39
40    if is_string_enum(schema) {
41        generate_string_enum(name, schema)
42    } else if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
43        generate_object_struct(name, schema, obj, components)
44    } else {
45        // Array, integer, etc. at top level: emit a newtype alias.
46        let ident = format_ident!("{}", name);
47        let inner = schema_to_rust_type(ref_or, true);
48        let doc = doc_attr(&schema.schema_data.description);
49        quote! {
50            #doc
51            pub type #ident = #inner;
52        }
53    }
54}
55
56/// Generate a string enum type from a schema.
57fn generate_string_enum(name: &str, schema: &Schema) -> TokenStream {
58    let ident = format_ident!("{}", name);
59    let doc = doc_attr(&schema.schema_data.description);
60    let variants = string_enum_values(schema)
61        .into_iter()
62        .map(|v| {
63            let variant_ident = format_ident!("{}", v.to_pascal_case());
64            if variant_ident == v {
65                quote! { #variant_ident }
66            } else {
67                let rename = &v;
68                quote! {
69                    #[serde(rename = #rename)]
70                    #variant_ident
71                }
72            }
73        })
74        .collect::<Vec<_>>();
75
76    quote! {
77        #doc
78        #[derive(
79            ::core::fmt::Debug,
80            ::core::clone::Clone,
81            ::serde::Serialize,
82            ::serde::Deserialize,
83        )]
84
85        pub enum #ident {
86            #(#variants,)*
87        }
88    }
89}
90
91/// Generate a struct from an object schema.
92fn generate_object_struct(
93    name: &str,
94    schema: &Schema,
95    obj: &openapiv3::ObjectType,
96    _components: &Components,
97) -> TokenStream {
98    let ident = format_ident!("{}", name);
99    let doc = doc_attr(&schema.schema_data.description);
100
101    let fields: Vec<TokenStream> = obj
102        .properties
103        .iter()
104        .map(|(prop_name, prop_ref_or)| {
105            // Check actual required array on the object
106            let is_required = obj.required.iter().any(|r| r == prop_name);
107
108            let snake = prop_name.to_snake_case();
109            // Escape Rust keywords (e.g. `type` -> `r#type`)
110            let field_ident = keyword_safe_ident(&snake);
111            // Always emit rename if either the snake conversion or keyword escaping changed the name
112            let rename_attr = {
113                let n = prop_name.as_str();
114                quote! { #[serde(rename = #n)] }
115            };
116
117            // Get description from the property schema if it's inline
118            let field_doc = match prop_ref_or {
119                ReferenceOr::Item(s) => doc_attr(&s.schema_data.description),
120                ReferenceOr::Reference { .. } => quote! {},
121            };
122
123            let field_type = schema_to_rust_type(&prop_ref_or.clone().unbox(), is_required);
124
125            quote! {
126                #field_doc
127                #rename_attr
128                pub #field_ident: #field_type,
129            }
130        })
131        .collect();
132
133    quote! {
134        #doc
135        #[derive(
136            ::core::fmt::Debug,
137            ::core::clone::Clone,
138            ::serde::Serialize,
139            ::serde::Deserialize,
140        )]
141
142        pub struct #ident {
143            #(#fields)*
144        }
145    }
146}
147
148/// Turn a `snake_case` name into a keyword-safe `syn::Ident`, using raw identifier
149/// syntax (`r#type`) when the name clashes with a Rust keyword.
150fn keyword_safe_ident(name: &str) -> proc_macro2::Ident {
151    // Keywords that are valid raw identifiers but not plain identifiers:
152    const KEYWORDS: &[&str] = &[
153        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
154        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
155        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
156        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
157    ];
158    if KEYWORDS.contains(&name) {
159        proc_macro2::Ident::new_raw(name, proc_macro2::Span::call_site())
160    } else {
161        format_ident!("{}", name)
162    }
163}
164
165/// Emit `#[doc = "..."]` if the description is `Some`, otherwise nothing.
166#[must_use]
167pub fn doc_attr(description: &Option<String>) -> TokenStream {
168    description
169        .as_ref()
170        .map_or_else(|| quote! {}, |d| quote! { #[doc = #d] })
171}