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.
93pub(crate) fn 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    let derives = model_derives();
113    quote! {
114        #doc
115        #derives
116
117        pub enum #ident {
118            #(#variants,)*
119        }
120    }
121}
122
123/// Generate a struct from an object schema.
124///
125/// Declared properties become fields; when the schema also carries an
126/// `additionalProperties` entry, a flattened `HashMap` catch-all field is added.
127/// A schema with no declared properties (a pure map) instead becomes a
128/// `HashMap` type alias.
129#[must_use]
130pub fn generate_object_struct(
131    name: &str,
132    schema: &Schema,
133    obj: &openapiv3::ObjectType,
134    inline_types: &mut Vec<TokenStream>,
135) -> TokenStream {
136    let ident = format_ident!("{}", name.to_pascal_case());
137    let doc = doc_attr(&schema.schema_data.description);
138
139    // A pure-map object (no declared properties, only `additionalProperties`)
140    // is emitted as a `HashMap` type alias rather than an empty struct.
141    if obj.properties.is_empty() {
142        if let Some(ap) = &obj.additional_properties {
143            if let Some(value_ty) = additional_properties_value_type(ap, Some(name), inline_types) {
144                return quote! {
145                    #doc
146                    pub type #ident =
147                        ::std::collections::HashMap<::std::string::String, #value_ty>;
148                };
149            }
150        }
151    }
152
153    let fields: Vec<TokenStream> = obj
154        .properties
155        .iter()
156        .map(|(prop_name, prop_ref_or)| {
157            let is_required = obj.required.iter().any(|r| r == prop_name);
158            object_field_tokens(
159                prop_name,
160                &prop_ref_or.clone().unbox(),
161                is_required,
162                name,
163                inline_types,
164            )
165        })
166        .collect();
167
168    // When declared properties coexist with `additionalProperties`, collect the
169    // extra entries into a flattened `HashMap` catch-all field.
170    let additional_field = obj.additional_properties.as_ref().and_then(|ap| {
171        let synth_name = format!("{name}AdditionalProperties");
172        additional_properties_value_type(ap, Some(&synth_name), inline_types).map(|value_ty| {
173            quote! {
174                #[serde(flatten)]
175                pub additional_properties:
176                    ::std::collections::HashMap<::std::string::String, #value_ty>,
177            }
178        })
179    });
180
181    let derives = model_derives();
182    quote! {
183        #doc
184        #derives
185
186        pub struct #ident {
187            #(#fields)*
188            #additional_field
189        }
190    }
191}
192
193/// Emit a single struct field for an object property. Shared between
194/// [`generate_object_struct`] and the `allOf` merger in
195/// [`super::compositions`].
196///
197/// `parent_struct_name` is used as the prefix for any inline composition
198/// encountered in this property, so that hoisted types get a stable, readable
199/// name like `PersonAddress`.
200#[must_use]
201pub fn object_field_tokens(
202    prop_name: &str,
203    prop_ref_or: &ReferenceOr<Schema>,
204    is_required: bool,
205    parent_struct_name: &str,
206    inline_types: &mut Vec<TokenStream>,
207) -> TokenStream {
208    let snake = prop_name.to_snake_case();
209    let field_ident = super::idents::keyword_safe_ident(&snake);
210    let rename_attr = {
211        let n = prop_name;
212        quote! { #[serde(rename = #n)] }
213    };
214
215    let field_doc = match prop_ref_or {
216        ReferenceOr::Item(s) => doc_attr(&s.schema_data.description),
217        ReferenceOr::Reference { .. } => quote! {},
218    };
219
220    let synth_name = format!("{parent_struct_name}{}", prop_name.to_pascal_case());
221    let field_type =
222        schema_to_rust_type_ctx(prop_ref_or, is_required, Some(&synth_name), inline_types);
223
224    // `serde_valid` validation attributes derived from the schema's constraints
225    // (`minLength`, `minimum`, `minItems`, …). Emitted only under the
226    // `validation` feature; otherwise the field is byte-identical to before.
227    #[cfg(feature = "validation")]
228    let validate_attrs = super::validation::validation_attrs(prop_ref_or);
229    #[cfg(not(feature = "validation"))]
230    let validate_attrs = quote! {};
231
232    quote! {
233        #field_doc
234        #rename_attr
235        #validate_attrs
236        pub #field_ident: #field_type,
237    }
238}
239
240/// Emit `#[doc = "..."]` if the description is `Some`, otherwise nothing.
241#[must_use]
242pub fn doc_attr(description: &Option<String>) -> TokenStream {
243    description
244        .as_ref()
245        .map_or_else(|| quote! {}, |d| quote! { #[doc = #d] })
246}
247
248/// The `#[derive(...)]` attribute applied to every generated model struct and
249/// enum.
250///
251/// With the `validation` feature enabled, `serde_valid::Validate` is appended so
252/// the generated field-level `#[validate(...)]` attributes take effect and
253/// callers can run `model.validate()`. The derive is referenced through the
254/// facade re-export (`::openapi_trait::serde_valid`), matching the convention
255/// used for `chrono`/`uuid`.
256#[must_use]
257pub fn model_derives() -> TokenStream {
258    #[cfg(feature = "validation")]
259    let extra = quote! { , ::openapi_trait::serde_valid::Validate };
260    #[cfg(not(feature = "validation"))]
261    let extra = quote! {};
262
263    quote! {
264        #[derive(
265            ::core::fmt::Debug,
266            ::core::clone::Clone,
267            ::serde::Serialize,
268            ::serde::Deserialize
269            #extra
270        )]
271    }
272}