Skip to main content

openapi_to_rust/
derives.rs

1//! Opt-in additional derives for generated models and HTTP parameter enums.
2
3use crate::{CodeGenerator, GeneratorError, Result, SchemaAnalysis, analysis::SchemaType};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6use syn::{Attribute, Item, Meta, Path, Token, parse::Parser, punctuated::Punctuated};
7
8/// Additive derives, configured by `[generator.derives]`.
9///
10/// Empty defaults preserve existing output. Derives are macro paths, such as
11/// `Ord` or `my_crate::MyDerive`; consumers provide custom macro dependencies.
12/// Required trait implementations on fields and referenced models are the
13/// consumer's responsibility, and are checked by Rust rather than inferred.
14#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
15#[serde(default, deny_unknown_fields)]
16pub struct DerivesConfig {
17    /// Applies to closed/extensible string enums, including hoisted inline
18    /// enums and HTTP parameter enums. Payload unions are not string enums.
19    pub string_enums: Vec<String>,
20    /// Additional derives keyed by the exact emitted Rust type name.
21    /// Valid types omitted by operation selection or pruning are ignored.
22    /// Unknown/ambiguous names and type aliases are rejected.
23    pub types: BTreeMap<String, Vec<String>>,
24}
25
26#[derive(Clone, Copy)]
27pub(crate) enum DeriveTarget {
28    StringEnum,
29    Model,
30    Alias,
31}
32
33fn validation(message: impl Into<String>) -> GeneratorError {
34    GeneratorError::ValidationError(message.into())
35}
36
37fn parse_path(value: &str) -> Result<Path> {
38    Path::parse_mod_style
39        .parse_str(value)
40        .map_err(|error| validation(format!("Invalid derive macro path `{value}`: {error}")))
41}
42
43// Only equate known standard spellings. Custom macros with the same final
44// identifier may come from different crates and must remain distinct.
45fn identity(path: &Path) -> String {
46    let name = path
47        .segments
48        .iter()
49        .map(|segment| segment.ident.to_string())
50        .collect::<Vec<_>>()
51        .join("::");
52    match name.as_str() {
53        "Debug" | "std::fmt::Debug" | "core::fmt::Debug" => "Debug",
54        "Display" | "std::fmt::Display" | "core::fmt::Display" => "Display",
55        "Clone" | "std::clone::Clone" | "core::clone::Clone" => "Clone",
56        "Copy" | "std::marker::Copy" | "core::marker::Copy" => "Copy",
57        "Default" | "std::default::Default" | "core::default::Default" => "Default",
58        "PartialEq" | "std::cmp::PartialEq" | "core::cmp::PartialEq" => "PartialEq",
59        "Eq" | "std::cmp::Eq" | "core::cmp::Eq" => "Eq",
60        "PartialOrd" | "std::cmp::PartialOrd" | "core::cmp::PartialOrd" => "PartialOrd",
61        "Ord" | "std::cmp::Ord" | "core::cmp::Ord" => "Ord",
62        "Hash" | "std::hash::Hash" | "core::hash::Hash" => "Hash",
63        "Serialize" | "serde::Serialize" => "Serialize",
64        "Deserialize" | "serde::Deserialize" => "Deserialize",
65        "specta::Type" => "specta::Type",
66        _ if path.leading_colon.is_some() => return format!("::{name}"),
67        _ => return name,
68    }
69    .to_string()
70}
71
72fn collect_derives(meta: &Meta, existing: &mut BTreeSet<String>) -> syn::Result<()> {
73    if let Meta::List(list) = meta {
74        if list.path.is_ident("derive") {
75            let paths = list.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated)?;
76            existing.extend(paths.iter().map(identity));
77        } else if list.path.is_ident("cfg_attr") {
78            let nested = list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
79            // Respect existing conditional derives, particularly Specta's
80            // feature gate, rather than adding an unconditional duplicate.
81            for meta in nested.iter().skip(1) {
82                collect_derives(meta, existing)?;
83            }
84        }
85    }
86    Ok(())
87}
88
89impl DerivesConfig {
90    pub(crate) fn is_empty(&self) -> bool {
91        self.string_enums.is_empty() && self.types.is_empty()
92    }
93
94    pub(crate) fn validate_paths(&self) -> Result<()> {
95        for (scope, values) in std::iter::once(("string_enums".to_string(), &self.string_enums))
96            .chain(
97                self.types
98                    .iter()
99                    .map(|(name, values)| (format!("types.{name}"), values)),
100            )
101        {
102            for value in values {
103                parse_path(value)
104                    .map_err(|error| validation(format!("generator.derives.{scope}: {error}")))?;
105            }
106        }
107        Ok(())
108    }
109
110    pub(crate) fn apply(
111        &self,
112        file: &mut syn::File,
113        targets: &BTreeMap<String, DeriveTarget>,
114    ) -> Result<()> {
115        if self.is_empty() {
116            return Ok(());
117        }
118        // Inspect the existing AST so manual wire implementations stay intact.
119        let mut manual: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
120        for item in &file.items {
121            if let Item::Impl(item) = item {
122                if let (Some((_, trait_path, _)), syn::Type::Path(self_type)) =
123                    (&item.trait_, item.self_ty.as_ref())
124                {
125                    if let Some(segment) = self_type.path.segments.last() {
126                        manual
127                            .entry(segment.ident.to_string())
128                            .or_default()
129                            .insert(identity(trait_path));
130                    }
131                }
132            }
133        }
134        for item in &mut file.items {
135            let (name, attrs): (String, &mut Vec<Attribute>) = match item {
136                Item::Struct(item) => (item.ident.to_string(), &mut item.attrs),
137                Item::Enum(item) => (item.ident.to_string(), &mut item.attrs),
138                _ => continue,
139            };
140            let Some(target) = targets.get(&name) else {
141                continue;
142            };
143            let requested = self.types.get(&name).into_iter().flatten();
144            let grouped = self
145                .string_enums
146                .iter()
147                .filter(|_| matches!(target, DeriveTarget::StringEnum));
148            let mut existing = BTreeSet::new();
149            for attr in attrs.iter() {
150                collect_derives(&attr.meta, &mut existing).map_err(|error| {
151                    GeneratorError::CodeGenError(format!(
152                        "Cannot inspect derives on `{name}`: {error}"
153                    ))
154                })?;
155            }
156            let mut additions = Vec::new();
157            for value in grouped.chain(requested) {
158                let path = parse_path(value)?;
159                let key = identity(&path);
160                if manual
161                    .get(&name)
162                    .is_some_and(|traits| traits.contains(&key))
163                {
164                    return Err(validation(format!(
165                        "generator.derives: cannot derive `{value}` on `{name}` because the generator already implements that trait manually"
166                    )));
167                }
168                if existing.insert(key) {
169                    additions.push(path);
170                }
171            }
172            if !additions.is_empty() {
173                attrs.push(syn::parse_quote!(#[derive(#(#additions),*)]));
174            }
175        }
176        Ok(())
177    }
178}
179
180impl CodeGenerator {
181    pub(crate) fn model_derive_targets(
182        &self,
183        analysis: &SchemaAnalysis,
184    ) -> BTreeMap<String, DeriveTarget> {
185        analysis
186            .schemas
187            .values()
188            .filter_map(|schema| {
189                let target = match &schema.schema_type {
190                    SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => {
191                        DeriveTarget::StringEnum
192                    }
193                    SchemaType::Object { .. }
194                    | SchemaType::Composition { .. }
195                    | SchemaType::Union { .. }
196                    | SchemaType::DiscriminatedUnion { .. } => DeriveTarget::Model,
197                    SchemaType::Reference { target } if *target == schema.name => return None,
198                    _ => DeriveTarget::Alias,
199                };
200                Some((self.to_rust_type_name(&schema.name), target))
201            })
202            .collect()
203    }
204
205    pub(crate) fn parameter_derive_targets(
206        &self,
207        operations: &[&crate::analysis::OperationInfo],
208    ) -> BTreeMap<String, DeriveTarget> {
209        operations
210            .iter()
211            .flat_map(|operation| &operation.parameters)
212            .filter(|parameter| parameter.enum_values.is_some())
213            .map(|parameter| (parameter.rust_type.clone(), DeriveTarget::StringEnum))
214            .collect()
215    }
216
217    pub(crate) fn validate_derives(&self, analysis: &SchemaAnalysis) -> Result<()> {
218        let config = &self.config().derives;
219        if config.is_empty() {
220            return Ok(());
221        }
222        config.validate_paths()?;
223        // Validate before pruning, allowing one config to be reused across
224        // operation selections without turning omitted targets into typos.
225        let models = self.model_derive_targets(analysis);
226        let operations: Vec<_> = analysis.operations.values().collect();
227        let parameters = self.parameter_derive_targets(&operations);
228        for name in config.types.keys() {
229            if analysis
230                .schemas
231                .values()
232                .filter(|schema| self.to_rust_type_name(&schema.name) == *name)
233                .count()
234                > 1
235            {
236                return Err(validation(format!(
237                    "generator.derives.types: `{name}` is ambiguous between multiple model schemas"
238                )));
239            }
240            match (models.get(name), parameters.get(name)) {
241                (Some(_), Some(_)) => {
242                    return Err(validation(format!(
243                        "generator.derives.types: `{name}` is ambiguous between a model and an HTTP parameter enum"
244                    )));
245                }
246                (Some(DeriveTarget::Alias), _) => {
247                    return Err(validation(format!(
248                        "generator.derives.types: `{name}` is a type alias; derives require a struct or enum"
249                    )));
250                }
251                (None, None) => {
252                    return Err(validation(format!(
253                        "generator.derives.types: unknown generated type `{name}`; use the emitted Rust type name"
254                    )));
255                }
256                _ => {}
257            }
258        }
259        Ok(())
260    }
261}