Skip to main content

openapi_trait_shared/codegen/
compositions.rs

1//! Generators for `oneOf` / `allOf` / `anyOf` schema compositions.
2//!
3//! - `oneOf` → Rust enum. Tagged via `#[serde(tag = "...")]` when the schema
4//!   carries a `discriminator`; otherwise `#[serde(untagged)]`.
5//! - `anyOf` → Rust enum, always `#[serde(untagged)]` (`oneOf` semantics without
6//!   the exclusivity guarantee is rarely useful in typed Rust).
7//! - `allOf` → Rust struct that merges its branches: `$ref` branches become
8//!   `#[serde(flatten)]` fields; inline object branches inline their properties
9//!   directly.
10//!
11//! `SchemaKind::Not` and `SchemaKind::Any` remain unsupported and continue to
12//! fall back to `serde_json::Value` in [`super::types`].
13//!
14//! Inline (non-top-level) compositions are synthesized into top-level types
15//! using a deterministic name derived from the enclosing context (object name +
16//! property, operation id + role). The accumulated definitions are emitted
17//! alongside the explicit `components/schemas` items so all generated types
18//! live at module scope.
19
20use heck::ToPascalCase;
21use openapiv3::{Discriminator, ReferenceOr, Schema, SchemaKind, Type};
22use proc_macro2::TokenStream;
23use quote::{format_ident, quote};
24
25use super::schemas::{doc_attr, object_field_tokens};
26use super::types::{ref_to_ident, schema_to_rust_type_ctx};
27
28/// Generate a Rust enum type for a `oneOf` composition.
29///
30/// When a `discriminator` is present we emit an internally tagged enum
31/// (`#[serde(tag = "...")]`); otherwise an untagged enum.
32#[must_use]
33pub fn generate_one_of(
34    name: &str,
35    variants: &[ReferenceOr<Schema>],
36    discriminator: Option<&Discriminator>,
37    description: Option<&String>,
38    inline_types: &mut Vec<TokenStream>,
39) -> TokenStream {
40    generate_enum(name, variants, discriminator, description, inline_types)
41}
42
43/// Generate a Rust enum type for an `anyOf` composition.
44///
45/// Always untagged — we treat `anyOf` like a non-discriminated `oneOf` because
46/// strict `anyOf` semantics (multiple branches may match) do not have a clean
47/// representation in typed Rust.
48#[must_use]
49pub fn generate_any_of(
50    name: &str,
51    variants: &[ReferenceOr<Schema>],
52    description: Option<&String>,
53    inline_types: &mut Vec<TokenStream>,
54) -> TokenStream {
55    generate_enum(name, variants, None, description, inline_types)
56}
57
58/// Generate a Rust struct type for an `allOf` composition.
59///
60/// `$ref` branches become `#[serde(flatten)]` fields of the referenced type.
61/// Inline object branches contribute their properties directly. Any other inline
62/// branch falls back to `serde_json::Value` (we do not synthesize nested types
63/// here — those are handled by the enum path via [`schema_to_rust_type_ctx`]).
64#[must_use]
65pub fn generate_all_of(
66    name: &str,
67    variants: &[ReferenceOr<Schema>],
68    description: Option<&String>,
69    inline_types: &mut Vec<TokenStream>,
70) -> TokenStream {
71    let ident = format_ident!("{}", name.to_pascal_case());
72    let doc = doc_attr(&description.cloned());
73
74    let mut fields: Vec<TokenStream> = Vec::new();
75    let mut ref_field_counter = 0usize;
76
77    for branch in variants {
78        match branch {
79            ReferenceOr::Reference { reference } => {
80                ref_field_counter += 1;
81                let field_ident = format_ident!("inner_{}", ref_field_counter);
82                let ty = ref_to_ident(reference);
83                fields.push(quote! {
84                    #[serde(flatten)]
85                    pub #field_ident: #ty,
86                });
87            }
88            ReferenceOr::Item(schema) => {
89                if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
90                    for (prop_name, prop_ref) in &obj.properties {
91                        let is_required = obj.required.iter().any(|r| r == prop_name);
92                        fields.push(object_field_tokens(
93                            prop_name,
94                            &prop_ref.clone().unbox(),
95                            is_required,
96                            name,
97                            inline_types,
98                        ));
99                    }
100                } else {
101                    // Nested composition or non-object inline branch: flatten a
102                    // synthesized type when possible, otherwise fall back.
103                    ref_field_counter += 1;
104                    let field_ident = format_ident!("inner_{}", ref_field_counter);
105                    let parent = format!("{name}Inner{ref_field_counter}");
106                    let ty = schema_to_rust_type_ctx(
107                        &ReferenceOr::Item(schema.clone()),
108                        true,
109                        Some(&parent),
110                        inline_types,
111                    );
112                    fields.push(quote! {
113                        #[serde(flatten)]
114                        pub #field_ident: #ty,
115                    });
116                }
117            }
118        }
119    }
120
121    quote! {
122        #doc
123        #[derive(
124            ::core::fmt::Debug,
125            ::core::clone::Clone,
126            ::serde::Serialize,
127            ::serde::Deserialize,
128        )]
129        pub struct #ident {
130            #(#fields)*
131        }
132    }
133}
134
135/// Shared helper backing [`generate_one_of`] and [`generate_any_of`].
136fn generate_enum(
137    name: &str,
138    variants: &[ReferenceOr<Schema>],
139    discriminator: Option<&Discriminator>,
140    description: Option<&String>,
141    inline_types: &mut Vec<TokenStream>,
142) -> TokenStream {
143    let ident = format_ident!("{}", name.to_pascal_case());
144    let doc = doc_attr(&description.cloned());
145
146    let serde_attr = discriminator.map_or_else(
147        || quote! { #[serde(untagged)] },
148        |d| {
149            let tag = &d.property_name;
150            quote! { #[serde(tag = #tag)] }
151        },
152    );
153
154    let variant_tokens: Vec<TokenStream> = variants
155        .iter()
156        .enumerate()
157        .map(|(idx, branch)| build_enum_variant(name, idx, branch, discriminator, inline_types))
158        .collect();
159
160    quote! {
161        #doc
162        #[derive(
163            ::core::fmt::Debug,
164            ::core::clone::Clone,
165            ::serde::Serialize,
166            ::serde::Deserialize,
167        )]
168        #serde_attr
169        pub enum #ident {
170            #(#variant_tokens,)*
171        }
172    }
173}
174
175/// Build a single enum variant for a `oneOf` / `anyOf` branch.
176fn build_enum_variant(
177    parent: &str,
178    idx: usize,
179    branch: &ReferenceOr<Schema>,
180    discriminator: Option<&Discriminator>,
181    inline_types: &mut Vec<TokenStream>,
182) -> TokenStream {
183    match branch {
184        ReferenceOr::Reference { reference } => {
185            let target_name = reference.rsplit('/').next().unwrap_or(reference);
186            let variant_ident = format_ident!("{}", target_name.to_pascal_case());
187            let ty = ref_to_ident(reference);
188            // For tagged enums the rename governs the discriminator value on
189            // the wire; for untagged enums the variant name is structural and
190            // the rename is a no-op (but harmless).
191            let rename_attr = discriminator
192                .and_then(|d| discriminator_key_for_ref(d, reference))
193                .map_or_else(|| quote! {}, |k| quote! { #[serde(rename = #k)] });
194            quote! {
195                #rename_attr
196                #variant_ident(#ty)
197            }
198        }
199        ReferenceOr::Item(schema) => {
200            let variant_ident = format_ident!("Variant{}", idx + 1);
201            let parent_for_synth = format!("{parent}Variant{}", idx + 1);
202            let ty = schema_to_rust_type_ctx(
203                &ReferenceOr::Item(schema.clone()),
204                true,
205                Some(&parent_for_synth),
206                inline_types,
207            );
208            quote! {
209                #variant_ident(#ty)
210            }
211        }
212    }
213}
214
215/// Look up the discriminator mapping key (the serde tag value) for a given
216/// `$ref`. Matches both fully qualified refs and bare component names, mirroring
217/// the `OpenAPI` spec which permits either form in `discriminator.mapping`.
218fn discriminator_key_for_ref(d: &Discriminator, reference: &str) -> Option<String> {
219    let bare = reference.rsplit('/').next().unwrap_or(reference);
220    d.mapping
221        .iter()
222        .find(|(_, v)| *v == reference || *v == bare)
223        .map(|(k, _)| k.clone())
224}