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