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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ dist/
# Rust
target/
output/

# A consumer of this generator does not commit its output, so the default is to
# ignore it. This repository is the exception: it commits golden files and the
# example output, and the tests compare against them. The rule matches at any
# depth, so those two directories need an explicit exception, or a new file in
# them is dropped without a message. Git applies an ignore rule to an untracked
# file only, so the files already committed stayed visible and hid this.
generated/
!crates/oapi-codegen/tests/generated/
!crates/oapi-codegen/tests/generated/**
!examples/bookstore/generated/
!examples/bookstore/generated/**

# Node (Prettier / Commitlint installed in CI)
node_modules/
5 changes: 4 additions & 1 deletion crates/oapi-codegen/src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ fn hints_for(err: &Error) -> Vec<String> {
"Declare a parameter with `name: {name}`, `in: path`, `required: true`, or remove `{{{name}}}` from the path."
)];
}
Error::TypeNameCollision { hint, .. }
Error::UnsupportedSpecVersion { hint, .. }
| Error::UnsupportedSpecKey { hint, .. }
| Error::UnsupportedContentType { hint, .. }
| Error::TypeNameCollision { hint, .. }
| Error::DuplicateTypeName { hint, .. }
| Error::OperationTypeCollision { hint, .. }
| Error::SchemaNameCollision { hint, .. }
Expand Down
11 changes: 11 additions & 0 deletions crates/oapi-codegen/src/emit/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,20 @@ pub(crate) fn emit_struct(strukt: &Struct, serde: SerdeDerives) -> Result<TokenS
None => quote! {},
};

// `additionalProperties: false` becomes `deny_unknown_fields`. The attribute
// is read by the `Deserialize` derive only, so it is gated on that derive and
// not on `has_serde`. A serialize-only type reads no unknown key, so it has
// none to deny, and the attribute on it would be orphaned.
let deny_unknown = if strukt.deny_unknown_fields && serde.deserialize {
quote! { #[serde(deny_unknown_fields)] }
} else {
quote! {}
};

return Ok(quote! {
#doc
#derives
#deny_unknown
#deprecated
pub struct #name {
#(#fields)*
Expand Down
1 change: 1 addition & 0 deletions crates/oapi-codegen/src/emit/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ mod tests {
deprecated: None,
fields,
additional_properties: None,
deny_unknown_fields: false,
});
}

Expand Down
78 changes: 78 additions & 0 deletions crates/oapi-codegen/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,37 @@ pub enum Error {
source: std::io::Error,
},

/// The document declares an OpenAPI version the generator does not read.
///
/// Only `3.0.x` is supported. A newer document is rejected and not read as a
/// 3.0 document, because the dialects overlap: one whose every construct
/// happens to parse as 3.0 would generate quietly, and one newer construct in
/// the same file would fail with a `serde` message that names no version.
UnsupportedSpecVersion {
/// The document that declares it, as a path or as the `$ref` that
/// reached it. Every parsed document is checked, so the message must
/// name which one failed.
document: String,
/// The `openapi:` value the document declares.
version: String,
/// What the generator reads instead.
hint: String,
},

/// The document declares a top-level key the generator cannot generate from.
///
/// `webhooks:` is the case. It is a 3.1 key that carries operations, and a
/// generator that ignores it emits no handler for any of them. Silence here
/// reads as "the spec declares no such operation".
UnsupportedSpecKey {
/// The top-level key, as written in the document.
key: String,
/// Why the generator cannot generate from it.
reason: String,
/// What to do instead.
hint: String,
},

/// A `$ref` pointed at something that cannot be resolved.
UnresolvedRef(String),

Expand Down Expand Up @@ -113,6 +144,30 @@ pub enum Error {
reason: String,
},

/// A request or response body declares content, and no content type the
/// generator can represent.
///
/// This is not a bodyless body. A bodyless response declares no `content:`
/// at all, and `204` is the common case. A body that declares
/// `application/pdf` states that a payload exists, so emitting no field for
/// it drops the payload with no message.
///
/// Both directions report through this one variant, because both make the
/// same statement about the same input. The remedy differs by direction, so
/// the hint carries it.
UnsupportedContentType {
/// HTTP method of the offending operation.
method: String,
/// Templated request path of the offending operation.
path: String,
/// Which body it is, as a noun phrase for the message (`request body`,
/// or a response named by its status code).
location: String,
/// The declared content types, in document order, comma separated.
declared: String,
hint: String,
},

/// A parameter declared `in: path` has no matching `{placeholder}` in the
/// operation's path template. An OpenAPI path parameter must appear in the
/// path, and lowering it from the template will otherwise silently drop it
Expand Down Expand Up @@ -290,6 +345,14 @@ impl std::fmt::Display for Error {
Error::ReadOutput { path, source } => {
return write!(f, "failed to read output `{path}`: {source}");
}
// The remedy is a hint, which the console prints under the message.
// `Display` therefore states the problem only.
Error::UnsupportedSpecVersion { document, version, .. } => {
return write!(f, "`{document}` declares `openapi: {version}`, which is not supported");
}
Error::UnsupportedSpecKey { key, reason, .. } => {
return write!(f, "the document declares `{key}:`, which {reason}");
}
Error::UnresolvedRef(reference) => {
return write!(f, "unresolved reference `{reference}`");
}
Expand All @@ -305,6 +368,18 @@ impl std::fmt::Display for Error {
Error::UnsupportedOperation { method, path, reason } => {
return write!(f, "unsupported operation `{method} {path}`: {reason}");
}
Error::UnsupportedContentType {
method,
path,
location,
declared,
..
} => {
return write!(
f,
"the {location} of `{method} {path}` declares only content types the generator cannot represent: {declared}"
);
}
Error::InvalidPathParameter { method, path, name } => {
return write!(
f,
Expand Down Expand Up @@ -385,6 +460,9 @@ impl std::error::Error for Error {
// It has no single `source`. `Display` shows the problems instead.
Error::Validation { .. }
| Error::Unimplemented(_)
| Error::UnsupportedSpecVersion { .. }
| Error::UnsupportedSpecKey { .. }
| Error::UnsupportedContentType { .. }
| Error::UnresolvedRef(_)
| Error::UnsupportedRef { .. }
| Error::UnsupportedSchema { .. }
Expand Down
10 changes: 10 additions & 0 deletions crates/oapi-codegen/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ pub struct Struct {
/// When set, the struct captures unknown keys into a flattened map of this
/// element type (`additionalProperties`).
pub additional_properties: Option<RustType>,
/// Whether the schema set `additionalProperties: false`, which becomes
/// `#[serde(deny_unknown_fields)]`.
///
/// This is a field of its own and not the `None` case of
/// [`Self::additional_properties`], because an absent `additionalProperties`
/// and an explicit `false` are different statements. An absent key permits
/// unknown keys and drops them, which is what serde does with no attribute.
/// An explicit `false` rejects them. Both give no flattened map, so the map
/// alone cannot tell them apart.
pub deny_unknown_fields: bool,
}

/// A single struct field.
Expand Down
118 changes: 115 additions & 3 deletions crates/oapi-codegen/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ use crate::error::Result;
/// Maximum `$ref` chain length before bailing out (cycle guard).
const MAX_REF_DEPTH: usize = 32;

/// The OpenAPI minor versions the generator reads. Every parsed document must
/// declare a patch release of one of them.
///
/// This is a list so that adding a version is one entry here. The gate and its
/// message both read it, so neither states a version of its own.
const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];

/// Top-level keys that carry operations the generator cannot emit. A document
/// that declares one is rejected, because ignoring it emits no handler for any
/// operation inside it and reads as a document that declares none.
///
/// `webhooks` is a 3.1 key, so today the version gate rejects such a document
/// first and this list only reaches a 3.0 document that declares the key anyway.
/// Such a document is still ambiguous, and the generator does not guess. The check
/// stands on its own once a version that defines the key is supported.
const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];

/// Shared empty schema map returned when a document has no components.
static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();

Expand Down Expand Up @@ -55,9 +72,25 @@ impl Spec {
source,
};
})?;
let inner: OpenAPI = serde_yaml::from_str(&text).map_err(|source| {
let document = path.display().to_string();
// Parse to a `Value` first, then into `OpenAPI` from that one tree. The
// typed form drops every key it does not know, so a key such as
// `webhooks:` is only visible here. This costs no second parse of the
// text.
let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseSpec {
path: path.display().to_string(),
path: document.clone(),
source,
};
})?;
// Both checks read the untyped tree, and both run before the typed parse.
// The version gate must, because a 3.1-only construct fails that parse
// with a message that names a YAML shape and not a version.
check_spec_version(&document, &value)?;
check_top_level_keys(&value)?;
let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
return Error::ParseSpec {
path: document.clone(),
source,
};
})?;
Expand Down Expand Up @@ -93,7 +126,19 @@ impl Spec {
source,
};
})?;
let parsed: OpenAPI = serde_yaml::from_str(&text).map_err(|source| {
let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseRefFile {
file: file.to_owned(),
source,
};
})?;
// A referenced file is a document of its own and declares its own
// version. A 3.1 fragment pulled into a 3.0 document is the same
// ambiguity as a 3.1 root, so the same gate applies, and it applies
// before the typed parse for the same reason.
check_spec_version(file, &value)?;
check_top_level_keys(&value)?;
let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
return Error::ParseRefFile {
file: file.to_owned(),
source,
Expand Down Expand Up @@ -381,6 +426,73 @@ impl Spec {
}
}

/// Reject a document whose `openapi:` value is not a patch release of a minor
/// version in [`SUPPORTED_SPEC_VERSIONS`].
///
/// The parser behind the generator ignores the `openapi:` value, so without this
/// check it reads whatever subset of an unsupported document happens to match a
/// supported dialect, and reports nothing. A document that generates in part and
/// fails in part is worse than one that fails at once, because the part that
/// generates looks correct.
///
/// The check runs on the untyped tree, before the typed parse. A construct that
/// only a newer version defines fails that parse with a message about a YAML
/// shape and not about a version.
///
/// A document that declares no `openapi:` key, or declares it as a non-string,
/// passes here. The typed parse reports that with a message that points at a line.
fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
return Ok(());
};
let version = version.trim();
// A patch part is optional in practice, so `3.0` and `3.0.3` both pass. The
// dot guards against a future `3.00` reading as `3.0`.
let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
return version == *minor || version.starts_with(&format!("{minor}."));
});
if supported {
return Ok(());
}
let reads = SUPPORTED_SPEC_VERSIONS
.iter()
.map(|minor| {
return format!("{minor}.x");
})
.collect::<Vec<_>>()
.join(", ");
return Err(Error::UnsupportedSpecVersion {
document: document.to_owned(),
version: version.to_owned(),
hint: format!("The generator reads OpenAPI {reads} only."),
});
}

/// Reject a document that declares a top-level key holding operations the
/// generator cannot emit.
///
/// This reads the untyped tree, because the typed `OpenAPI` form drops every key
/// it does not know and a dropped key cannot be reported. A document that is not
/// a mapping passes here. The typed parse that follows reports that shape with
/// its own message, which points at the line.
fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
let Some(mapping) = value.as_mapping() else {
return Ok(());
};
for key in UNSUPPORTED_TOP_LEVEL_KEYS {
if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
return Err(Error::UnsupportedSpecKey {
key: key.to_owned(),
reason: "declares operations the generator cannot emit".to_owned(),
hint: format!(
"Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
),
});
}
}
return Ok(());
}

/// Extract the trailing schema name from a *same-document* `$ref`
/// (`#/components/schemas/Foo` -> `Foo`).
///
Expand Down
Loading