Skip to main content

openapi_trait_shared/codegen/
types.rs

1use heck::ToPascalCase;
2use openapiv3::{
3    AdditionalProperties, IntegerFormat, NumberFormat, ObjectType, ReferenceOr, Schema, SchemaKind,
4    StringFormat, StringType, Type, VariantOrUnknownOrEmpty,
5};
6use proc_macro2::TokenStream;
7use quote::{format_ident, quote};
8
9/// Map an `OpenAPI` `Schema` (or `$ref`) to a Rust type `TokenStream`.
10///
11/// `required` controls whether the result is wrapped in `Option<T>`.
12///
13/// This is the context-free entry point: any inline `oneOf` / `allOf` / `anyOf`
14/// encountered along the way falls back to `serde_json::Value`. Use
15/// [`schema_to_rust_type_ctx`] when a parent name is available so that inline
16/// compositions can be synthesized into named top-level types.
17#[must_use]
18pub fn schema_to_rust_type(ref_or: &ReferenceOr<Schema>, required: bool) -> TokenStream {
19    let mut sink: Vec<TokenStream> = Vec::new();
20    // No parent name means no synthesis, so the (discarded) sink never receives a
21    // model whose fields would need validation attributes; an empty model set is
22    // therefore sufficient here.
23    let models = std::collections::BTreeSet::new();
24    schema_to_rust_type_ctx(ref_or, required, None, &mut sink, &models)
25    // sink is discarded — by definition no parent name means no synthesis.
26}
27
28/// Context-aware variant of [`schema_to_rust_type`].
29///
30/// When `parent_name` is `Some` and an inline composition is encountered, a
31/// top-level type definition is appended to `inline_types` (`parent_name` is
32/// used verbatim as the type ident) and the returned token stream references
33/// that ident.
34#[must_use]
35pub fn schema_to_rust_type_ctx(
36    ref_or: &ReferenceOr<Schema>,
37    required: bool,
38    parent_name: Option<&str>,
39    inline_types: &mut Vec<TokenStream>,
40    models: &std::collections::BTreeSet<String>,
41) -> TokenStream {
42    let inner = ref_or_to_inner_type_ctx(ref_or, parent_name, inline_types, models);
43    if required {
44        inner
45    } else {
46        quote! { ::core::option::Option<#inner> }
47    }
48}
49
50/// Resolve a `$ref` or inline schema to its Rust type, threading inline-type
51/// synthesis context through.
52fn ref_or_to_inner_type_ctx(
53    ref_or: &ReferenceOr<Schema>,
54    parent_name: Option<&str>,
55    inline_types: &mut Vec<TokenStream>,
56    models: &std::collections::BTreeSet<String>,
57) -> TokenStream {
58    match ref_or {
59        ReferenceOr::Reference { reference } => ref_to_ident(reference),
60        ReferenceOr::Item(schema) => schema_kind_to_type(schema, parent_name, inline_types, models),
61    }
62}
63
64#[must_use]
65pub fn ref_to_ident(reference: &str) -> TokenStream {
66    // "#/components/schemas/Foo" -> Foo
67    let name = reference.rsplit('/').next().unwrap_or(reference);
68    let ident = format_ident!("{}", name.to_pascal_case());
69    quote! { #ident }
70}
71
72/// Convert a schema to a Rust type, synthesizing a top-level composition type
73/// when `parent_name` is provided and the schema is a composition.
74fn schema_kind_to_type(
75    schema: &Schema,
76    parent_name: Option<&str>,
77    inline_types: &mut Vec<TokenStream>,
78    models: &std::collections::BTreeSet<String>,
79) -> TokenStream {
80    match &schema.schema_kind {
81        SchemaKind::Type(Type::String(_)) if is_string_enum(schema) => {
82            synthesize_inline_string_enum(schema, parent_name, inline_types)
83        }
84        SchemaKind::Type(Type::Object(obj)) => {
85            object_schema_to_type(schema, obj, parent_name, inline_types, models)
86        }
87        SchemaKind::Type(t) => primitive_type_to_rust(t, parent_name, inline_types, models),
88        SchemaKind::OneOf { one_of } => {
89            synthesize_inline_composition(parent_name, inline_types, |name, sink| {
90                super::compositions::generate_one_of(
91                    name,
92                    one_of,
93                    schema.schema_data.discriminator.as_ref(),
94                    schema.schema_data.description.as_ref(),
95                    sink,
96                    models,
97                )
98            })
99        }
100        SchemaKind::AnyOf { any_of } => {
101            synthesize_inline_composition(parent_name, inline_types, |name, sink| {
102                super::compositions::generate_any_of(
103                    name,
104                    any_of,
105                    schema.schema_data.description.as_ref(),
106                    sink,
107                    models,
108                )
109            })
110        }
111        SchemaKind::AllOf { all_of } => {
112            synthesize_inline_composition(parent_name, inline_types, |name, sink| {
113                super::compositions::generate_all_of(
114                    name,
115                    all_of,
116                    schema.schema_data.description.as_ref(),
117                    sink,
118                    models,
119                )
120            })
121        }
122        SchemaKind::Not { .. } | SchemaKind::Any(_) => {
123            // Intentionally unsupported: emit untyped JSON.
124            quote! { ::serde_json::Value }
125        }
126    }
127}
128
129/// Generate a named enum for an inline string enum when a parent name is
130/// available; otherwise fall back to `String`.
131fn synthesize_inline_string_enum(
132    schema: &Schema,
133    parent_name: Option<&str>,
134    inline_types: &mut Vec<TokenStream>,
135) -> TokenStream {
136    parent_name.map_or_else(
137        || quote! { ::std::string::String },
138        |name| {
139            let tokens = super::schemas::generate_string_enum(name, schema);
140            inline_types.push(tokens);
141            let ident = format_ident!("{}", name.to_pascal_case());
142            quote! { #ident }
143        },
144    )
145}
146
147/// Either synthesize a top-level composition type (when a parent name is
148/// available) and return a reference to it, or fall back to
149/// `serde_json::Value`.
150fn synthesize_inline_composition(
151    parent_name: Option<&str>,
152    inline_types: &mut Vec<TokenStream>,
153    generate: impl FnOnce(&str, &mut Vec<TokenStream>) -> TokenStream,
154) -> TokenStream {
155    parent_name.map_or_else(
156        || quote! { ::serde_json::Value },
157        |name| {
158            let tokens = generate(name, inline_types);
159            inline_types.push(tokens);
160            let ident = format_ident!("{}", name.to_pascal_case());
161            quote! { #ident }
162        },
163    )
164}
165
166/// Convert a primitive `OpenAPI` type to a Rust type token stream.
167fn primitive_type_to_rust(
168    t: &Type,
169    parent_name: Option<&str>,
170    inline_types: &mut Vec<TokenStream>,
171    models: &std::collections::BTreeSet<String>,
172) -> TokenStream {
173    match t {
174        Type::Integer(i) => {
175            if i.format == openapiv3::VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32) {
176                quote! { i32 }
177            } else {
178                quote! { i64 }
179            }
180        }
181        Type::Number(n) => {
182            if n.format == openapiv3::VariantOrUnknownOrEmpty::Item(NumberFormat::Float) {
183                quote! { f32 }
184            } else {
185                quote! { f64 }
186            }
187        }
188        Type::String(s) => string_type_to_rust(s),
189        Type::Boolean(_) => quote! { bool },
190        Type::Array(a) => {
191            let item_ty = a.items.as_ref().map_or_else(
192                || quote! { ::serde_json::Value },
193                |items| {
194                    ref_or_to_inner_type_ctx(
195                        &items.clone().unbox(),
196                        parent_name,
197                        inline_types,
198                        models,
199                    )
200                },
201            );
202            quote! { ::std::vec::Vec<#item_ty> }
203        }
204        // Objects are handled in `schema_kind_to_type`, which has the full
205        // schema (description, synthesis context) available.
206        Type::Object(_) => quote! { ::serde_json::Value },
207    }
208}
209
210/// Map a string schema to its Rust type, specializing known `format` values.
211///
212/// `date-time`/`date`/`uuid` map to typed `chrono`/`uuid` types (re-exported
213/// through the facade so generated code can reference them as
214/// `::openapi_trait::…`); `binary` maps to `Vec<u8>`. Every other format —
215/// including `email` and unknown formats — falls back to `String`.
216///
217/// Note that `openapiv3::StringFormat` only models `Date`/`DateTime`/`Binary`/
218/// `Byte`/`Password`; `uuid` (and other non-standard formats) arrive as
219/// `VariantOrUnknownOrEmpty::Unknown`.
220fn string_type_to_rust(s: &StringType) -> TokenStream {
221    // String enums are handled by the caller via a dedicated enum type; here we
222    // only emit the scalar fallback.
223    if !s.enumeration.is_empty() {
224        return quote! { ::std::string::String };
225    }
226    match &s.format {
227        VariantOrUnknownOrEmpty::Item(StringFormat::Binary) => quote! { ::std::vec::Vec<u8> },
228        VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => {
229            quote! { ::openapi_trait::chrono::DateTime<::openapi_trait::chrono::Utc> }
230        }
231        VariantOrUnknownOrEmpty::Item(StringFormat::Date) => {
232            quote! { ::openapi_trait::chrono::NaiveDate }
233        }
234        VariantOrUnknownOrEmpty::Unknown(name) if name == "uuid" => {
235            quote! { ::openapi_trait::uuid::Uuid }
236        }
237        _ => quote! { ::std::string::String },
238    }
239}
240
241/// Convert an object schema to a Rust type.
242///
243/// - An object that declares `properties` is synthesized into a named top-level
244///   struct (via [`super::schemas::generate_object_struct`]) when a
245///   `parent_name` is available; the returned token stream references it.
246/// - An object with no declared `properties` but an `additionalProperties`
247///   entry is a map and becomes `HashMap<String, T>`.
248/// - Anything else (e.g. a free-form object with no schema info, or no parent
249///   name to synthesize against) falls back to untyped JSON.
250fn object_schema_to_type(
251    schema: &Schema,
252    obj: &ObjectType,
253    parent_name: Option<&str>,
254    inline_types: &mut Vec<TokenStream>,
255    models: &std::collections::BTreeSet<String>,
256) -> TokenStream {
257    if !obj.properties.is_empty() {
258        return synthesize_inline_composition(parent_name, inline_types, |name, sink| {
259            super::schemas::generate_object_struct(name, schema, obj, sink, models)
260        });
261    }
262    if let Some(ap) = &obj.additional_properties {
263        if let Some(value_ty) =
264            additional_properties_value_type(ap, parent_name, inline_types, models)
265        {
266            return quote! {
267                ::std::collections::HashMap<::std::string::String, #value_ty>
268            };
269        }
270    }
271    quote! { ::serde_json::Value }
272}
273
274/// Resolve an `additionalProperties` declaration to the value type `T` of the
275/// resulting `HashMap<String, T>`.
276///
277/// - `additionalProperties: true` → `serde_json::Value` (any value allowed).
278/// - `additionalProperties: false` → `None` (no extra properties; the caller
279///   should not emit a map).
280/// - `additionalProperties: <schema>` → the mapped type of that schema.
281#[must_use]
282pub fn additional_properties_value_type(
283    ap: &AdditionalProperties,
284    parent_name: Option<&str>,
285    inline_types: &mut Vec<TokenStream>,
286    models: &std::collections::BTreeSet<String>,
287) -> Option<TokenStream> {
288    match ap {
289        AdditionalProperties::Any(false) => None,
290        AdditionalProperties::Any(true) => Some(quote! { ::serde_json::Value }),
291        AdditionalProperties::Schema(schema) => Some(ref_or_to_inner_type_ctx(
292            schema,
293            parent_name,
294            inline_types,
295            models,
296        )),
297    }
298}
299
300/// Returns true when the schema is a string with enumeration values.
301#[must_use]
302pub fn is_string_enum(schema: &Schema) -> bool {
303    if let SchemaKind::Type(Type::String(s)) = &schema.schema_kind {
304        !s.enumeration.is_empty()
305    } else {
306        false
307    }
308}
309
310/// Extract enum values from a string schema (skipping None entries).
311#[must_use]
312pub fn string_enum_values(schema: &Schema) -> Vec<String> {
313    if let SchemaKind::Type(Type::String(s)) = &schema.schema_kind {
314        s.enumeration.iter().filter_map(Clone::clone).collect()
315    } else {
316        vec![]
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Build a `StringType` with the given `format`, treating `None` as no
325    /// format and a known marker for the unknown variants.
326    fn string_with_format(format: VariantOrUnknownOrEmpty<StringFormat>) -> StringType {
327        StringType {
328            format,
329            ..Default::default()
330        }
331    }
332
333    fn emitted(s: &StringType) -> String {
334        string_type_to_rust(s).to_string()
335    }
336
337    #[test]
338    fn date_time_maps_to_chrono_datetime() {
339        let s = string_with_format(VariantOrUnknownOrEmpty::Item(StringFormat::DateTime));
340        assert_eq!(
341            emitted(&s),
342            quote! { ::openapi_trait::chrono::DateTime<::openapi_trait::chrono::Utc> }.to_string()
343        );
344    }
345
346    #[test]
347    fn date_maps_to_chrono_naive_date() {
348        let s = string_with_format(VariantOrUnknownOrEmpty::Item(StringFormat::Date));
349        assert_eq!(
350            emitted(&s),
351            quote! { ::openapi_trait::chrono::NaiveDate }.to_string()
352        );
353    }
354
355    #[test]
356    fn uuid_unknown_format_maps_to_uuid() {
357        let s = string_with_format(VariantOrUnknownOrEmpty::Unknown("uuid".to_string()));
358        assert_eq!(
359            emitted(&s),
360            quote! { ::openapi_trait::uuid::Uuid }.to_string()
361        );
362    }
363
364    #[test]
365    fn binary_still_maps_to_byte_vec() {
366        let s = string_with_format(VariantOrUnknownOrEmpty::Item(StringFormat::Binary));
367        assert_eq!(emitted(&s), quote! { ::std::vec::Vec<u8> }.to_string());
368    }
369
370    #[test]
371    fn email_unknown_format_stays_string() {
372        let s = string_with_format(VariantOrUnknownOrEmpty::Unknown("email".to_string()));
373        assert_eq!(emitted(&s), quote! { ::std::string::String }.to_string());
374    }
375
376    #[test]
377    fn no_format_stays_string() {
378        let s = string_with_format(VariantOrUnknownOrEmpty::Empty);
379        assert_eq!(emitted(&s), quote! { ::std::string::String }.to_string());
380    }
381
382    #[test]
383    fn string_enum_stays_string_even_with_format() {
384        // Enums are emitted as dedicated enum types by the caller; the scalar
385        // mapping must not specialize them on `format`.
386        let s = StringType {
387            format: VariantOrUnknownOrEmpty::Unknown("uuid".to_string()),
388            enumeration: vec![Some("a".to_string()), Some("b".to_string())],
389            ..Default::default()
390        };
391        assert_eq!(emitted(&s), quote! { ::std::string::String }.to_string());
392    }
393}