Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ differ:
- Typed status-code response enums instead of response structs.
- Token-based generation (`quote`/`syn`), not user-overridable `text/template`.
- Blocking `reqwest` client — no async runtime forced on consumers.
- Reuses your `oapi-codegen` YAML config — unknown keys are ignored.
- Reuses your `oapi-codegen` YAML config — unknown keys produce warnings and are ignored.
- Explicit over implicit: `--config-file` is required, and an output path must be given via `--output-file` or the
config's `output:` key; empty generation fails loudly (Go defaults these and prints to stdout).
- Fails fast where Go assumes: where `oapi-codegen` silently defaults, guesses, or ignores an ambiguity, this generator
Expand Down
131 changes: 126 additions & 5 deletions crates/oapi-codegen/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ use std::path::Path;
use std::path::PathBuf;

use serde::Deserialize;
use serde::Serialize;

use crate::diagnostic::Warning;
use crate::diagnostic::pointer;
use crate::diagnostic::report_warnings;
use crate::error::Error;
use crate::error::Result;

/// A generator configuration, mirroring the keys used by `oapi-codegen`.
///
/// Unknown keys are ignored so that existing `oapi-codegen` configurations can be used
/// as-is. Only the subset relevant to this tool is interpreted.
#[derive(Debug, Default, Clone, Deserialize)]
/// Loading reports warnings for unknown keys and ignores them for compatibility.
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
/// Target module/package name (informational for the Rust generator).
Expand All @@ -32,7 +35,7 @@ pub struct Config {
}

/// The set of artifacts a configuration requests.
#[derive(Debug, Default, Clone, Deserialize)]
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Generate {
/// Generate data models (structs/enums) from component schemas.
Expand Down Expand Up @@ -74,7 +77,7 @@ pub(crate) const DEFAULT_RESPONSE_SUFFIX: &str = "response";
pub(crate) const TYPE_NAME_SUFFIX_KEY: &str = "type-name-suffix";

/// Output tuning options.
#[derive(Debug, Default, Clone, Deserialize)]
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct OutputOptions {
/// Keep schemas that are not referenced (no pruning).
Expand Down Expand Up @@ -131,6 +134,19 @@ impl Config {
source,
};
})?;
let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseConfig {
path: path.display().to_string(),
source,
};
})?;
let warnings = configuration_warnings(&value).map_err(|source| {
return Error::ParseConfig {
path: path.display().to_string(),
source,
};
})?;
report_warnings(&path.display().to_string(), &warnings);
let config: Config = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseConfig {
path: path.display().to_string(),
Expand All @@ -141,10 +157,115 @@ impl Config {
}
}

fn configuration_warnings(value: &serde_yaml::Value) -> serde_yaml::Result<Vec<Warning>> {
let shape = serde_yaml::to_value(Config::default())?;
let mut warnings = Vec::new();
collect_unknown_keys(value, &shape, "", &mut warnings);
return Ok(warnings);
}

fn collect_unknown_keys(
value: &serde_yaml::Value,
shape: &serde_yaml::Value,
parent: &str,
warnings: &mut Vec<Warning>,
) {
let (Some(mapping), Some(fields)) = (value.as_mapping(), shape.as_mapping()) else {
return;
};
// Empty default mappings contain user-defined keys.
if fields.is_empty() {
return;
}
for (key, value) in mapping {
let Some(name) = key.as_str() else {
continue;
};
let path = pointer(parent, name);
if let Some(field) = fields.get(key) {
collect_unknown_keys(value, field, &path, warnings);
} else {
warnings.push(Warning::new(path, "unknown configuration key is ignored"));
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn unknown_configuration_keys_warn() -> serde_yaml::Result<()> {
for (yaml, paths) in [
("packge: demo", vec!["/packge"]),
("generate: {modles: true}", vec!["/generate/modles"]),
("output-options: {skip-prun: true}", vec!["/output-options/skip-prun"]),
("a~/b: secret", vec!["/a~0~1b"]),
("generate: {'~1/': true}", vec!["/generate/~01~1"]),
(
"packge: demo\ngenerate: {modles: true}\noutput-options: {skip-prun: true}",
vec!["/packge", "/generate/modles", "/output-options/skip-prun"],
),
] {
let value = serde_yaml::from_str(yaml)?;
let expected: Vec<_> = paths
.into_iter()
.map(|path| {
return Warning::new(path, "unknown configuration key is ignored");
})
.collect();
assert_eq!(configuration_warnings(&value)?, expected, "{yaml}");
assert!(serde_yaml::from_value::<Config>(value).is_ok(), "{yaml}");
}
return Ok(());
}

#[test]
fn known_configuration_keys_do_not_warn() -> serde_yaml::Result<()> {
for yaml in [
"{}",
"package: demo\noutput: generated.rs",
"generate: {models: true, std-http-server: true, client: true, embedded-spec: false, server-urls: true}",
"import-mapping: {'./other.yaml': other, 'a~/b': module}",
"output-options: {include-tags: [pets], response-type-suffix: Resp, type-name-suffix: Alt}",
] {
let value = serde_yaml::from_str(yaml)?;
assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
assert!(serde_yaml::from_value::<Config>(value).is_ok(), "{yaml}");
}
let defaults = serde_yaml::to_value(Config::default())?;
assert!(configuration_warnings(&defaults)?.is_empty());
return Ok(());
}

#[test]
fn invalid_configuration_values_remain_deserialization_errors() -> serde_yaml::Result<()> {
for yaml in ["null", "generate: null", "output-options: null", "import-mapping: null"] {
let value = serde_yaml::from_str(yaml)?;
assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
}
for yaml in [
"false",
"[]",
"generate: wrong",
"generate: {models: wrong}",
"output-options: []",
"output-options: {include-tags: false}",
"import-mapping: {other: []}",
] {
let value = serde_yaml::from_str(yaml)?;
assert!(configuration_warnings(&value)?.is_empty(), "{yaml}");
assert!(serde_yaml::from_value::<Config>(value).is_err(), "{yaml}");
}
let value = serde_yaml::from_str("generate: {modles: true, models: wrong}")?;
assert_eq!(
configuration_warnings(&value)?,
vec![Warning::new("/generate/modles", "unknown configuration key is ignored")]
);
assert!(serde_yaml::from_value::<Config>(value).is_err());
return Ok(());
}

#[test]
fn config_keys_match_serde_names() {
let yaml =
Expand Down
3 changes: 3 additions & 0 deletions crates/oapi-codegen/src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ pub fn report_install_failed(dep: &Dependency, detail: &str) {
/// Build the context-specific hints shown after an error message.
fn hints_for(err: &Error) -> Vec<String> {
match err {
Error::InvalidSpec { .. } => {
return vec!["Use the OpenAPI 3.0 spelling and location for this key.".to_owned()];
}
Error::ReadSpec { path, source } => {
return io_read_hints("spec file", path, source.kind());
}
Expand Down
Loading
Loading