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