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    // Names of schemas whose generated type derives `Validate`, so a `$ref`
24    // field to one can safely carry a bare `#[validate]`. Empty without the
25    // feature; unused there but threaded uniformly to keep signatures stable.
26    #[cfg(feature = "validation")]
27    let models: std::collections::BTreeSet<String> =
28        super::validation::validatable_model_names(openapi);
29    #[cfg(not(feature = "validation"))]
30    let models: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
31
32    let mut inline_types: Vec<TokenStream> = Vec::new();
33    let items: Vec<TokenStream> = components
34        .schemas
35        .iter()
36        .map(|(name, ref_or)| generate_schema_item(name, ref_or, &mut inline_types, &models))
37        .collect();
38
39    quote! {
40        #(#items)*
41        #(#inline_types)*
42    }
43}
44
45/// Generate a single schema item (enum, struct, or type alias).
46fn generate_schema_item(
47    name: &str,
48    ref_or: &ReferenceOr<Schema>,
49    inline_types: &mut Vec<TokenStream>,
50    models: &std::collections::BTreeSet<String>,
51) -> TokenStream {
52    let schema = match ref_or {
53        ReferenceOr::Item(s) => s,
54        ReferenceOr::Reference { reference } => {
55            // Unusual: a component schema that is itself a $ref; just emit a type alias.
56            let ident = format_ident!("{}", name.to_pascal_case());
57            let target = ref_to_ident(reference);
58            return quote! { pub type #ident = #target; };
59        }
60    };
61
62    if is_string_enum(schema) {
63        return generate_string_enum(name, schema);
64    }
65
66    match &schema.schema_kind {
67        SchemaKind::OneOf { one_of } => generate_one_of(
68            name,
69            one_of,
70            schema.schema_data.discriminator.as_ref(),
71            schema.schema_data.description.as_ref(),
72            inline_types,
73            models,
74        ),
75        SchemaKind::AnyOf { any_of } => generate_any_of(
76            name,
77            any_of,
78            schema.schema_data.description.as_ref(),
79            inline_types,
80            models,
81        ),
82        SchemaKind::AllOf { all_of } => generate_all_of(
83            name,
84            all_of,
85            schema.schema_data.description.as_ref(),
86            inline_types,
87            models,
88        ),
89        SchemaKind::Type(Type::Object(obj)) => {
90            generate_object_struct(name, schema, obj, inline_types, models)
91        }
92        _ => {
93            // Array, integer, etc. at top level: emit a newtype alias.
94            let ident = format_ident!("{}", name.to_pascal_case());
95            let inner = schema_to_rust_type_ctx(ref_or, true, Some(name), inline_types, models);
96            let doc = doc_attr(&schema.schema_data.description);
97            quote! {
98                #doc
99                pub type #ident = #inner;
100            }
101        }
102    }
103}
104
105/// Generate a string enum type from a schema.
106pub(crate) fn generate_string_enum(name: &str, schema: &Schema) -> TokenStream {
107    let ident = format_ident!("{}", name.to_pascal_case());
108    let doc = doc_attr(&schema.schema_data.description);
109    let variants = string_enum_values(schema)
110        .into_iter()
111        .map(|v| {
112            let variant_ident = format_ident!("{}", v.to_pascal_case());
113            if variant_ident == v {
114                quote! { #variant_ident }
115            } else {
116                let rename = &v;
117                quote! {
118                    #[serde(rename = #rename)]
119                    #variant_ident
120                }
121            }
122        })
123        .collect::<Vec<_>>();
124
125    let derives = model_derives();
126    quote! {
127        #doc
128        #derives
129
130        pub enum #ident {
131            #(#variants,)*
132        }
133    }
134}
135
136/// Generate a struct from an object schema.
137///
138/// Declared properties become fields; when the schema also carries an
139/// `additionalProperties` entry, a flattened `HashMap` catch-all field is added.
140/// A schema with no declared properties (a pure map) instead becomes a
141/// `HashMap` type alias.
142#[must_use]
143pub fn generate_object_struct(
144    name: &str,
145    schema: &Schema,
146    obj: &openapiv3::ObjectType,
147    inline_types: &mut Vec<TokenStream>,
148    models: &std::collections::BTreeSet<String>,
149) -> TokenStream {
150    let ident = format_ident!("{}", name.to_pascal_case());
151    let doc = doc_attr(&schema.schema_data.description);
152
153    // A pure-map object (no declared properties, only `additionalProperties`)
154    // is emitted as a `HashMap` type alias rather than an empty struct.
155    if obj.properties.is_empty() {
156        if let Some(ap) = &obj.additional_properties {
157            if let Some(value_ty) =
158                additional_properties_value_type(ap, Some(name), inline_types, models)
159            {
160                return quote! {
161                    #doc
162                    pub type #ident =
163                        ::std::collections::HashMap<::std::string::String, #value_ty>;
164                };
165            }
166        }
167    }
168
169    let fields: Vec<TokenStream> = obj
170        .properties
171        .iter()
172        .map(|(prop_name, prop_ref_or)| {
173            let is_required = obj.required.iter().any(|r| r == prop_name);
174            object_field_tokens(
175                prop_name,
176                &prop_ref_or.clone().unbox(),
177                is_required,
178                name,
179                inline_types,
180                models,
181            )
182        })
183        .collect();
184
185    // When declared properties coexist with `additionalProperties`, collect the
186    // extra entries into a flattened `HashMap` catch-all field.
187    let additional_field = obj.additional_properties.as_ref().and_then(|ap| {
188        let synth_name = format!("{name}AdditionalProperties");
189        additional_properties_value_type(ap, Some(&synth_name), inline_types, models).map(
190            |value_ty| {
191                quote! {
192                    #[serde(flatten)]
193                    pub additional_properties:
194                        ::std::collections::HashMap<::std::string::String, #value_ty>,
195                }
196            },
197        )
198    });
199
200    let derives = model_derives();
201    quote! {
202        #doc
203        #derives
204
205        pub struct #ident {
206            #(#fields)*
207            #additional_field
208        }
209    }
210}
211
212/// Emit a single struct field for an object property. Shared between
213/// [`generate_object_struct`] and the `allOf` merger in
214/// [`super::compositions`].
215///
216/// `parent_struct_name` is used as the prefix for any inline composition
217/// encountered in this property, so that hoisted types get a stable, readable
218/// name like `PersonAddress`.
219#[must_use]
220pub fn object_field_tokens(
221    prop_name: &str,
222    prop_ref_or: &ReferenceOr<Schema>,
223    is_required: bool,
224    parent_struct_name: &str,
225    inline_types: &mut Vec<TokenStream>,
226    models: &std::collections::BTreeSet<String>,
227) -> TokenStream {
228    let snake = prop_name.to_snake_case();
229    let field_ident = super::idents::keyword_safe_ident(&snake);
230    let rename_attr = {
231        let n = prop_name;
232        quote! { #[serde(rename = #n)] }
233    };
234
235    let field_doc = match prop_ref_or {
236        ReferenceOr::Item(s) => doc_attr(&s.schema_data.description),
237        ReferenceOr::Reference { .. } => quote! {},
238    };
239
240    let synth_name = format!("{parent_struct_name}{}", prop_name.to_pascal_case());
241    let field_type = schema_to_rust_type_ctx(
242        prop_ref_or,
243        is_required,
244        Some(&synth_name),
245        inline_types,
246        models,
247    );
248
249    // `serde_valid` validation attributes derived from the schema's constraints
250    // (`minLength`, `minimum`, `minItems`, …). Emitted only under the
251    // `validation` feature; otherwise the field is byte-identical to before.
252    #[cfg(feature = "validation")]
253    let validate_attrs = super::validation::validation_attrs(prop_ref_or, models);
254    #[cfg(not(feature = "validation"))]
255    let validate_attrs = {
256        // `models` is only consulted under the `validation` feature; without it
257        // the parameter is otherwise unused.
258        let _ = models;
259        quote! {}
260    };
261
262    quote! {
263        #field_doc
264        #rename_attr
265        #validate_attrs
266        pub #field_ident: #field_type,
267    }
268}
269
270/// Emit `#[doc = "..."]` if the description is `Some`, otherwise nothing.
271#[must_use]
272pub fn doc_attr(description: &Option<String>) -> TokenStream {
273    description
274        .as_ref()
275        .map_or_else(|| quote! {}, |d| quote! { #[doc = #d] })
276}
277
278/// The `#[derive(...)]` attribute applied to every generated model struct and
279/// enum.
280///
281/// With the `validation` feature enabled, `serde_valid::Validate` is appended so
282/// the generated field-level `#[validate(...)]` attributes take effect and
283/// callers can run `model.validate()`. The derive is referenced through the
284/// facade re-export (`::openapi_trait::serde_valid`), matching the convention
285/// used for `chrono`/`uuid`.
286#[must_use]
287pub fn model_derives() -> TokenStream {
288    #[cfg(feature = "validation")]
289    let extra = quote! { , ::openapi_trait::serde_valid::Validate };
290    #[cfg(not(feature = "validation"))]
291    let extra = quote! {};
292
293    quote! {
294        #[derive(
295            ::core::fmt::Debug,
296            ::core::clone::Clone,
297            ::serde::Serialize,
298            ::serde::Deserialize
299            #extra
300        )]
301    }
302}