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, model_derives, 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    models: &std::collections::BTreeSet<String>,
40) -> TokenStream {
41    generate_enum(
42        name,
43        variants,
44        discriminator,
45        description,
46        inline_types,
47        models,
48    )
49}
50
51/// Generate a Rust enum type for an `anyOf` composition.
52///
53/// Always untagged — we treat `anyOf` like a non-discriminated `oneOf` because
54/// strict `anyOf` semantics (multiple branches may match) do not have a clean
55/// representation in typed Rust.
56#[must_use]
57pub fn generate_any_of(
58    name: &str,
59    variants: &[ReferenceOr<Schema>],
60    description: Option<&String>,
61    inline_types: &mut Vec<TokenStream>,
62    models: &std::collections::BTreeSet<String>,
63) -> TokenStream {
64    generate_enum(name, variants, None, description, inline_types, models)
65}
66
67/// Generate a Rust struct type for an `allOf` composition.
68///
69/// `$ref` branches become `#[serde(flatten)]` fields of the referenced type.
70/// Inline object branches contribute their properties directly. Any other inline
71/// branch falls back to `serde_json::Value` (we do not synthesize nested types
72/// here — those are handled by the enum path via [`schema_to_rust_type_ctx`]).
73#[must_use]
74pub fn generate_all_of(
75    name: &str,
76    variants: &[ReferenceOr<Schema>],
77    description: Option<&String>,
78    inline_types: &mut Vec<TokenStream>,
79    models: &std::collections::BTreeSet<String>,
80) -> TokenStream {
81    let ident = format_ident!("{}", name.to_pascal_case());
82    let doc = doc_attr(&description.cloned());
83
84    let mut fields: Vec<TokenStream> = Vec::new();
85    let mut ref_field_counter = 0usize;
86
87    for branch in variants {
88        match branch {
89            ReferenceOr::Reference { reference } => {
90                ref_field_counter += 1;
91                let field_ident = format_ident!("inner_{}", ref_field_counter);
92                let ty = ref_to_ident(reference);
93                fields.push(quote! {
94                    #[serde(flatten)]
95                    pub #field_ident: #ty,
96                });
97            }
98            ReferenceOr::Item(schema) => {
99                if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
100                    for (prop_name, prop_ref) in &obj.properties {
101                        let is_required = obj.required.iter().any(|r| r == prop_name);
102                        fields.push(object_field_tokens(
103                            prop_name,
104                            &prop_ref.clone().unbox(),
105                            is_required,
106                            name,
107                            inline_types,
108                            models,
109                        ));
110                    }
111                } else {
112                    // Nested composition or non-object inline branch: flatten a
113                    // synthesized type when possible, otherwise fall back.
114                    ref_field_counter += 1;
115                    let field_ident = format_ident!("inner_{}", ref_field_counter);
116                    let parent = format!("{name}Inner{ref_field_counter}");
117                    let ty = schema_to_rust_type_ctx(
118                        &ReferenceOr::Item(schema.clone()),
119                        true,
120                        Some(&parent),
121                        inline_types,
122                        models,
123                    );
124                    fields.push(quote! {
125                        #[serde(flatten)]
126                        pub #field_ident: #ty,
127                    });
128                }
129            }
130        }
131    }
132
133    let derives = model_derives();
134    quote! {
135        #doc
136        #derives
137        pub struct #ident {
138            #(#fields)*
139        }
140    }
141}
142
143/// Shared helper backing [`generate_one_of`] and [`generate_any_of`].
144fn generate_enum(
145    name: &str,
146    variants: &[ReferenceOr<Schema>],
147    discriminator: Option<&Discriminator>,
148    description: Option<&String>,
149    inline_types: &mut Vec<TokenStream>,
150    models: &std::collections::BTreeSet<String>,
151) -> TokenStream {
152    let ident = format_ident!("{}", name.to_pascal_case());
153    let doc = doc_attr(&description.cloned());
154
155    let serde_attr = discriminator.map_or_else(
156        || quote! { #[serde(untagged)] },
157        |d| {
158            let tag = &d.property_name;
159            quote! { #[serde(tag = #tag)] }
160        },
161    );
162
163    let variant_tokens: Vec<TokenStream> = variants
164        .iter()
165        .enumerate()
166        .map(|(idx, branch)| {
167            build_enum_variant(name, idx, branch, discriminator, inline_types, models)
168        })
169        .collect();
170
171    let derives = model_derives();
172    quote! {
173        #doc
174        #derives
175        #serde_attr
176        pub enum #ident {
177            #(#variant_tokens,)*
178        }
179    }
180}
181
182/// Build a single enum variant for a `oneOf` / `anyOf` branch.
183fn build_enum_variant(
184    parent: &str,
185    idx: usize,
186    branch: &ReferenceOr<Schema>,
187    discriminator: Option<&Discriminator>,
188    inline_types: &mut Vec<TokenStream>,
189    models: &std::collections::BTreeSet<String>,
190) -> TokenStream {
191    match branch {
192        ReferenceOr::Reference { reference } => {
193            let target_name = reference.rsplit('/').next().unwrap_or(reference);
194            let variant_ident = format_ident!("{}", target_name.to_pascal_case());
195            let ty = ref_to_ident(reference);
196            // For tagged enums the rename governs the discriminator value on
197            // the wire; for untagged enums the variant name is structural and
198            // the rename is a no-op (but harmless).
199            let rename_attr = discriminator
200                .and_then(|d| discriminator_key_for_ref(d, reference))
201                .map_or_else(|| quote! {}, |k| quote! { #[serde(rename = #k)] });
202            quote! {
203                #rename_attr
204                #variant_ident(#ty)
205            }
206        }
207        ReferenceOr::Item(schema) => {
208            let variant_ident = format_ident!("Variant{}", idx + 1);
209            let parent_for_synth = format!("{parent}Variant{}", idx + 1);
210            let ty = schema_to_rust_type_ctx(
211                &ReferenceOr::Item(schema.clone()),
212                true,
213                Some(&parent_for_synth),
214                inline_types,
215                models,
216            );
217            quote! {
218                #variant_ident(#ty)
219            }
220        }
221    }
222}
223
224/// Look up the discriminator mapping key (the serde tag value) for a given
225/// `$ref`. Matches both fully qualified refs and bare component names, mirroring
226/// the `OpenAPI` spec which permits either form in `discriminator.mapping`.
227fn discriminator_key_for_ref(d: &Discriminator, reference: &str) -> Option<String> {
228    let bare = reference.rsplit('/').next().unwrap_or(reference);
229    d.mapping
230        .iter()
231        .find(|(_, v)| *v == reference || *v == bare)
232        .map(|(k, _)| k.clone())
233}