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 = super::idents::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/// Emit `#[doc = "..."]` if the description is `Some`, otherwise nothing.
240#[must_use]
241pub fn doc_attr(description: &Option<String>) -> TokenStream {
242    description
243        .as_ref()
244        .map_or_else(|| quote! {}, |d| quote! { #[doc = #d] })
245}