Skip to main content

oapi_codegen/
config.rs

1//! Generator configuration, compatible with `oapi-codegen`'s YAML configuration files.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::path::PathBuf;
6
7use serde::Deserialize;
8use serde::Serialize;
9
10use crate::diagnostic::Warning;
11use crate::diagnostic::pointer;
12use crate::diagnostic::report_warnings;
13use crate::error::Error;
14use crate::error::Result;
15
16/// A generator configuration, mirroring the keys used by `oapi-codegen`.
17///
18/// Loading reports warnings for unknown keys and ignores them for compatibility.
19#[derive(Debug, Default, Clone, Deserialize, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub struct Config {
22    /// Target module/package name (informational for the Rust generator).
23    pub package: Option<String>,
24    /// Output file path for generated code, when configured.
25    pub output: Option<PathBuf>,
26    /// Which artifacts to generate.
27    #[serde(default)]
28    pub generate: Generate,
29    /// Output tuning options.
30    #[serde(default)]
31    pub output_options: OutputOptions,
32    /// Mapping of referenced spec files to external modules (server generation).
33    #[serde(default)]
34    pub import_mapping: BTreeMap<String, String>,
35}
36
37/// The set of artifacts a configuration requests.
38#[derive(Debug, Default, Clone, Deserialize, Serialize)]
39#[serde(rename_all = "kebab-case")]
40pub struct Generate {
41    /// Generate data models (structs/enums) from component schemas.
42    #[serde(default)]
43    pub models: bool,
44    /// Generate an axum server interface from the spec's paths.
45    #[serde(default)]
46    pub std_http_server: bool,
47    /// Generate a blocking `reqwest` client from the spec's paths.
48    #[serde(default)]
49    pub client: bool,
50    /// Embed the spec into the generated code (not yet implemented).
51    #[serde(default)]
52    pub embedded_spec: bool,
53    /// Emit constants and builder functions for the spec's `servers` URLs.
54    #[serde(default)]
55    pub server_urls: bool,
56}
57
58/// Config key of the [`Config::output_options`] section, as written in a configuration
59/// file. Must match the `kebab-case` serde name. Guarded by a deserialization
60/// test.
61pub(crate) const OUTPUT_OPTIONS_KEY: &str = "output-options";
62
63/// Config key of [`OutputOptions::response_type_suffix`], as written in a configuration
64/// file. Must match the `kebab-case` serde name. Guarded by a deserialization
65/// test.
66pub(crate) const RESPONSE_TYPE_SUFFIX_KEY: &str = "response-type-suffix";
67
68/// Seed for a response enum's name suffix when
69/// [`OutputOptions::response_type_suffix`] is unset.
70/// The `to_ident(_, Pascal)` call turns it into the `Response` that terminates every
71/// default `<Op>Response` enum.
72pub(crate) const DEFAULT_RESPONSE_SUFFIX: &str = "response";
73
74/// Config key of [`OutputOptions::type_name_suffix`], as written in a config
75/// file. Must match the `kebab-case` serde name; guarded by a deserialization
76/// test.
77pub(crate) const TYPE_NAME_SUFFIX_KEY: &str = "type-name-suffix";
78
79/// Output tuning options.
80#[derive(Debug, Default, Clone, Deserialize, Serialize)]
81#[serde(rename_all = "kebab-case")]
82pub struct OutputOptions {
83    /// Keep schemas that are not referenced (no pruning).
84    #[serde(default)]
85    pub skip_prune: bool,
86    /// Only generate operations tagged with one of these tags (empty = all).
87    #[serde(default)]
88    pub include_tags: Vec<String>,
89    /// Skip operations tagged with any of these tags.
90    #[serde(default)]
91    pub exclude_tags: Vec<String>,
92    /// Only generate operations whose `operationId` is one of these
93    /// (empty = all).
94    #[serde(default)]
95    pub include_operation_ids: Vec<String>,
96    /// Skip operations whose `operationId` is one of these.
97    #[serde(default)]
98    pub exclude_operation_ids: Vec<String>,
99    /// Remove these component schemas from the spec before lowering, so their
100    /// models are not generated. Filtering runs before pruning. If an excluded
101    /// schema is still referenced by a retained operation or schema, generation
102    /// can fail or emit a reference to a type that is not declared.
103    #[serde(default)]
104    pub exclude_schemas: Vec<String>,
105    /// Suffix appended to a per-operation response enum's name (default
106    /// `Response`). Set this to resolve a clash between a generated
107    /// `<Op>Response` enum and a component schema of the same name, mirroring
108    /// `oapi-codegen`'s `response-type-suffix`.
109    #[serde(default)]
110    pub response_type_suffix: Option<String>,
111    /// Suffix added to the second of two schema names that collapse onto one
112    /// Rust identifier. For example, `foo-bar` and `fooBar` both become `FooBar`.
113    ///
114    /// The default is unset. An unset suffix makes such a collision an error,
115    /// because the generator will not pick a name for one of two distinct
116    /// schemas. Use `x-rust-name` on the colliding schema first. That extension
117    /// marks one schema and records the name the author wants. This option is
118    /// for specs with many mechanical collisions, where one annotation per
119    /// schema costs too much.
120    ///
121    /// A set suffix must hold at least one letter or digit. Casing removes
122    /// punctuation, so a suffix such as `-` leaves the type name unchanged and
123    /// cannot resolve a collision. See [`crate::lower::type_renames`].
124    #[serde(default)]
125    pub type_name_suffix: Option<String>,
126}
127
128impl Config {
129    /// Load and parse a configuration file.
130    pub fn load(path: &Path) -> Result<Self> {
131        let text = std::fs::read_to_string(path).map_err(|source| {
132            return Error::ReadConfig {
133                path: path.display().to_string(),
134                source,
135            };
136        })?;
137        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
138            return Error::ParseConfig {
139                path: path.display().to_string(),
140                source,
141            };
142        })?;
143        let warnings = configuration_warnings(&value).map_err(|source| {
144            return Error::ParseConfig {
145                path: path.display().to_string(),
146                source,
147            };
148        })?;
149        report_warnings(&path.display().to_string(), &warnings);
150        let config: Config = serde_yaml::from_str(&text).map_err(|source| {
151            return Error::ParseConfig {
152                path: path.display().to_string(),
153                source,
154            };
155        })?;
156        return Ok(config);
157    }
158}
159
160fn configuration_warnings(value: &serde_yaml::Value) -> serde_yaml::Result<Vec<Warning>> {
161    let shape = serde_yaml::to_value(Config::default())?;
162    let mut warnings = Vec::new();
163    collect_unknown_keys(value, &shape, "", &mut warnings);
164    return Ok(warnings);
165}
166
167fn collect_unknown_keys(
168    value: &serde_yaml::Value,
169    shape: &serde_yaml::Value,
170    parent: &str,
171    warnings: &mut Vec<Warning>,
172) {
173    let (Some(mapping), Some(fields)) = (value.as_mapping(), shape.as_mapping()) else {
174        return;
175    };
176    // Empty default mappings contain user-defined keys.
177    if fields.is_empty() {
178        return;
179    }
180    for (key, value) in mapping {
181        let Some(name) = key.as_str() else {
182            continue;
183        };
184        let path = pointer(parent, name);
185        if let Some(field) = fields.get(key) {
186            collect_unknown_keys(value, field, &path, warnings);
187        } else {
188            warnings.push(Warning::new(path, "unknown configuration key is ignored"));
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn unknown_configuration_keys_warn() -> serde_yaml::Result<()> {
199        for (yaml, paths) in [
200            ("packge: demo", vec!["/packge"]),
201            ("generate: {modles: true}", vec!["/generate/modles"]),
202            ("output-options: {skip-prun: true}", vec!["/output-options/skip-prun"]),
203            ("a~/b: secret", vec!["/a~0~1b"]),
204            ("generate: {'~1/': true}", vec!["/generate/~01~1"]),
205            (
206                "packge: demo\ngenerate: {modles: true}\noutput-options: {skip-prun: true}",
207                vec!["/packge", "/generate/modles", "/output-options/skip-prun"],
208            ),
209        ] {
210            let value = serde_yaml::from_str(yaml)?;
211            let expected: Vec<_> = paths
212                .into_iter()
213                .map(|path| {
214                    return Warning::new(path, "unknown configuration key is ignored");
215                })
216                .collect();
217            assert_eq!(configuration_warnings(&value)?, expected, "{yaml}");
218            assert!(serde_yaml::from_value::<Config>(value).is_ok(), "{yaml}");
219        }
220        return Ok(());
221    }
222
223    #[test]
224    fn known_configuration_keys_do_not_warn() -> serde_yaml::Result<()> {
225        for yaml in [
226            "{}",
227            "package: demo\noutput: generated.rs",
228            "generate: {models: true, std-http-server: true, client: true, embedded-spec: false, server-urls: true}",
229            "import-mapping: {'./other.yaml': other, 'a~/b': module}",
230            "output-options: {include-tags: [pets], response-type-suffix: Resp, type-name-suffix: Alt}",
231        ] {
232            let value = serde_yaml::from_str(yaml)?;
233            assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
234            assert!(serde_yaml::from_value::<Config>(value).is_ok(), "{yaml}");
235        }
236        let defaults = serde_yaml::to_value(Config::default())?;
237        assert!(configuration_warnings(&defaults)?.is_empty());
238        return Ok(());
239    }
240
241    #[test]
242    fn invalid_configuration_values_remain_deserialization_errors() -> serde_yaml::Result<()> {
243        for yaml in ["null", "generate: null", "output-options: null", "import-mapping: null"] {
244            let value = serde_yaml::from_str(yaml)?;
245            assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
246        }
247        for yaml in [
248            "false",
249            "[]",
250            "generate: wrong",
251            "generate: {models: wrong}",
252            "output-options: []",
253            "output-options: {include-tags: false}",
254            "import-mapping: {other: []}",
255        ] {
256            let value = serde_yaml::from_str(yaml)?;
257            assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
258            assert!(serde_yaml::from_value::<Config>(value).is_err(), "{yaml}");
259        }
260        let value = serde_yaml::from_str("generate: {modles: true, models: wrong}")?;
261        assert_eq!(
262            configuration_warnings(&value)?,
263            vec![Warning::new("/generate/modles", "unknown configuration key is ignored")]
264        );
265        assert!(serde_yaml::from_value::<Config>(value).is_err());
266        return Ok(());
267    }
268
269    #[test]
270    fn config_keys_match_serde_names() {
271        let yaml =
272            format!("{OUTPUT_OPTIONS_KEY}:\n  {RESPONSE_TYPE_SUFFIX_KEY}: Resp\n  {TYPE_NAME_SUFFIX_KEY}: Alt\n",);
273        let config: Config = serde_yaml::from_str(&yaml).expect("config parses");
274        assert_eq!(
275            config.output_options.response_type_suffix.as_deref(),
276            Some("Resp"),
277            "OUTPUT_OPTIONS_KEY/RESPONSE_TYPE_SUFFIX_KEY drifted from the serde field names",
278        );
279        assert_eq!(
280            config.output_options.type_name_suffix.as_deref(),
281            Some("Alt"),
282            "TYPE_NAME_SUFFIX_KEY drifted from the serde field name",
283        );
284    }
285
286    #[test]
287    fn type_name_suffix_defaults_to_unset() {
288        // An unset suffix must mean "stop on a collision", so the default is
289        // `None` and not a fallback string.
290        let config: Config = serde_yaml::from_str("package: demo\n").expect("config parses");
291        assert_eq!(config.output_options.type_name_suffix, None);
292    }
293
294    #[test]
295    fn default_response_suffix_pascalizes_to_response() {
296        use crate::naming::Case;
297        use crate::naming::to_ident;
298
299        assert_eq!(to_ident(DEFAULT_RESPONSE_SUFFIX, Case::Pascal).logical(), "Response");
300    }
301}