Skip to main content

openapi_trait_shared/codegen/
schemas.rs

1use heck::{ToPascalCase, ToSnakeCase};
2use openapiv3::{OpenAPI, ReferenceOr, Schema, SchemaKind, Type};
3use proc_macro2::TokenStream;
4use quote::{format_ident, quote};
5
6use super::compositions::{generate_all_of, generate_any_of, generate_one_of};
7use super::types::{
8    additional_properties_value_type, is_string_enum, ref_to_ident, schema_to_rust_type_ctx,
9    string_enum_values,
10};
11
12/// Generate all schema structs and enums from `components/schemas`.
13///
14/// Any inline `oneOf` / `allOf` / `anyOf` encountered inside an object property
15/// is hoisted to a synthesized top-level type and emitted alongside the named
16/// schemas, so the resulting module is self-contained.
17#[must_use]
18pub fn generate_schemas(openapi: &OpenAPI) -> TokenStream {
19    let Some(components) = &openapi.components else {
20        return quote! {};
21    };
22
23    let mut inline_types: Vec<TokenStream> = Vec::new();
24    let items: Vec<TokenStream> = components
25        .schemas
26        .iter()
27        .map(|(name, ref_or)| generate_schema_item(name, ref_or, &mut inline_types))
28        .collect();
29
30    quote! {
31        #(#items)*
32        #(#inline_types)*
33    }
34}
35
36/// Generate a single schema item (enum, struct, or type alias).
37fn generate_schema_item(
38    name: &str,
39    ref_or: &ReferenceOr<Schema>,
40    inline_types: &mut Vec<TokenStream>,
41) -> TokenStream {
42    let schema = match ref_or {
43        ReferenceOr::Item(s) => s,
44        ReferenceOr::Reference { reference } => {
45            // Unusual: a component schema that is itself a $ref; just emit a type alias.
46            let ident = format_ident!("{}", name.to_pascal_case());
47            let target = ref_to_ident(reference);
48            return quote! { pub type #ident = #target; };
49        }
50    };
51
52    if is_string_enum(schema) {
53        return generate_string_enum(name, schema);
54    }
55
56    match &schema.schema_kind {
57        SchemaKind::OneOf { one_of } => generate_one_of(
58            name,
59            one_of,
60            schema.schema_data.discriminator.as_ref(),
61            schema.schema_data.description.as_ref(),
62            inline_types,
63        ),
64        SchemaKind::AnyOf { any_of } => generate_any_of(
65            name,
66            any_of,
67            schema.schema_data.description.as_ref(),
68            inline_types,
69        ),
70        SchemaKind::AllOf { all_of } => generate_all_of(
71            name,
72            all_of,
73            schema.schema_data.description.as_ref(),
74            inline_types,
75        ),
76        SchemaKind::Type(Type::Object(obj)) => {
77            generate_object_struct(name, schema, obj, inline_types)
78        }
79        _ => {
80            // Array, integer, etc. at top level: emit a newtype alias.
81            let ident = format_ident!("{}", name.to_pascal_case());
82            let inner = schema_to_rust_type_ctx(ref_or, true, Some(name), inline_types);
83            let doc = doc_attr(&schema.schema_data.description);
84            quote! {
85                #doc
86                pub type #ident = #inner;
87            }
88        }
89    }
90}
91
92/// Generate a string enum type from a schema.
93fn generate_string_enum(name: &str, schema: &Schema) -> TokenStream {
94    let ident = format_ident!("{}", name.to_pascal_case());
95    let doc = doc_attr(&schema.schema_data.description);
96    let variants = string_enum_values(schema)
97        .into_iter()
98        .map(|v| {
99            let variant_ident = format_ident!("{}", v.to_pascal_case());
100            if variant_ident == v {
101                quote! { #variant_ident }
102            } else {
103                let rename = &v;
104                quote! {
105                    #[serde(rename = #rename)]
106                    #variant_ident
107                }
108            }
109        })
110        .collect::<Vec<_>>();
111
112    quote! {
113        #doc
114        #[derive(
115            ::core::fmt::Debug,
116            ::core::clone::Clone,
117            ::serde::Serialize,
118            ::serde::Deserialize,
119        )]
120
121        pub enum #ident {
122            #(#variants,)*
123        }
124    }
125}
126
127/// Generate a struct from an object schema.
128///
129/// Declared properties become fields; when the schema also carries an
130/// `additionalProperties` entry, a flattened `HashMap` catch-all field is added.
131/// A schema with no declared properties (a pure map) instead becomes a
132/// `HashMap` type alias.
133#[must_use]
134pub fn generate_object_struct(
135    name: &str,
136    schema: &Schema,
137    obj: &openapiv3::ObjectType,
138    inline_types: &mut Vec<TokenStream>,
139) -> TokenStream {
140    let ident = format_ident!("{}", name.to_pascal_case());
141    let doc = doc_attr(&schema.schema_data.description);
142
143    // A pure-map object (no declared properties, only `additionalProperties`)
144    // is emitted as a `HashMap` type alias rather than an empty struct.
145    if obj.properties.is_empty() {
146        if let Some(ap) = &obj.additional_properties {
147            if let Some(value_ty) = additional_properties_value_type(ap, Some(name), inline_types) {
148                return quote! {
149                    #doc
150                    pub type #ident =
151                        ::std::collections::HashMap<::std::string::String, #value_ty>;
152                };
153            }
154        }
155    }
156
157    let fields: Vec<TokenStream> = obj
158        .properties
159        .iter()
160        .map(|(prop_name, prop_ref_or)| {
161            let is_required = obj.required.iter().any(|r| r == prop_name);
162            object_field_tokens(
163                prop_name,
164                &prop_ref_or.clone().unbox(),
165                is_required,
166                name,
167                inline_types,
168            )
169        })
170        .collect();
171
172    // When declared properties coexist with `additionalProperties`, collect the
173    // extra entries into a flattened `HashMap` catch-all field.
174    let additional_field = obj.additional_properties.as_ref().and_then(|ap| {
175        let synth_name = format!("{name}AdditionalProperties");
176        additional_properties_value_type(ap, Some(&synth_name), inline_types).map(|value_ty| {
177            quote! {
178                #[serde(flatten)]
179                pub additional_properties:
180                    ::std::collections::HashMap<::std::string::String, #value_ty>,
181            }
182        })
183    });
184
185    quote! {
186        #doc
187        #[derive(
188            ::core::fmt::Debug,
189            ::core::clone::Clone,
190            ::serde::Serialize,
191            ::serde::Deserialize,
192        )]
193
194        pub struct #ident {
195            #(#fields)*
196            #additional_field
197        }
198    }
199}
200
201/// Emit a single struct field for an object property. Shared between
202/// [`generate_object_struct`] and the `allOf` merger in
203/// [`super::compositions`].
204///
205/// `parent_struct_name` is used as the prefix for any inline composition
206/// encountered in this property, so that hoisted types get a stable, readable
207/// name like `PersonAddress`.
208#[must_use]
209pub fn object_field_tokens(
210    prop_name: &str,
211    prop_ref_or: &ReferenceOr<Schema>,
212    is_required: bool,
213    parent_struct_name: &str,
214    inline_types: &mut Vec<TokenStream>,
215) -> TokenStream {
216    let snake = prop_name.to_snake_case();
217    let field_ident = keyword_safe_ident(&snake);
218    let rename_attr = {
219        let n = prop_name;
220        quote! { #[serde(rename = #n)] }
221    };
222
223    let field_doc = match prop_ref_or {
224        ReferenceOr::Item(s) => doc_attr(&s.schema_data.description),
225        ReferenceOr::Reference { .. } => quote! {},
226    };
227
228    let synth_name = format!("{parent_struct_name}{}", prop_name.to_pascal_case());
229    let field_type =
230        schema_to_rust_type_ctx(prop_ref_or, is_required, Some(&synth_name), inline_types);
231
232    quote! {
233        #field_doc
234        #rename_attr
235        pub #field_ident: #field_type,
236    }
237}
238
239/// Turn a `snake_case` name into a keyword-safe `syn::Ident`, using raw identifier
240/// syntax (`r#type`) when the name clashes with a Rust keyword.
241fn keyword_safe_ident(name: &str) -> proc_macro2::Ident {
242    // Keywords that are valid raw identifiers but not plain identifiers:
243    const KEYWORDS: &[&str] = &[
244        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
245        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
246        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
247        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
248    ];
249    if KEYWORDS.contains(&name) {
250        proc_macro2::Ident::new_raw(name, proc_macro2::Span::call_site())
251    } else {
252        format_ident!("{}", name)
253    }
254}
255
256/// Emit `#[doc = "..."]` if the description is `Some`, otherwise nothing.
257#[must_use]
258pub fn doc_attr(description: &Option<String>) -> TokenStream {
259    description
260        .as_ref()
261        .map_or_else(|| quote! {}, |d| quote! { #[doc = #d] })
262}