diff --git a/Cargo.lock b/Cargo.lock index 3789598..95e8e18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,9 +212,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -280,7 +280,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -323,9 +323,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -333,18 +333,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "deranged" @@ -360,7 +360,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "form_urlencoded" @@ -733,9 +733,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -763,9 +763,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -826,9 +826,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -897,7 +897,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_yaml", - "syn 3.0.4", + "syn 3.0.5", "trycmd", "uuid", ] @@ -975,7 +975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1141,7 +1141,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1244,9 +1244,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "snapbox" @@ -1320,9 +1320,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -1423,7 +1423,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1649,9 +1649,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -1662,9 +1662,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -1672,9 +1672,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1682,31 +1682,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -1878,7 +1878,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] diff --git a/README.md b/README.md index 802f491..8302033 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/oapi-codegen/Cargo.toml b/crates/oapi-codegen/Cargo.toml index 2a3cc7a..c1165ba 100644 --- a/crates/oapi-codegen/Cargo.toml +++ b/crates/oapi-codegen/Cargo.toml @@ -30,7 +30,7 @@ workspace = true anstream = "1.0.0" clap = { version = "4.6.6", features = ["derive"] } http = "1.5.0" -indexmap = "2.14.1" +indexmap = "2.14.2" openapiv3 = "2.2.0" owo-colors = "4.4.0" prettyplease = "0.3.0" @@ -40,7 +40,7 @@ regex = { version = "1.13.1", default-features = false, features = ["std", "perf serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_yaml = "0.9.34" -syn = { version = "3.0.4", features = ["full"] } +syn = { version = "3.0.5", features = ["full"] } [dev-dependencies] axum = { version = "0.8.9", features = ["multipart"] } diff --git a/crates/oapi-codegen/src/config.rs b/crates/oapi-codegen/src/config.rs index e9b4d4f..f2eb3c2 100644 --- a/crates/oapi-codegen/src/config.rs +++ b/crates/oapi-codegen/src/config.rs @@ -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). @@ -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. @@ -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). @@ -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(), @@ -141,10 +157,115 @@ impl Config { } } +fn configuration_warnings(value: &serde_yaml::Value) -> serde_yaml::Result> { + 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, +) { + 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::(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::(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::(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::(value).is_err()); + return Ok(()); + } + #[test] fn config_keys_match_serde_names() { let yaml = diff --git a/crates/oapi-codegen/src/console.rs b/crates/oapi-codegen/src/console.rs index c6ab02d..ff9250a 100644 --- a/crates/oapi-codegen/src/console.rs +++ b/crates/oapi-codegen/src/console.rs @@ -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 { 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()); } diff --git a/crates/oapi-codegen/src/coverage.rs b/crates/oapi-codegen/src/coverage.rs new file mode 100644 index 0000000..96671b0 --- /dev/null +++ b/crates/oapi-codegen/src/coverage.rs @@ -0,0 +1,1005 @@ +//! OpenAPI 3.0 object keys and diagnostics before typed deserialization. + +use serde_yaml::Value; + +use crate::diagnostic::Warning; +use crate::diagnostic::pointer; +use crate::diagnostic::report_warnings; +use crate::error::Error; +use crate::error::Result; +use crate::lower::validate::Diagnostics; + +const MAX_DEPTH: usize = 128; +const CONSTRAINT_KEYS: &[&str] = &[ + "multipleOf", + "maximum", + "exclusiveMaximum", + "minimum", + "exclusiveMinimum", + "maxLength", + "minLength", + "pattern", + "maxItems", + "minItems", + "uniqueItems", + "maxProperties", + "minProperties", +]; + +macro_rules! catalogue { + ($context:expr; $($pattern:pat => $fields:expr),* $(,)?) => { + match $context { + $($pattern => const { $fields },)* + } + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Context { + Document, + Info, + Contact, + License, + Server, + ServerVariable, + Paths, + PathItem, + Operation, + Components, + Schema, + PropertySchema, + Parameter, + Header, + RequestBody, + Responses, + Response, + MediaType, + Encoding, + Example, + Link, + Callback, + SecurityScheme, + OAuthFlows, + OAuthFlow, + ExternalDocs, + Tag, + Discriminator, + Xml, +} + +#[derive(Debug, Clone, Copy)] +enum Traversal { + Literal, + SchemaOrBool, + Object(Context), + Map(Context), + Array(Context), +} + +#[derive(Debug, Clone, Copy)] +enum Handling { + Read, + Annotation, + Unsupported(&'static str), +} + +#[derive(Debug, Clone, Copy)] +struct Field { + name: &'static str, + traversal: Traversal, + handling: Handling, +} + +const fn read(name: &'static str, traversal: Traversal) -> Field { + return Field { + name, + traversal, + handling: Handling::Read, + }; +} + +const fn annotation(name: &'static str, traversal: Traversal) -> Field { + return Field { + name, + traversal, + handling: Handling::Annotation, + }; +} + +const fn unsupported(name: &'static str, traversal: Traversal, reason: &'static str) -> Field { + return Field { + name, + traversal, + handling: Handling::Unsupported(reason), + }; +} + +fn fields(context: Context) -> &'static [Field] { + use Context::*; + use Traversal::*; + + return catalogue! { context; + Document => &[ + read("openapi", Literal), + annotation("info", Object(Info)), + read("servers", Array(Server)), + read("paths", Object(Paths)), + read("components", Object(Components)), + read("security", Literal), + annotation("tags", Array(Tag)), + annotation("externalDocs", Object(ExternalDocs)), + ], + Info => &[ + annotation("title", Literal), + annotation("description", Literal), + annotation("termsOfService", Literal), + annotation("contact", Object(Contact)), + annotation("license", Object(License)), + annotation("version", Literal), + ], + Contact => &[ + annotation("name", Literal), + annotation("url", Literal), + annotation("email", Literal), + ], + License => &[annotation("name", Literal), annotation("url", Literal)], + Server => &[ + read("url", Literal), + read("description", Literal), + read("variables", Map(ServerVariable)), + ], + ServerVariable => &[ + read("enum", Literal), + read("default", Literal), + read("description", Literal), + ], + PathItem => &[ + annotation("summary", Literal), + annotation("description", Literal), + read("get", Object(Operation)), + read("put", Object(Operation)), + read("post", Object(Operation)), + read("delete", Object(Operation)), + read("options", Object(Operation)), + read("head", Object(Operation)), + read("patch", Object(Operation)), + read("trace", Object(Operation)), + unsupported("servers", Array(Server), "path-level server overrides are not implemented"), + read("parameters", Array(Parameter)), + ], + Operation => &[ + read("tags", Literal), + read("summary", Literal), + read("description", Literal), + annotation("externalDocs", Object(ExternalDocs)), + read("operationId", Literal), + read("parameters", Array(Parameter)), + read("requestBody", Object(RequestBody)), + read("responses", Object(Responses)), + unsupported("callbacks", Map(Callback), "callback operations are not generated"), + unsupported("deprecated", Literal, "operation deprecation is not emitted"), + read("security", Literal), + unsupported("servers", Array(Server), "operation-level server overrides are not implemented"), + ], + Components => &[ + read("schemas", Map(Schema)), + read("responses", Map(Response)), + read("parameters", Map(Parameter)), + annotation("examples", Map(Example)), + read("requestBodies", Map(RequestBody)), + read("headers", Map(Header)), + read("securitySchemes", Map(SecurityScheme)), + unsupported("links", Map(Link), "response links are not generated"), + unsupported("callbacks", Map(Callback), "callback operations are not generated"), + ], + Schema | PropertySchema => &[ + annotation("title", Literal), + read("multipleOf", Literal), + read("maximum", Literal), + read("exclusiveMaximum", Literal), + read("minimum", Literal), + read("exclusiveMinimum", Literal), + read("maxLength", Literal), + read("minLength", Literal), + read("pattern", Literal), + read("maxItems", Literal), + read("minItems", Literal), + read("uniqueItems", Literal), + read("maxProperties", Literal), + read("minProperties", Literal), + read("required", Literal), + read("enum", Literal), + read("type", Literal), + read("allOf", Array(Schema)), + read("oneOf", Array(Schema)), + read("anyOf", Array(Schema)), + read("not", Object(Schema)), + read("items", Object(Schema)), + read("properties", Map(PropertySchema)), + read("additionalProperties", SchemaOrBool), + read("description", Literal), + read("format", Literal), + read("default", Literal), + read("nullable", Literal), + read("discriminator", Object(Discriminator)), + read("readOnly", Literal), + read("writeOnly", Literal), + unsupported("xml", Object(Xml), "XML serialization is not implemented"), + annotation("externalDocs", Object(ExternalDocs)), + annotation("example", Literal), + read("deprecated", Literal), + ], + Parameter => &[ + read("name", Literal), + read("in", Literal), + read("description", Literal), + read("required", Literal), + unsupported("deprecated", Literal, "parameter deprecation is not emitted"), + unsupported("allowEmptyValue", Literal, "allowEmptyValue is not implemented"), + read("style", Literal), + read("explode", Literal), + unsupported("allowReserved", Literal, "allowReserved is not implemented"), + read("schema", Object(Schema)), + annotation("example", Literal), + annotation("examples", Map(Example)), + read("content", Map(MediaType)), + ], + Header => &[ + read("description", Literal), + read("required", Literal), + unsupported("deprecated", Literal, "header deprecation is not emitted"), + unsupported("allowEmptyValue", Literal, "header allowEmptyValue is not implemented"), + unsupported("allowReserved", Literal, "header allowReserved is not implemented"), + unsupported("style", Literal, "response-header serialization styles are not implemented"), + unsupported("explode", Literal, "response-header explode is not implemented"), + read("schema", Object(Schema)), + annotation("example", Literal), + annotation("examples", Map(Example)), + read("content", Map(MediaType)), + ], + RequestBody => &[ + annotation("description", Literal), + read("content", Map(MediaType)), + read("required", Literal), + ], + Response => &[ + read("description", Literal), + read("headers", Map(Header)), + read("content", Map(MediaType)), + unsupported("links", Map(Link), "response links are not generated"), + ], + MediaType => &[ + read("schema", Object(Schema)), + annotation("example", Literal), + annotation("examples", Map(Example)), + unsupported("encoding", Map(Encoding), "per-property body encoding is not implemented"), + ], + Encoding => &[ + read("contentType", Literal), + read("headers", Map(Header)), + read("style", Literal), + read("explode", Literal), + read("allowReserved", Literal), + ], + Example => &[ + annotation("summary", Literal), + annotation("description", Literal), + annotation("value", Literal), + annotation("externalValue", Literal), + ], + Link => &[ + read("operationRef", Literal), + read("operationId", Literal), + read("parameters", Literal), + read("requestBody", Literal), + annotation("description", Literal), + read("server", Object(Server)), + ], + SecurityScheme => &[ + read("type", Literal), + read("description", Literal), + read("name", Literal), + read("in", Literal), + read("scheme", Literal), + annotation("bearerFormat", Literal), + read("flows", Object(OAuthFlows)), + read("openIdConnectUrl", Literal), + ], + OAuthFlows => &[ + read("implicit", Object(OAuthFlow)), + read("password", Object(OAuthFlow)), + read("clientCredentials", Object(OAuthFlow)), + read("authorizationCode", Object(OAuthFlow)), + ], + OAuthFlow => &[ + read("authorizationUrl", Literal), + read("tokenUrl", Literal), + read("refreshUrl", Literal), + read("scopes", Literal), + ], + ExternalDocs => &[annotation("description", Literal), annotation("url", Literal)], + Tag => &[ + annotation("name", Literal), + annotation("description", Literal), + annotation("externalDocs", Object(ExternalDocs)), + ], + Discriminator => &[ + unsupported("propertyName", Literal, "discriminator dispatch uses shapes rather than this property"), + read("mapping", Literal), + ], + Xml => &[ + annotation("name", Literal), + annotation("namespace", Literal), + annotation("prefix", Literal), + annotation("attribute", Literal), + annotation("wrapped", Literal), + ], + Paths | Responses | Callback => &[], + }; +} + +impl Context { + fn references(self) -> bool { + return matches!( + self, + Self::Schema + | Self::PropertySchema + | Self::PathItem + | Self::Parameter + | Self::Header + | Self::RequestBody + | Self::Response + | Self::Example + | Self::Link + | Self::SecurityScheme + | Self::Callback + ); + } +} + +struct Sweep<'a> { + document: &'a str, + warnings: Vec, + problems: Diagnostics, +} + +pub(crate) fn check(document: &str, value: &Value) -> Result<()> { + let sweep = inspect(document, value); + report_warnings(document, &sweep.warnings); + return sweep.problems.into_result(); +} + +fn inspect<'a>(document: &'a str, value: &Value) -> Sweep<'a> { + let mut sweep = Sweep { + document, + warnings: Vec::new(), + problems: Diagnostics::new(), + }; + sweep.object(value, Context::Document, "", 0); + return sweep; +} + +impl Sweep<'_> { + fn invalid(&mut self, path: &str, reason: impl Into) { + self.problems.push(Error::InvalidSpec { + document: self.document.to_owned(), + path: path.to_owned(), + reason: reason.into(), + }); + } + + fn warn(&mut self, path: &str, message: impl Into) { + self.warnings.push(Warning::new(path, message)); + } + + fn object(&mut self, value: &Value, context: Context, path: &str, depth: usize) { + if depth > MAX_DEPTH { + self.invalid(path, format!("OpenAPI object nesting exceeds {MAX_DEPTH} levels")); + return; + } + let Some(mapping) = value.as_mapping() else { + self.invalid(path, format!("{context:?} must be an object")); + return; + }; + let reference = context.references() && mapping.contains_key("$ref"); + for (key, child) in mapping { + let key = match key { + Value::String(key) => key.clone(), + Value::Number(number) if context == Context::Responses => number.to_string(), + _ => { + self.invalid(path, "OpenAPI object keys must be strings"); + continue; + } + }; + let at = pointer(path, &key); + if key == "$ref" && context.references() { + if child.as_str().is_none() { + self.invalid(&at, "$ref must be a string"); + } + continue; + } + if reference && context != Context::PathItem { + self.warn(&at, "siblings of $ref are ignored in OpenAPI 3.0"); + } + if key.starts_with("x-") { + self.extension(&key, context, &at); + continue; + } + let dynamic = match context { + Context::Paths if key.starts_with('/') => Some(Context::PathItem), + Context::Responses if response_key(&key) => Some(Context::Response), + Context::Callback => Some(Context::PathItem), + _ => None, + }; + if let Some(next) = dynamic { + self.object(child, next, &at, depth + 1); + continue; + } + let Some(field) = fields(context).iter().find(|field| return field.name == key) else { + self.invalid(&at, format!("unknown OpenAPI 3.0 key `{key}` in {context:?}")); + continue; + }; + if !reference { + if let Handling::Unsupported(reason) = field.handling + && child.as_bool() != Some(false) + { + self.warn(&at, reason); + } + self.value_notes(context, &key, child, &at); + } + self.walk(child, field.traversal, &at, depth + 1); + } + if !reference { + self.object_notes(value, context, path); + } + } + + fn walk(&mut self, value: &Value, traversal: Traversal, path: &str, depth: usize) { + match traversal { + Traversal::Literal => {} + Traversal::SchemaOrBool if value.is_bool() => {} + Traversal::SchemaOrBool => self.object(value, Context::Schema, path, depth), + Traversal::Object(context) => self.object(value, context, path, depth), + Traversal::Map(context) => { + if let Some(mapping) = value.as_mapping() { + for (name, child) in mapping { + let Some(name) = name.as_str() else { + self.invalid(path, "OpenAPI map names must be strings"); + continue; + }; + self.object(child, context, &pointer(path, name), depth); + } + } else { + self.invalid(path, "this OpenAPI field must be a map"); + } + } + Traversal::Array(context) => { + if let Some(sequence) = value.as_sequence() { + for (index, child) in sequence.iter().enumerate() { + self.object(child, context, &pointer(path, &index.to_string()), depth); + } + } else { + self.invalid(path, "this OpenAPI field must be an array"); + } + } + } + } + + fn extension(&mut self, key: &str, context: Context, path: &str) { + if key.starts_with("x-go-") { + return; + } + let handled = match context { + Context::PropertySchema => matches!( + key, + "x-rust-type" + | "x-rust-name" + | "x-rust-derive" + | "x-rust-serde-skip" + | "x-omitempty" + | "x-order" + | "x-deprecated-reason" + | "x-enum-varnames" + | "x-enumNames" + ), + Context::Schema => matches!( + key, + "x-rust-type" + | "x-rust-name" + | "x-rust-derive" + | "x-deprecated-reason" + | "x-enum-varnames" + | "x-enumNames" + ), + Context::Operation => key == "x-rust-name", + _ => false, + }; + if !handled { + self.warn(path, "this extension is not implemented here and is ignored"); + } + } + + fn value_notes(&mut self, context: Context, key: &str, value: &Value, path: &str) { + if matches!(context, Context::Document | Context::Operation) + && key == "security" + && let Some(requirements) = value.as_sequence() + { + if requirements.len() > 1 { + self.warn(path, "security alternatives are flattened into a list of scheme names"); + } + if requirements.iter().any(|requirement| { + return requirement.as_mapping().is_some_and(|schemes| { + return schemes.values().any(|scopes| { + return scopes.as_sequence().is_some_and(|scopes| return !scopes.is_empty()); + }); + }); + }) { + self.warn(path, "security scopes are not enforced by the generated code"); + } + } + if matches!(context, Context::Schema | Context::PropertySchema) { + match key { + "oneOf" | "anyOf" => self.warn( + path, + "Rust deserialization checks do not enforce all schema constraints, which can affect union match counts", + ), + "allOf" + if value.as_sequence().is_some_and(|members| { + return match members.as_slice() { + [] => false, + [member] => member.get("$ref").is_none(), + _ => true, + }; + }) => + { + self.warn( + path, + "allOf merges properties rather than validating every member independently", + ); + } + "default" if value.is_null() => { + self.warn( + path, + "the parser discards a null default, so an absent property does not receive explicit null", + ); + } + "default" if context == Context::Schema => { + self.warn( + path, + "defaults are applied only at supported property and query-parameter uses", + ); + } + _ => {} + } + } + } + + fn object_notes(&mut self, value: &Value, context: Context, path: &str) { + match context { + Context::RequestBody if value.get("required").and_then(Value::as_bool) != Some(true) => { + self.warn( + path, + "optional request bodies are generated as required when a body type is emitted", + ); + } + Context::MediaType if value.get("schema").is_none() => { + self.warn(path, "a media entry without a schema does not generate a body type"); + } + Context::Schema | Context::PropertySchema => self.schema_notes(value, context, path), + _ => {} + } + } + + fn schema_notes(&mut self, value: &Value, context: Context, path: &str) { + if value.get("x-rust-type").is_some() && value.get("allOf").is_some() { + self.warn( + path, + "constraints inherited through allOf are not enforced for x-rust-type", + ); + } + if value.get("nullable").and_then(Value::as_bool) == Some(true) + && let Ok(schema) = serde_yaml::from_value::(value.clone()) + { + let location = path.split("/schema/").next().unwrap_or(path); + if !location.starts_with("/components/schemas/") + && (location.contains("/parameters/") + || location.contains("/headers/") + || location.contains("/content/multipart~1form-data/") + || location.contains("/content/application~1x-www-form-urlencoded/") + || location.contains("/content/text~1plain/")) + { + self.warn( + path, + "nullable values have no supported null representation in this wire format", + ); + } + if crate::lower::default::unsupported_nullable_default(&schema) { + self.warn( + &pointer(path, "default"), + "this nullable default has no supported Rust literal and is ignored", + ); + } + if crate::lower::constraints::unsupported_nullable_constraints(&schema) + && CONSTRAINT_KEYS.iter().any(|key| return value.get(*key).is_some()) + { + self.warn(path, "constraints on this nullable value type are not enforced"); + } + } + let kind = value.get("type").and_then(Value::as_str); + if let Some(kind) = kind + && !matches!(kind, "string" | "integer" | "number" | "boolean" | "object" | "array") + { + self.invalid( + &pointer(path, "type"), + format!("`{kind}` is not an OpenAPI 3.0 schema type"), + ); + } + if context == Context::Schema && CONSTRAINT_KEYS.iter().any(|key| return value.get(*key).is_some()) { + self.warn( + path, + "constraints are enforced only at supported field uses, not on type aliases or array items", + ); + } + if value.get("x-rust-derive").is_some() && value.get("x-rust-type").is_none() { + self.warn( + &pointer(path, "x-rust-derive"), + "x-rust-derive requires x-rust-type and is otherwise ignored", + ); + } + if matches!(kind, Some("number" | "boolean")) && value.get("enum").is_some() { + self.warn( + &pointer(path, "enum"), + "number and boolean enum restrictions are not enforced", + ); + } + if let Some(format) = value.get("format").and_then(Value::as_str) { + let handled = match kind { + Some("string") => matches!(format, "date" | "date-time" | "byte" | "binary" | "password" | "uuid"), + Some("integer") => matches!(format, "int32" | "int64"), + Some("number") => matches!(format, "float" | "double"), + _ => false, + }; + if !handled { + self.warn( + &pointer(path, "format"), + "this format is not implemented and the base type is used", + ); + } + } + if kind == Some("object") + && value.get("additionalProperties").and_then(Value::as_bool) == Some(false) + && value + .get("properties") + .and_then(Value::as_mapping) + .is_none_or(serde_yaml::Mapping::is_empty) + { + self.warn( + &pointer(path, "additionalProperties"), + "an object without declared properties becomes a map and does not reject additional properties", + ); + } + } +} + +fn response_key(key: &str) -> bool { + if key == "default" { + return true; + } + let mut bytes = key.bytes(); + return matches!(bytes.next(), Some(b'1'..=b'5')) + && matches!( + (bytes.next(), bytes.next(), bytes.next()), + (Some(b'0'..=b'9'), Some(b'0'..=b'9'), None) | (Some(b'X'), Some(b'X'), None) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inspect_yaml(yaml: &str) -> Sweep<'static> { + let value = serde_yaml::from_str(yaml).expect("valid YAML"); + return inspect("spec.yaml", &value); + } + + #[test] + fn unknown_keys_are_rejected_in_nested_objects() { + for yaml in [ + "inf: {}", + "info: {titel: Demo}", + "components: {schemas: {Widget: {type: string, const: x}}}", + "paths: {/widgets: {get: {operationID: list}}}", + "components: {schemas: {Widget: {properties: {name: {typo: string}}}}}", + "components: {schemas: {Widget: {xml: {nam: widget}}}}", + "components: {examples: {payload: {externalvalue: url}}}", + "components: {securitySchemes: {auth: {flows: {password: {tokenURL: url}}}}}", + "paths: {widgets: {}}", + "paths: {/widgets: {get: {responses: {20X: {description: wrong}}}}}", + ] { + let sweep = inspect_yaml(yaml); + assert!(!sweep.problems.is_empty(), "accepted {yaml}"); + } + } + + #[test] + fn invalid_structures_and_schema_types_are_rejected() { + for yaml in [ + "info: []", + "components: {schemas: []}", + "servers: {}", + "components: {schemas: {Widget: {type: stirng}}}", + "components: {schemas: {Widget: {type: 'null'}}}", + "components: {schemas: {Widget: {$ref: 42, type: string}}}", + "components: {schemas: {Widget: {xml: false}}}", + "components: {schemas: {Widget: {items: true}}}", + "components: {schemas: {Widget: {additionalProperties: []}}}", + ] { + assert!(!inspect_yaml(yaml).problems.is_empty(), "accepted {yaml}"); + } + for value in ["true", "false", "{type: string}"] { + let yaml = format!("components: {{schemas: {{Widget: {{type: object, additionalProperties: {value}}}}}}}"); + assert!(inspect_yaml(&yaml).problems.is_empty(), "{yaml}"); + } + } + + #[test] + fn supported_shapes_and_documented_annotations_are_quiet() { + let sweep = inspect_yaml( + " +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 + contact: {name: Support, url: 'https://example.com', email: support@example.com} + license: {name: MIT, url: 'https://example.com/license'} +tags: [{name: widgets, externalDocs: {url: 'https://example.com/widgets'}}] +paths: {} +components: + schemas: + Widget: + type: object + title: A widget + description: The model. + required: [id] + properties: + id: {type: string, example: {arbitrary: true}} +", + ); + assert!(sweep.problems.is_empty()); + assert!(sweep.warnings.is_empty(), "{:?}", sweep.warnings); + } + + #[test] + fn header_flags_are_recognized_and_default_values_are_quiet() { + for flag in [false, true] { + let yaml = format!( + "components: {{headers: {{X-Test: {{schema: {{type: string}}, allowEmptyValue: {flag}, allowReserved: {flag}}}}}}}" + ); + let sweep = inspect_yaml(&yaml); + assert!(sweep.problems.is_empty()); + assert_eq!(sweep.warnings.is_empty(), !flag); + } + } + + #[test] + fn deeply_nested_objects_stop_at_the_inspection_limit() { + let mut value = Value::Mapping(serde_yaml::Mapping::new()); + for _ in 0..MAX_DEPTH + 1 { + let mut mapping = serde_yaml::Mapping::new(); + mapping.insert(Value::String("items".to_owned()), value); + value = Value::Mapping(mapping); + } + let mut sweep = inspect_yaml("{}"); + sweep.object(&value, Context::Schema, "/schema", 0); + let error = sweep.problems.into_result().expect_err("nesting limit"); + assert!(error.to_string().contains("nesting exceeds")); + } + + #[test] + fn literal_maps_and_user_names_are_not_spec_objects() { + let sweep = inspect_yaml( + " +components: + schemas: + x-model: + type: object + properties: + const: {type: string} + example: {const: arbitrary, typo: {anything: true}} + default: {unevaluatedProperties: false} + enum: [{other: {anything: true}}] + discriminator: {propertyName: kind, mapping: {arbitrary: '#/components/schemas/x-model'}} + examples: + x-example: {value: {notAnOpenAPIKey: true}} + links: + next: {parameters: {arbitrary: '$response.body#/id'}, requestBody: {anything: true}} + securitySchemes: + auth: + type: oauth2 + flows: + password: {tokenUrl: /token, scopes: {arbitrary: description}} +security: [{arbitrary: [custom]}] +", + ); + assert!(sweep.problems.is_empty()); + assert!( + !sweep + .warnings + .iter() + .any(|warning| return warning.path.contains("typo")) + ); + } + + #[test] + fn unsupported_features_warn_and_their_children_are_still_checked() { + let sweep = inspect_yaml( + "paths: {/widgets: {get: {callbacks: {event: {'{$request.body#/url}': {post: {responses: {default: {description: ok}}}}}}}}}", + ); + assert!(sweep.problems.is_empty()); + assert!( + sweep + .warnings + .iter() + .any(|warning| return warning.path.ends_with("/callbacks")) + ); + let invalid = inspect_yaml( + "paths: {/widgets: {get: {callbacks: {event: {'{$request.body#/url}': {post: {typo: true}}}}}}}", + ); + assert!(!invalid.problems.is_empty()); + } + + #[test] + fn references_report_ignored_siblings() { + let sweep = + inspect_yaml("components: {schemas: {Widget: {$ref: '#/components/schemas/Other', description: ignored}}}"); + assert!(sweep.problems.is_empty()); + assert!( + sweep + .warnings + .iter() + .any(|warning| return warning.message.contains("siblings")) + ); + let invalid = + inspect_yaml("components: {schemas: {Widget: {$ref: '#/components/schemas/Other', requird: [id]}}}"); + assert!(!invalid.problems.is_empty()); + } + + #[test] + fn extensions_are_opaque_but_unhandled_extensions_warn() { + let sweep = inspect_yaml( + "x-vendor: {arbitrary: true}\nx-go-custom: true\ncomponents: {schemas: {Widget: {type: string, x-rust-type: 'String'}}}", + ); + assert!(sweep.problems.is_empty()); + assert_eq!(sweep.warnings.len(), 1); + assert_eq!(sweep.warnings.first().expect("warning").path, "/x-vendor"); + } + + #[test] + fn pointers_escape_property_and_path_names() { + let sweep = inspect_yaml("paths: {'/a~b': {get: {typo: true}}}"); + let error = sweep.problems.into_result().expect_err("unknown key"); + assert!(matches!(error, Error::InvalidSpec { path, .. } if path == "/paths/~1a~0b/get/typo")); + } + + #[test] + fn response_keys_allow_exact_codes_ranges_and_default() { + for key in ["100", "200", "599", "2XX", "default"] { + assert!(response_key(key), "{key}"); + } + for key in ["20", "2000", "600", "2xx", "20X", "foo"] { + assert!(!response_key(key), "{key}"); + } + let sweep = inspect_yaml("paths: {/widgets: {get: {responses: {200: {description: ok}}}}}"); + assert!(sweep.problems.is_empty()); + } + + #[test] + fn schema_limitations_are_reported_without_changing_generation() { + for (schema, keyword) in [ + ("{type: number, enum: [1.5]}", "enum"), + ("{type: string, format: custom}", "format"), + ("{type: string, nullable: true, default: null}", "default"), + ("{oneOf: [{type: string}, {type: integer}]}", "oneOf"), + ("{anyOf: [{type: string}, {type: integer}]}", "anyOf"), + ("{type: object, additionalProperties: false}", "additionalProperties"), + ( + "{allOf: [{type: object, properties: {name: {type: string}}, additionalProperties: false}]}", + "allOf", + ), + ] { + let yaml = format!("components: {{schemas: {{Widget: {schema}}}}}"); + let sweep = inspect_yaml(&yaml); + assert!(sweep.problems.is_empty(), "{schema}"); + assert!( + sweep + .warnings + .iter() + .any(|warning| return warning.path.ends_with(keyword)), + "{schema}", + ); + if matches!(keyword, "oneOf" | "anyOf") { + assert!(sweep.warnings.iter().any(|warning| { + return warning.message.contains("Rust deserialization checks") + && warning.message.contains("can affect union match counts"); + })); + } + } + } + + #[test] + fn nullable_limitations_warn_before_the_typed_parse() { + for (schema, message) in [ + ( + "{type: string, nullable: true, default: null}", + "parser discards a null default", + ), + ( + "{type: array, nullable: true, items: {type: string}, default: [value]}", + "no supported Rust literal", + ), + ( + "{type: string, nullable: true, x-rust-type: String, minLength: 2}", + "constraints on this nullable value type", + ), + ] { + let yaml = + format!("components: {{schemas: {{Container: {{type: object, properties: {{field: {schema}}}}}}}}}"); + let sweep = inspect_yaml(&yaml); + assert!(sweep.problems.is_empty()); + assert!( + sweep + .warnings + .iter() + .any(|warning| return warning.message.contains(message)) + ); + } + + let supported = inspect_yaml("components: {schemas: {Text: {type: string, nullable: true}}}"); + assert!(supported.warnings.is_empty()); + } + + #[test] + fn nullable_wire_parameters_and_custom_allof_constraints_warn() { + let report = inspect_yaml( + "paths: + /probe: + get: + parameters: + - name: query + in: query + schema: {type: string, nullable: true} + responses: {} +components: + schemas: + Custom: + nullable: true + x-rust-type: i64 + allOf: + - {type: string, minLength: 2}", + ); + assert!(report.warnings.iter().any(|warning| { + return warning.message.contains("no supported null representation"); + })); + assert!(report.warnings.iter().any(|warning| { + return warning.message.contains("inherited through allOf"); + })); + let json = inspect_yaml( + "paths: + /probe: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + headers: + type: array + items: {type: string, nullable: true} + responses: {}", + ); + assert!(!json.warnings.iter().any(|warning| { + return warning.message.contains("no supported null representation"); + })); + } +} diff --git a/crates/oapi-codegen/src/diagnostic.rs b/crates/oapi-codegen/src/diagnostic.rs new file mode 100644 index 0000000..98716dc --- /dev/null +++ b/crates/oapi-codegen/src/diagnostic.rs @@ -0,0 +1,50 @@ +use anstream::eprintln; +use owo_colors::OwoColorize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Warning { + pub(crate) path: String, + pub(crate) message: String, +} + +impl Warning { + pub(crate) fn new(path: impl Into, message: impl Into) -> Self { + return Self { + path: path.into(), + message: message.into(), + }; + } +} + +pub(crate) fn report_warnings(document: &str, warnings: &[Warning]) { + for warning in warnings { + eprintln!( + "{} {document}: {}: {}", + "warning:".yellow().bold(), + warning.path, + warning.message + ); + } +} + +pub(crate) fn pointer(parent: &str, key: &str) -> String { + return format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pointer_escapes_literal_keys() { + for (parent, key, expected) in [ + ("", "generate", "/generate"), + ("/generate", "modles", "/generate/modles"), + ("", "a~/b", "/a~0~1b"), + ("/a~0~1b", "~1/", "/a~0~1b/~01~1"), + ("", "", "/"), + ] { + assert_eq!(pointer(parent, key), expected); + } + } +} diff --git a/crates/oapi-codegen/src/emit/constraints.rs b/crates/oapi-codegen/src/emit/constraints.rs index 7b076b5..5bf5f31 100644 --- a/crates/oapi-codegen/src/emit/constraints.rs +++ b/crates/oapi-codegen/src/emit/constraints.rs @@ -36,11 +36,24 @@ pub(crate) fn is_checked(field: &Field) -> bool { pub(crate) fn emit_validate_fn(field: &Field) -> Result { let name = validate_fn_name(field); let ty = emit_type(&field.ty)?; - let Some(constraints) = &field.constraints else { - return Ok(quote! {}); + let empty = Constraints::default(); + let constraints = field.constraints.as_ref().unwrap_or(&empty); + let tests = if crate::lower::constraints::accepts_only_null(field) { + vec![Check { + test: quote! {{ let _ = item; true }}, + message: "has no non-null value within the declared bounds".to_owned(), + }] + } else { + checks(constraints, checked_type(field, constraints)) + }; + let body = wrap(&field.ty, &tests, field.name.logical(), constraints.checked_as.as_ref()); + let read = match &field.ty { + RustType::Option(inner) => { + let inner = emit_type(inner)?; + quote! { Some(<#inner as serde::Deserialize>::deserialize(deserializer)?) } + } + _ => quote! { <#ty as serde::Deserialize>::deserialize(deserializer)? }, }; - let tests = checks(constraints, checked_type(field, constraints)); - let body = wrap(&field.ty, &tests, field.name.logical()); let pattern = emit_pattern(field, constraints)?; let doc = format!( " The rules the document gives `{}`, checked on the way in.", @@ -53,7 +66,7 @@ pub(crate) fn emit_validate_fn(field: &Field) -> Result { D: serde::Deserializer<'de>, { #pattern - let value = <#ty as serde::Deserialize>::deserialize(deserializer)?; + let value = #read; #body return Ok(value); } @@ -72,7 +85,10 @@ struct Check { /// /// An `Option` runs the rules only when it holds a value. A `Vec` carries its /// own rules, and is not walked into, because `items` is a schema of its own. -fn wrap(ty: &RustType, tests: &[Check], label: &str) -> TokenStream { +fn wrap(ty: &RustType, tests: &[Check], label: &str, checked_as: Option<&RustType>) -> TokenStream { + if tests.is_empty() { + return quote! {}; + } let rules = tests.iter().map(|check| { let test = &check.test; let message = format!("`{label}` {}", check.message); @@ -83,18 +99,25 @@ fn wrap(ty: &RustType, tests: &[Check], label: &str) -> TokenStream { }; }); let rules: Vec = rules.collect(); - if ty.is_option() { - return quote! { - if let Some(item) = value.as_ref() { - #(#rules)* - } - }; - } - return quote! { - { - let item = &value; - #(#rules)* + let body = unwrap_checks(ty, quote! { #(#rules)* }, checked_as); + return quote! {{ let item = &value; #body }}; +} + +fn unwrap_checks(ty: &RustType, body: TokenStream, checked_as: Option<&RustType>) -> TokenStream { + return match ty { + RustType::Option(inner) | RustType::Nullable(inner) => { + let body = unwrap_checks(inner, body, checked_as); + quote! { if let Some(item) = item.as_ref() { #body } } + } + RustType::Boxed(inner) => { + let body = unwrap_checks(inner, body, checked_as); + quote! {{ let item = item.as_ref(); #body }} } + RustType::Named(_) => match checked_as { + Some(checked) => unwrap_checks(checked, body, None), + _ => body, + }, + _ => body, }; } @@ -105,7 +128,7 @@ fn wrap(ty: &RustType, tests: &[Check], label: &str) -> TokenStream { /// Lowering resolves that type, so read it when it is there. fn checked_type<'a>(field: &'a Field, constraints: &'a Constraints) -> &'a RustType { return match &constraints.checked_as { - Some(ty) => ty, + Some(ty) => ty.innermost(), None => field.ty.innermost(), }; } @@ -300,7 +323,7 @@ fn array_checks(constraints: &Constraints, ty: &RustType, tests: &mut Vec if !element.is_scalar() { return; } - let test = if matches!(**element, RustType::F64) { + let test = if matches!(element.innermost(), RustType::F64) { quote! { item.iter().enumerate().any(|(index, left)| { return item.iter().skip(index + 1).any(|right| return left == right); diff --git a/crates/oapi-codegen/src/emit/mod.rs b/crates/oapi-codegen/src/emit/mod.rs index 5d69842..df8c3ba 100644 --- a/crates/oapi-codegen/src/emit/mod.rs +++ b/crates/oapi-codegen/src/emit/mod.rs @@ -11,6 +11,7 @@ mod operation; mod package; mod reqwest; mod servers; +mod union; pub(crate) mod usage; use std::collections::HashMap; @@ -112,7 +113,7 @@ pub fn emit_module(module: &Module, server_urls: Option<&ServerUrls>) -> Result< // No service, so no direction to narrow the serde traits by, and every model // keeps both. The foreign narrowing does apply: a trait a foreign type lacks // is unsatisfiable whichever direction the data flows. - let mut items = module_items(module, &usage::models_only_derives(module))?; + let mut items = module_items(module, &usage::models_only_derives(module), false)?; items.extend(server_url_items(server_urls)?); return render(&items); } @@ -201,7 +202,10 @@ pub struct ReservedTypeName { /// whose generated name matches one of these will produce a duplicate item, so /// [`crate::lower::check_type_name_collisions`] rejects it up front. pub fn reserved_type_names(targets: Targets) -> Vec { - let mut names = Vec::new(); + let mut names = vec![ReservedTypeName { + name: "Nullable", + description: "nullable JSON value", + }]; if targets.server { names.push(ReservedTypeName { name: axum::API_TRAIT_NAME, @@ -238,7 +242,7 @@ pub fn emit_flat( ) -> Result { let derives = usage::model_derives(module, service, targets); let foreign = usage::foreign_resolver(module); - let mut items = module_items(module, &derives)?; + let mut items = module_items(module, &derives, uses_nullable(module, Some(service)))?; items.extend(server_url_items(server_urls)?); for operation in &service.operations { items.extend(operation::emit_operation_types(operation, targets, &foreign)?); @@ -263,8 +267,79 @@ fn server_url_items(server_urls: Option<&ServerUrls>) -> Result /// Lower every IR item in a module into its token stream, applying each item's /// derive set from `derives` (defaulting to every trait when absent). -fn module_items(module: &Module, derives: &HashMap) -> Result> { +fn module_items( + module: &Module, + derives: &HashMap, + service_nullable: bool, +) -> Result> { let mut items = Vec::with_capacity(module.items.len()); + if service_nullable || module.items.iter().any(item_uses_nullable) { + items.push(quote! { + /// A present JSON value, including explicit null. + #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] + #[serde(untagged)] + pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), + } + + impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } + } + + impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> for NullableVisitor { + type Value = Nullable; + + fn expecting(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + + fn visit_newtype_struct(self, deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer).map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + + fn visit_map(self, map: M) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + + // The newtype boundary rejects absent fields before reading an Option. + return deserializer.deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } + } + }); + } for item in &module.items { let set = derives.get(item.name()).copied().unwrap_or_else(ModelDerives::both); items.push(models::emit_item(item, set)?); @@ -272,6 +347,62 @@ fn module_items(module: &Module, derives: &HashMap) -> Res return Ok(items); } +pub(crate) fn uses_nullable(module: &Module, service: Option<&Service>) -> bool { + use crate::ir::RequestPayload; + use crate::ir::ResponseBody; + + return module.items.iter().any(item_uses_nullable) + || service.is_some_and(|service| { + return service.operations.iter().any(|operation| { + let request = match &operation.request { + Some(RequestPayload::Single(body)) => type_uses_nullable(&body.ty), + Some(RequestPayload::Negotiated(body)) => body + .variants + .iter() + .any(|variant| return type_uses_nullable(&variant.body.ty)), + _ => false, + }; + return request + || operation.responses.iter().any(|response| { + return match &response.body { + Some(ResponseBody::Single(body)) => type_uses_nullable(&body.ty), + Some(ResponseBody::Negotiated(body)) => body + .variants + .iter() + .any(|variant| return type_uses_nullable(&variant.body.ty)), + None => false, + }; + }); + }); + }); +} + +fn item_uses_nullable(item: &crate::ir::Item) -> bool { + return match item { + crate::ir::Item::Struct(value) => { + value.fields.iter().any(|field| return type_uses_nullable(&field.ty)) + || value.additional_properties.as_ref().is_some_and(type_uses_nullable) + } + crate::ir::Item::Alias(value) => type_uses_nullable(&value.ty), + crate::ir::Item::Enum(value) => match &value.kind { + crate::ir::EnumKind::Union(variants) => { + variants.iter().any(|variant| return type_uses_nullable(&variant.ty)) + } + _ => false, + }, + }; +} + +fn type_uses_nullable(ty: &RustType) -> bool { + return match ty { + RustType::Nullable(_) => true, + RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { + type_uses_nullable(inner) + } + _ => false, + }; +} + /// Pretty-print a sequence of top-level items, one blank line apart, prefixed /// with the generated-file header. fn render(items: &[TokenStream]) -> Result { @@ -359,6 +490,10 @@ pub(crate) fn emit_type(ty: &RustType) -> Result { let inner = emit_type(inner)?; quote! { Option<#inner> } } + RustType::Nullable(inner) => { + let inner = emit_type(inner)?; + quote! { Nullable<#inner> } + } RustType::Boxed(inner) => { let inner = emit_type(inner)?; quote! { Box<#inner> } diff --git a/crates/oapi-codegen/src/emit/models.rs b/crates/oapi-codegen/src/emit/models.rs index 3c17576..f210570 100644 --- a/crates/oapi-codegen/src/emit/models.rs +++ b/crates/oapi-codegen/src/emit/models.rs @@ -19,7 +19,6 @@ use crate::ir::Item; use crate::ir::RustType; use crate::ir::StringVariant; use crate::ir::Struct; -use crate::ir::UnionVariant; use crate::naming::RustIdent; /// The full derive set for one generated model: its serde traits, plus which of @@ -95,7 +94,7 @@ pub(crate) fn emit_item(item: &Item, derives: ModelDerives) -> Result TokenStream { +pub(super) fn derive_attr(derives: ModelDerives) -> TokenStream { let mut parts: Vec = Vec::new(); if derives.serde.serialize { parts.push(quote! { serde::Serialize }); @@ -156,7 +155,7 @@ pub(crate) const DEBUG_CLONE_AND_EQ: ForeignDerives = ForeignDerives { }; /// Render a `#[deprecated]` / `#[deprecated(note = "...")]` attribute, if any. -fn deprecated_attr(deprecated: &Option) -> TokenStream { +pub(super) fn deprecated_attr(deprecated: &Option) -> TokenStream { return match deprecated { None => quote! {}, Some(Deprecation { note: None }) => quote! { #[deprecated] }, @@ -177,6 +176,9 @@ pub(crate) fn emit_struct(strukt: &Struct, derives: ModelDerives) -> Result Result Result Result { + let inner = emit_default_value(value, inner)?; + return Ok(quote! { Nullable::Value(#inner) }); + } RustType::Boxed(inner) => { let inner = emit_default_value(value, inner)?; return Ok(quote! { Box::new(#inner) }); @@ -329,12 +340,12 @@ fn emit_field(field: &Field, serde: SerdeDerives, owner: &RustIdent) -> Result Result { - let mut rendered = Vec::with_capacity(variants.len()); - for variant in variants { - rendered.push(emit_union_variant(variant)?); - } - quote! { - #doc - #derive_attr - #[serde(untagged)] - #deprecated - pub enum #name { - #(#rendered)* - } - } - } + EnumKind::Union(variants) => super::union::emit_one_of(enom, variants, derives)?, + EnumKind::AnyOf(variants) => super::union::emit_any_of(enom, variants, derives)?, }; return Ok(tokens); } @@ -491,15 +489,6 @@ fn emit_string_variant(variant: &StringVariant) -> TokenStream { }; } -/// Render one newtype variant of a union enum. -fn emit_union_variant(variant: &UnionVariant) -> Result { - let name = variant.name.to_token(); - let ty = emit_type(&variant.ty)?; - return Ok(quote! { - #name(#ty), - }); -} - /// Render a `type X = Y;` alias. fn emit_alias(alias: &Alias) -> Result { let name = alias.name.to_token(); diff --git a/crates/oapi-codegen/src/emit/package.rs b/crates/oapi-codegen/src/emit/package.rs index 01caa0a..3822cde 100644 --- a/crates/oapi-codegen/src/emit/package.rs +++ b/crates/oapi-codegen/src/emit/package.rs @@ -71,7 +71,7 @@ pub fn emit_package( let mut package = Builder::new(stem); - let models = super::module_items(module, &derives)?; + let models = super::module_items(module, &derives, super::uses_nullable(module, Some(service)))?; let has_models = !models.is_empty(); package.add(MODELS, models)?; package.add(SERVER_URLS, super::server_url_items(server_urls)?)?; diff --git a/crates/oapi-codegen/src/emit/union.rs b/crates/oapi-codegen/src/emit/union.rs new file mode 100644 index 0000000..6de1356 --- /dev/null +++ b/crates/oapi-codegen/src/emit/union.rs @@ -0,0 +1,158 @@ +use proc_macro2::TokenStream; +use quote::format_ident; +use quote::quote; + +use super::models::ModelDerives; +use super::models::SerdeDerives; +use super::models::deprecated_attr; +use super::models::derive_attr; +use crate::emit::doc_attr; +use crate::emit::emit_type; +use crate::error::Result; +use crate::ir::Enum; +use crate::ir::UnionVariant; +use crate::naming::Case; +use crate::naming::to_ident; + +pub(super) fn emit_one_of(enom: &Enum, variants: &[UnionVariant], derives: ModelDerives) -> Result { + let name = enom.name.to_token(); + let doc = doc_attr(&enom.doc); + let deprecated = deprecated_attr(&enom.deprecated); + let derive = derive_attr(ModelDerives { + serde: SerdeDerives { + deserialize: false, + ..derives.serde + }, + ..derives + }); + let mut payloads = Vec::new(); + let mut attempts = Vec::new(); + for variant in variants { + let variant_name = variant.name.to_token(); + let ty = emit_type(&variant.ty)?; + payloads.push(quote! { #variant_name(#ty), }); + attempts.push(quote! { + if let ::std::result::Result::Ok(payload) = <#ty as serde::Deserialize>::deserialize(&value) { + if selected.is_some() { + return ::std::result::Result::Err(serde::de::Error::custom( + "oneOf matched multiple Rust alternatives" + )); + } + selected = ::std::option::Option::Some(Self::#variant_name(payload)); + } + }); + } + let untagged = derives.serde.serialize.then(|| return quote! { #[serde(untagged)] }); + let deserialize = derives.serde.deserialize.then(|| { + return quote! { + impl<'de> serde::Deserialize<'de> for #name { + fn deserialize<__Deserializer: serde::Deserializer<'de>>(deserializer: __Deserializer) -> ::std::result::Result { + let value = ::deserialize(deserializer)?; + let mut selected = ::std::option::Option::None; + #(#attempts)* + return selected.ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative" + )); + } + } + }; + }); + return Ok(quote! { + #doc + #derive + #untagged + #deprecated + pub enum #name { + #(#payloads)* + } + + #deserialize + }); +} + +pub(super) fn emit_any_of(enom: &Enum, variants: &[UnionVariant], derives: ModelDerives) -> Result { + let name = enom.name.to_token(); + let doc = doc_attr(&enom.doc); + let deprecated = deprecated_attr(&enom.deprecated); + let derive = derive_attr(ModelDerives { + serde: SerdeDerives { + deserialize: false, + ..derives.serde + }, + ..derives + }); + let transparent = derives.serde.serialize.then(|| return quote! { #[serde(transparent)] }); + let mut accessors = Vec::new(); + let mut checks = Vec::new(); + if derives.serde.deserialize { + for variant in variants { + let ty = emit_type(&variant.ty)?; + let accessor = format_ident!("as_{}", to_ident(variant.name.logical(), Case::Snake).logical()); + let accessor_doc = format!("Decode the `{}` Rust alternative.", variant.name.logical()); + checks.push(quote! { <#ty as serde::Deserialize>::deserialize(&value).is_ok() }); + accessors.push(quote! { + #[doc = #accessor_doc] + pub fn #accessor(&self) -> ::std::result::Result<#ty, serde_json::Error> { + return <#ty as serde::Deserialize>::deserialize(&self.value); + } + }); + } + } + let construction = if derives.serde.deserialize { + quote! { + impl ::std::convert::TryFrom for #name { + type Error = serde_json::Error; + + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(#(#checks)||*) { + return ::std::result::Result::Err(::custom( + "anyOf matched no Rust alternative" + )); + } + return ::std::result::Result::Ok(Self { value }); + } + } + + impl<'de> serde::Deserialize<'de> for #name { + fn deserialize<__Deserializer: serde::Deserializer<'de>>(deserializer: __Deserializer) -> ::std::result::Result { + let value = ::deserialize(deserializer)?; + return >::try_from(value).map_err(serde::de::Error::custom); + } + } + } + } else { + quote! { + /// Construct a raw JSON wrapper without alternative checks. + impl ::std::convert::From for #name { + fn from(value: serde_json::Value) -> Self { + return Self { value }; + } + } + } + }; + return Ok(quote! { + #doc + #derive + #transparent + #deprecated + pub struct #name { + value: serde_json::Value, + } + + impl #name { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + + #(#accessors)* + } + + #construction + }); +} diff --git a/crates/oapi-codegen/src/emit/usage.rs b/crates/oapi-codegen/src/emit/usage.rs index 56d6693..f5bc56f 100644 --- a/crates/oapi-codegen/src/emit/usage.rs +++ b/crates/oapi-codegen/src/emit/usage.rs @@ -127,9 +127,11 @@ impl ForeignResolver { let canonical = to_ident(name, Case::Pascal); return self.models.get(canonical.logical()).copied().unwrap_or_default(); } - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { - self.of_type(inner) - } + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => self.of_type(inner), _ => ForeignDerives::default(), }; } @@ -165,7 +167,20 @@ pub(crate) fn foreign_resolver(module: &Module) -> ForeignResolver { fn foreign_derives(module: &Module, adjacency: &HashMap>) -> HashMap { // Seed each model with what its own directly-named foreign types allow. let mut direct: HashMap = HashMap::new(); + let raw_unions: std::collections::HashSet<&str> = module + .items + .iter() + .filter_map(|item| { + if matches!(item, Item::Enum(enom) if matches!(enom.kind, crate::ir::EnumKind::AnyOf(_))) { + return Some(item.name()); + } + return None; + }) + .collect(); for item in &module.items { + if raw_unions.contains(item.name()) { + continue; + } let mut allowed = ForeignDerives::default(); for ty in item_types(item) { allowed = allowed.intersect(direct_foreign_constraint(&ty)); @@ -182,6 +197,9 @@ fn foreign_derives(module: &Module, adjacency: &HashMap>) -> // model to the models naming it. let mut referrers: HashMap<&str, Vec<&str>> = HashMap::new(); for (name, targets) in adjacency { + if raw_unions.contains(name.as_str()) { + continue; + } for target in targets { referrers.entry(target.as_str()).or_default().push(name.as_str()); } @@ -222,9 +240,11 @@ fn foreign_derives(module: &Module, adjacency: &HashMap>) -> fn direct_foreign_constraint(ty: &RustType) -> ForeignDerives { return match ty { RustType::Verbatim { derives, .. } => *derives, - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { - direct_foreign_constraint(inner) - } + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => direct_foreign_constraint(inner), _ => ForeignDerives::default(), }; } @@ -247,7 +267,7 @@ fn item_types(item: &Item) -> Vec { } } Item::Enum(enom) => { - if let crate::ir::EnumKind::Union(variants) = &enom.kind { + if let crate::ir::EnumKind::Union(variants) | crate::ir::EnumKind::AnyOf(variants) = &enom.kind { for variant in variants { types.push(variant.ty.clone()); } @@ -324,7 +344,7 @@ fn item_references(item: &Item) -> Vec { } } Item::Enum(enom) => { - if let crate::ir::EnumKind::Union(variants) = &enom.kind { + if let crate::ir::EnumKind::Union(variants) | crate::ir::EnumKind::AnyOf(variants) = &enom.kind { for variant in variants { collect_named(&variant.ty, &mut names); } @@ -346,9 +366,11 @@ fn item_references(item: &Item) -> Vec { fn collect_named(ty: &RustType, out: &mut Vec) { match ty { RustType::Named(name) => out.push(to_ident(name, Case::Pascal).logical().to_owned()), - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { - collect_named(inner, out) - } + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => collect_named(inner, out), _ => {} } } diff --git a/crates/oapi-codegen/src/error.rs b/crates/oapi-codegen/src/error.rs index 9694283..5ad0f5f 100644 --- a/crates/oapi-codegen/src/error.rs +++ b/crates/oapi-codegen/src/error.rs @@ -4,6 +4,15 @@ #[derive(Debug)] #[non_exhaustive] pub enum Error { + /// An OpenAPI object contains an invalid key or structure. + InvalidSpec { + /// The source document. + document: String, + /// The JSON pointer to the invalid entry. + path: String, + /// Why the entry is invalid. + reason: String, + }, /// The spec file cannot be read from disk. ReadSpec { /// Path that cannot be read. @@ -411,6 +420,9 @@ pub enum Error { impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Error::InvalidSpec { document, path, reason } => { + return write!(f, "{document}#{path}: {reason}"); + } Error::ReadSpec { path, source } => { return write!(f, "failed to read spec file `{path}`: {source}"); } @@ -594,7 +606,8 @@ impl std::error::Error for Error { Error::InvalidGeneratedCode { source } => return Some(source), // `Validation` holds problems at the same level and wraps no cause. // It has no single `source`. `Display` shows the problems instead. - Error::Validation { .. } + Error::InvalidSpec { .. } + | Error::Validation { .. } | Error::Unimplemented(_) | Error::UnownedOutput { .. } | Error::UnsplittableOutput { .. } diff --git a/crates/oapi-codegen/src/ir.rs b/crates/oapi-codegen/src/ir.rs index a3b6ad2..95ac73f 100644 --- a/crates/oapi-codegen/src/ir.rs +++ b/crates/oapi-codegen/src/ir.rs @@ -89,11 +89,10 @@ pub struct Field { /// The value that serde uses when the property is absent, from `default`. /// /// An optional property with a default is *not* wrapped in `Option`. Once - /// parsed, it always holds a value. Only `nullable` keeps the `Option`, - /// because there `null` is a value that the property can carry. + /// parsed, it always holds a value. A nullable value uses `Nullable`. /// /// `default: null` never reaches here. The parser reads it as no default at - /// all, and serde already leaves a missing `Option` as `None`. + /// all. A diagnostic reports this limitation. pub default: Option, /// The validation keywords the property declares, when it declares any. /// @@ -272,13 +271,10 @@ pub enum EnumKind { /// The permitted values, in document order. variants: Vec, }, - /// A `#[serde(untagged)]` union over the given newtype variants. - /// - /// Untagged (rather than internally tagged) is used even when the OpenAPI - /// schema has a discriminator: OpenAPI variant schemas typically carry the - /// discriminator property themselves, which is incompatible with serde's - /// internally-tagged representation. + /// A tag-free enum whose deserializer requires exactly one Rust payload match. Union(Vec), + /// A raw JSON wrapper with typed views and directional deserialization checks. + AnyOf(Vec), } /// A unit variant of a string enum. @@ -421,6 +417,8 @@ pub enum RustType { Map(Box), /// `Option`. Option(Box), + /// A present value that can be JSON null. + Nullable(Box), /// `Box`, added by the recursion pass to give a cyclic type a size. /// /// Nothing in a schema asks for this. `lower::recurse` inserts it where a @@ -461,13 +459,16 @@ impl RustType { /// own, so a rule reads the collection and not an element. pub fn innermost(&self) -> &RustType { return match self { - RustType::Option(inner) | RustType::Boxed(inner) => inner.innermost(), + RustType::Option(inner) | RustType::Nullable(inner) | RustType::Boxed(inner) => inner.innermost(), other => other, }; } /// Whether this is a scalar the generated code compares with `==`. pub fn is_scalar(&self) -> bool { + if let RustType::Nullable(inner) = self { + return inner.is_scalar(); + } return matches!( self, RustType::Bool @@ -502,6 +503,7 @@ impl RustType { RustType::Vec(inner) => format!("Vec<{}>", inner.label()), RustType::Map(inner) => format!("std::collections::HashMap", inner.label()), RustType::Option(inner) => format!("Option<{}>", inner.label()), + RustType::Nullable(inner) => format!("Nullable<{}>", inner.label()), RustType::Boxed(inner) => format!("Box<{}>", inner.label()), RustType::Named(name) => name.clone(), RustType::External { module, name } => format!("{module}::{name}"), diff --git a/crates/oapi-codegen/src/lib.rs b/crates/oapi-codegen/src/lib.rs index 3213328..21df4da 100644 --- a/crates/oapi-codegen/src/lib.rs +++ b/crates/oapi-codegen/src/lib.rs @@ -7,7 +7,9 @@ pub mod cli; pub mod config; +mod coverage; pub mod deps; +mod diagnostic; pub mod emit; pub mod error; pub mod filter; @@ -120,7 +122,11 @@ fn lower_spec(spec_path: &Path, config: &Config) -> Result { server: want_server, client: want_client, }; - lower::check_type_name_collisions(&service, &module, &emit::reserved_type_names(targets))?; + let mut reserved = emit::reserved_type_names(targets); + if !emit::uses_nullable(&module, Some(&service)) { + reserved.retain(|name| return name.name != "Nullable"); + } + lower::check_type_name_collisions(&service, &module, &reserved)?; lower::check_prelude_shadowing(&module, targets)?; return Ok(Lowered::Service { module, @@ -134,6 +140,13 @@ fn lower_spec(spec_path: &Path, config: &Config) -> Result { lower::split_by_direction(&mut module, None); names.check_emitted(&module)?; lower::check_duplicate_models(&module)?; + if emit::uses_nullable(&module, None) { + lower::check_type_name_collisions( + &Service::default(), + &module, + &emit::reserved_type_names(emit::Targets::default()), + )?; + } lower::check_prelude_shadowing(&module, emit::Targets::default())?; lower::box_recursive_types(&mut module)?; return Ok(Lowered::Models { module, server_urls }); diff --git a/crates/oapi-codegen/src/loader.rs b/crates/oapi-codegen/src/loader.rs index c063827..94a1396 100644 --- a/crates/oapi-codegen/src/loader.rs +++ b/crates/oapi-codegen/src/loader.rs @@ -90,6 +90,7 @@ impl Spec { // with a message that names a YAML shape and not a version. check_spec_version(&document, &value)?; check_top_level_keys(&value)?; + crate::coverage::check(&document, &value)?; let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| { return Error::ParseSpec { path: document.clone(), @@ -140,6 +141,7 @@ impl Spec { // before the typed parse for the same reason. check_spec_version(file, &value)?; check_top_level_keys(&value)?; + crate::coverage::check(file, &value)?; let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| { return Error::ParseRefFile { file: file.to_owned(), diff --git a/crates/oapi-codegen/src/lower/constraints.rs b/crates/oapi-codegen/src/lower/constraints.rs index 2aa806a..4aa21e6 100644 --- a/crates/oapi-codegen/src/lower/constraints.rs +++ b/crates/oapi-codegen/src/lower/constraints.rs @@ -49,6 +49,9 @@ pub(crate) fn inclusive_minimum(it: &openapiv3::IntegerType) -> Option { /// A keyword the generator cannot check on its own type is left alone. The /// parser accepts `minLength` on an integer, for example, and nothing reads it. pub(crate) fn constraints_of(schema: &Schema) -> Option { + if unsupported_nullable_constraints(schema) { + return None; + } let mut found = Constraints::default(); match &schema.schema_kind { SchemaKind::Type(Type::String(st)) => { @@ -106,6 +109,27 @@ pub(crate) fn constraints_of(schema: &Schema) -> Option { return Some(found); } +pub(crate) fn unsupported_nullable_constraints(schema: &Schema) -> bool { + if !schema.schema_data.nullable { + return false; + } + if schema.schema_data.extensions.contains_key(X_RUST_TYPE) { + return true; + } + return match &schema.schema_kind { + SchemaKind::Type(Type::String(value)) => { + !value.enumeration.is_empty() + || !matches!( + crate::lower::schema::string_format_type(&value.format), + RustType::String + ) + } + SchemaKind::Type(Type::Integer(value)) => !value.enumeration.is_empty(), + SchemaKind::Type(Type::Object(value)) => !value.properties.is_empty(), + _ => false, + }; +} + /// The keywords a `$ref` target gives a field, and the type they check. /// /// A `$ref` to a constrained scalar makes a type alias, and an alias carries no @@ -125,7 +149,11 @@ pub(crate) fn constraints_through_ref(target: &Schema) -> Option { SchemaKind::Type(Type::Number(nt)) if nt.enumeration.is_empty() => RustType::F64, _ => return None, }; - found.checked_as = Some(checked_as); + found.checked_as = Some(if target.schema_data.nullable { + RustType::Nullable(Box::new(checked_as)) + } else { + checked_as + }); return Some(found); } @@ -250,7 +278,7 @@ pub(crate) fn check_constraints(field: &Field) -> Result<()> { return Ok(()); }; let checked = match &constraints.checked_as { - Some(ty) => ty, + Some(ty) => ty.innermost(), None => field.ty.innermost(), }; let name = field.name.logical(); @@ -269,17 +297,46 @@ pub(crate) fn check_constraints(field: &Field) -> Result<()> { } // An empty range names the fault on its own. A bound the type cannot hold is // the same fault seen from further away, so only one of the two is reported. - match empty_range_reason(constraints, checked) { - Some(reason) => diagnostics.push(Error::UnsupportedSchema { - path: name.to_owned(), - reason, - }), - None => check_width(constraints, checked, name, &mut diagnostics), + if !accepts_only_null(field) { + match empty_range_reason(constraints, checked) { + Some(reason) => diagnostics.push(Error::UnsupportedSchema { + path: name.to_owned(), + reason, + }), + None => check_width(constraints, checked, name, &mut diagnostics), + } } check_reach(constraints, checked, name, &mut diagnostics); return diagnostics.into_result(); } +pub(crate) fn accepts_only_null(field: &Field) -> bool { + let Some(constraints) = &field.constraints else { + return false; + }; + let nullable = has_nullable_wrapper(&field.ty) || constraints.checked_as.as_ref().is_some_and(has_nullable_wrapper); + if !nullable { + return false; + } + let checked = constraints.checked_as.as_ref().unwrap_or(&field.ty).innermost(); + if empty_range_reason(constraints, checked).is_some() { + return true; + } + let Some((low, high)) = integer_limits(checked) else { + return false; + }; + return int_bound(constraints.maximum).is_some_and(|maximum| return maximum < low) + || high.is_some_and(|high| return int_bound(constraints.minimum).is_some_and(|minimum| return minimum > high)); +} + +fn has_nullable_wrapper(ty: &RustType) -> bool { + return match ty { + RustType::Nullable(_) => true, + RustType::Option(inner) | RustType::Boxed(inner) => has_nullable_wrapper(inner), + _ => false, + }; +} + /// Reject a bound too wide for the type the field holds. /// /// A bound outside the range of a narrow type writes a literal that does not diff --git a/crates/oapi-codegen/src/lower/default.rs b/crates/oapi-codegen/src/lower/default.rs index 695497b..8bdc8e9 100644 --- a/crates/oapi-codegen/src/lower/default.rs +++ b/crates/oapi-codegen/src/lower/default.rs @@ -5,11 +5,9 @@ //! a variant path for an enum. This pass settles that against the field type, //! while the schema is still in hand. The emitter then prints the result. //! -//! A value with no literal form is an error, not a silent drop. A dropped -//! default leaves the document and the code in disagreement. +//! A value with no literal form produces an error or a diagnostic, not a silent drop. //! -//! `default: null` never arrives. The parser reads it as no default at all, and -//! serde already leaves a missing `Option` as `None`. +//! The parser discards `default: null`. The coverage pass reports this limitation. use serde_json::Value; @@ -19,6 +17,46 @@ use crate::ir::DefaultValue; use crate::ir::RustType; use crate::ir::StringVariant; +pub(crate) fn unsupported_nullable_default(schema: &openapiv3::Schema) -> bool { + use openapiv3::SchemaKind; + use openapiv3::Type; + + if !schema.schema_data.nullable || schema.schema_data.default.is_none() { + return false; + } + if schema + .schema_data + .extensions + .contains_key(crate::lower::schema::X_RUST_TYPE) + { + return true; + } + return match &schema.schema_kind { + SchemaKind::Type(Type::String(value)) => { + !matches!( + crate::lower::schema::string_format_type(&value.format), + RustType::String + ) && value.enumeration.is_empty() + } + SchemaKind::Type(Type::Integer(value)) => !value.enumeration.is_empty(), + SchemaKind::Type(Type::Boolean(_) | Type::Number(_)) => false, + SchemaKind::Type(Type::Array(_)) => schema + .schema_data + .default + .as_ref() + .is_some_and(|value| return value.as_array().is_some_and(|items| return !items.is_empty())), + SchemaKind::Type(Type::Object(value)) => { + !value.properties.is_empty() + || schema + .schema_data + .default + .as_ref() + .is_some_and(|value| return value.as_object().is_some_and(|items| return !items.is_empty())) + } + _ => true, + }; +} + /// Lower a `default` against the type of the property. /// /// `variants_of` finds a generated string enum by the name in @@ -56,9 +94,7 @@ fn value_for( variants_of: &dyn Fn(&str) -> Option>, ) -> Option { return match ty { - // A `nullable` property keeps its `Option`. The default fills the - // `Some` side of it. - RustType::Option(inner) => value_for(json, inner, variants_of), + RustType::Option(inner) | RustType::Nullable(inner) => value_for(json, inner, variants_of), RustType::Boxed(inner) => value_for(json, inner, variants_of), RustType::Bool => json.as_bool().map(DefaultValue::Bool), RustType::I64 => json.as_i64().map(DefaultValue::Int), @@ -119,7 +155,7 @@ fn variant_for( /// The type, in the words a specification author uses, for the error message. fn describe(ty: &RustType) -> String { return match ty { - RustType::Option(inner) | RustType::Boxed(inner) => describe(inner), + RustType::Option(inner) | RustType::Nullable(inner) | RustType::Boxed(inner) => describe(inner), RustType::Bool => "a boolean".to_owned(), RustType::I32 => "a 32-bit integer".to_owned(), RustType::I64 => "an integer".to_owned(), @@ -209,14 +245,21 @@ mod tests { } #[test] - fn a_nullable_field_defaults_to_the_some_side() { - let optional = RustType::Option(Box::new(RustType::String)); + fn a_nullable_field_defaults_to_the_value_side() { + let optional = RustType::Nullable(Box::new(RustType::String)); assert_eq!( lowered(Value::from("hi"), &optional), DefaultValue::Str("hi".to_owned()) ); } + #[test] + fn the_openapi_parser_discards_explicit_null_defaults() { + let schema: openapiv3::Schema = + serde_yaml::from_str("type: string\nnullable: true\ndefault: null").expect("valid nullable schema"); + assert!(schema.schema_data.default.is_none()); + } + #[test] fn a_value_of_the_wrong_type_is_rejected() { assert!(lower(Value::from("7"), &RustType::I64).is_err()); diff --git a/crates/oapi-codegen/src/lower/direction.rs b/crates/oapi-codegen/src/lower/direction.rs index ff82fb4..843f618 100644 --- a/crates/oapi-codegen/src/lower/direction.rs +++ b/crates/oapi-codegen/src/lower/direction.rs @@ -202,8 +202,8 @@ fn project_item(item: &Item, direction: Direction, projections: &BTreeMap EnumKind::Union( - variants + EnumKind::Union(variants) | EnumKind::AnyOf(variants) => { + let variants = variants .iter() .map(|variant| { return UnionVariant { @@ -211,8 +211,13 @@ fn project_item(item: &Item, direction: Direction, projections: &BTreeMap other.clone(), }, ..enom.clone() @@ -279,6 +284,7 @@ fn project_type(ty: &RustType, direction: Direction, projections: &BTreeMap RustType::Vec(Box::new(project_type(inner, direction, projections))), RustType::Map(inner) => RustType::Map(Box::new(project_type(inner, direction, projections))), RustType::Option(inner) => RustType::Option(Box::new(project_type(inner, direction, projections))), + RustType::Nullable(inner) => RustType::Nullable(Box::new(project_type(inner, direction, projections))), RustType::Boxed(inner) => RustType::Boxed(Box::new(project_type(inner, direction, projections))), other => other.clone(), }; diff --git a/crates/oapi-codegen/src/lower/paths.rs b/crates/oapi-codegen/src/lower/paths.rs index d28b96d..4461be3 100644 --- a/crates/oapi-codegen/src/lower/paths.rs +++ b/crates/oapi-codegen/src/lower/paths.rs @@ -45,6 +45,7 @@ use openapiv3::Operation as OasOperation; use openapiv3::Parameter; use openapiv3::ParameterData; use openapiv3::ParameterSchemaOrContent; +use openapiv3::PathStyle; use openapiv3::QueryStyle; use openapiv3::ReferenceOr; use openapiv3::RequestBody; @@ -55,6 +56,8 @@ use openapiv3::SchemaKind; use openapiv3::StatusCode; use openapiv3::Type; +use crate::diagnostic::Warning; +use crate::diagnostic::report_warnings; use crate::error::Error; use crate::error::Result; use crate::ir::Body; @@ -332,8 +335,9 @@ impl Lowerer<'_> { // the template. Driving the loop above from the template alone will // otherwise silently drop such a parameter from the generated signature, // producing a handler that omits a required input. + let mut seen = Vec::new(); for parameter in params { - let Parameter::Path { parameter_data, .. } = ¶meter.value else { + let Parameter::Path { parameter_data, style } = ¶meter.value else { continue; }; if !placeholders @@ -346,6 +350,20 @@ impl Lowerer<'_> { name: parameter_data.name.clone(), }); } + if seen.contains(¶meter_data.name.as_str()) { + continue; + } + seen.push(parameter_data.name.as_str()); + if !matches!(style, PathStyle::Simple) { + self.warn( + parameter.origin.as_deref(), + &format!("{method} {path}"), + format!( + "path parameter `{}` uses unsupported style `{style:?}`. Generated code uses `simple` encoding", + parameter_data.name + ), + ); + } } return Ok(path_params); } @@ -499,7 +517,14 @@ impl Lowerer<'_> { }); } let element = match &array.items { - Some(ReferenceOr::Item(item)) => scalar_type(&item.schema_kind), + Some(ReferenceOr::Item(item)) => { + self.warn_scalar_enum( + origin, + &format!("{method} {path} query parameter `{name}` items"), + &item.schema_kind, + ); + scalar_type(&item.schema_kind) + } Some(ReferenceOr::Reference { reference }) if ref_file_part(reference).is_some() => { return Err(Error::UnsupportedOperation { method: method.to_owned(), @@ -511,6 +536,11 @@ impl Lowerer<'_> { } Some(ReferenceOr::Reference { reference }) => { let item = self.spec.resolve_schema(origin, reference)?; + self.warn_scalar_enum( + origin, + &format!("{method} {path} query parameter `{name}` items"), + &item.schema_kind, + ); scalar_type(&item.schema_kind) } None => { @@ -537,6 +567,20 @@ impl Lowerer<'_> { reason: format!("query parameter `{name}` must be a scalar or an array of scalars"), }; })?; + self.warn_scalar_enum( + origin, + &format!("{method} {path} query parameter `{name}`"), + &schema.schema_kind, + ); + if !matches!(style, QueryStyle::Form) { + self.warn( + origin, + &format!("{method} {path}"), + format!( + "query parameter `{name}` uses unsupported scalar style `{style:?}`. Generated code uses `form` encoding" + ), + ); + } return Ok(ty); } @@ -674,6 +718,11 @@ impl Lowerer<'_> { reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"), }); } + self.warn_scalar_enum( + origin, + &format!("{method} {path} {kind_label} `{name}`"), + &schema.schema_kind, + ); return Ok(ty); } @@ -784,9 +833,30 @@ impl Lowerer<'_> { reason: format!("cookie parameter `{name}` uses a `byte`/`binary` format, which is not supported"), }); } + self.warn_scalar_enum( + origin, + &format!("{method} {path} cookie parameter `{name}`"), + &schema.schema_kind, + ); return Ok(ty); } + fn warn_scalar_enum(&self, origin: Option<&str>, context: &str, kind: &SchemaKind) { + let has_enum = match kind { + SchemaKind::Type(Type::String(schema)) => !schema.enumeration.is_empty(), + SchemaKind::Type(Type::Integer(schema)) => !schema.enumeration.is_empty(), + _ => false, + }; + if has_enum { + self.warn( + origin, + context, + "the declared `enum` is ignored. Generated code uses the base scalar type without enum validation" + .to_owned(), + ); + } + } + /// Collect every supported content type a body declares, deduplicated by /// [`BodyKind`] and ordered by the caller's `priority` (requests and /// responses differ — see [`REQUEST_BODY_PRIORITY`] / @@ -798,19 +868,47 @@ impl Lowerer<'_> { &self, content: &'m indexmap::IndexMap, priority: &[BodyKind], + origin: Option<&str>, + context: &str, ) -> Vec<(BodyKind, &'m openapiv3::MediaType)> { let mut selected = Vec::new(); for &wanted in priority { + let mut first = None; for (name, media) in content { if media_type_kind(name) == Some(wanted) { - selected.push((wanted, media)); - break; + match first { + Some(first) => self.warn( + origin, + context, + format!("media type `{name}` is ignored because `{first}` is the first representation of the same body kind"), + ), + None => { + selected.push((wanted, media)); + first = Some(name); + } + } + } + } + } + if !selected.is_empty() { + for name in content.keys() { + if !media_type_kind(name).is_some_and(|kind| return priority.contains(&kind)) { + self.warn(origin, context, format!("unsupported media type `{name}` is ignored")); } } } return selected; } + fn warn(&self, origin: Option<&str>, context: &str, message: String) { + let document = self.spec.source().display().to_string(); + let message = match origin { + Some(origin) => format!("{message} (resolved from `{origin}`)"), + None => message, + }; + report_warnings(&document, &[Warning::new(context, message)]); + } + /// Lower a selected body media entry into a typed [`Body`] for the given /// content kind. Text bodies must be `string`. form bodies must reference a /// named object schema. JSON reuses the existing body-type mapping. @@ -1002,6 +1100,11 @@ impl Lowerer<'_> { reason: format!("path parameter `{name}` must be a scalar type"), }; })?; + self.warn_scalar_enum( + origin, + &format!("{method} {path} path parameter `{name}`"), + &schema.schema_kind, + ); return Ok(ty); } @@ -1032,7 +1135,12 @@ impl Lowerer<'_> { (resolved.value, resolved.origin) } }; - let supported = self.supported_bodies(&body.content, &REQUEST_BODY_PRIORITY); + let supported = self.supported_bodies( + &body.content, + &REQUEST_BODY_PRIORITY, + origin.as_deref(), + &format!("{method} {path} request body"), + ); if supported.is_empty() { if body.content.is_empty() { return Ok(None); @@ -1426,7 +1534,12 @@ impl Lowerer<'_> { origin: Option<&str>, response: &OasResponse, ) -> Result> { - let supported = self.supported_bodies(&response.content, &RESPONSE_BODY_PRIORITY); + let supported = self.supported_bodies( + &response.content, + &RESPONSE_BODY_PRIORITY, + origin, + &format!("{method} {path} {location}"), + ); if supported.is_empty() && !response.content.is_empty() { return Err(Error::UnsupportedContentType { method: method.to_owned(), @@ -1511,7 +1624,33 @@ impl Lowerer<'_> { }; RustType::Vec(Box::new(element)) } + SchemaKind::Type(Type::Object(object)) if object.properties.is_empty() => { + let value = match &object.additional_properties { + Some(openapiv3::AdditionalProperties::Schema(value)) => { + self.body_type(path, method, origin, value)? + } + Some(openapiv3::AdditionalProperties::Any(true)) | None => RustType::Value, + Some(openapiv3::AdditionalProperties::Any(false)) => { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: "closed object bodies must reference a named schema (`$ref`)".to_owned(), + }); + } + }; + RustType::Map(Box::new(value)) + } SchemaKind::Any(_) => RustType::Value, + SchemaKind::AllOf { all_of } => { + let [member] = all_of.as_slice() else { + return Err(Error::UnsupportedOperation { + method: method.to_owned(), + path: path.to_owned(), + reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(), + }); + }; + self.body_type(path, method, origin, member)? + } _ => { return Err(Error::UnsupportedOperation { method: method.to_owned(), @@ -1520,7 +1659,11 @@ impl Lowerer<'_> { }); } }; - return Ok(ty); + return Ok(if schema.schema_data.nullable { + crate::lower::schema::nullable_type(self.spec, ty) + } else { + ty + }); } /// Decide the Rust type for a schema `$ref`, given the referenced file the diff --git a/crates/oapi-codegen/src/lower/prune.rs b/crates/oapi-codegen/src/lower/prune.rs index 4f70922..0dc796c 100644 --- a/crates/oapi-codegen/src/lower/prune.rs +++ b/crates/oapi-codegen/src/lower/prune.rs @@ -68,9 +68,11 @@ fn canonical(name: &str) -> String { fn named_ref(ty: &RustType) -> Option { return match ty { RustType::Named(name) => Some(canonical(name)), - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { - named_ref(inner) - } + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => named_ref(inner), _ => None, }; } @@ -81,7 +83,7 @@ fn item_refs(item: &Item) -> Vec { Item::Struct(s) => struct_refs(s), Item::Enum(enumeration) => match &enumeration.kind { EnumKind::Strings(_) | EnumKind::Integers { .. } => Vec::new(), - EnumKind::Union(variants) => variants + EnumKind::Union(variants) | EnumKind::AnyOf(variants) => variants .iter() .filter_map(|variant| return named_ref(&variant.ty)) .collect(), diff --git a/crates/oapi-codegen/src/lower/recurse.rs b/crates/oapi-codegen/src/lower/recurse.rs index a46d738..c201710 100644 --- a/crates/oapi-codegen/src/lower/recurse.rs +++ b/crates/oapi-codegen/src/lower/recurse.rs @@ -38,11 +38,11 @@ use crate::naming::to_ident; /// Box every field and variant of `module` that closes a type cycle. /// -/// Fails when a cycle runs only through aliases, because a `Box` there still -/// expands forever. +/// Reject alias-only cycles and union cycles that consume no input. pub fn box_recursive_types(module: &mut Module) -> Result<()> { let graph = Graph::of(module); graph.check_alias_cycles()?; + graph.check_union_cycles(module)?; for item in &mut module.items { let owner = canonical(item.name()); @@ -53,7 +53,7 @@ pub fn box_recursive_types(module: &mut Module) -> Result<()> { } } Item::Enum(enumeration) => { - if let EnumKind::Union(variants) = &mut enumeration.kind { + if let EnumKind::Union(variants) | EnumKind::AnyOf(variants) = &mut enumeration.kind { for variant in variants { box_held(&mut variant.ty, &owner, &graph); } @@ -110,7 +110,7 @@ impl Graph { } } Item::Enum(enumeration) => { - if let EnumKind::Union(variants) = &enumeration.kind { + if let EnumKind::Union(variants) | EnumKind::AnyOf(variants) = &enumeration.kind { for variant in variants { collect_held(&variant.ty, &mut targets); } @@ -176,6 +176,37 @@ impl Graph { return Ok(()); } + fn check_union_cycles(&self, module: &Module) -> Result<()> { + // Object properties consume input; transparent union and alias edges do not. + let edges: Vec> = self + .edges + .iter() + .zip(&module.items) + .map(|(targets, item)| { + return if matches!(item, Item::Struct(_)) { + Vec::new() + } else { + targets.clone() + }; + }) + .collect(); + let component = components(&edges); + for (node, targets) in edges.iter().enumerate() { + if !self.is_alias(node) + && targets + .iter() + .any(|target| return component.get(node) == component.get(*target)) + { + return Err(Error::UnsupportedSchema { + path: self.name_of(node), + reason: "union recursion must pass through an object property or array element to consume input" + .to_owned(), + }); + } + } + return Ok(()); + } + /// Whether the items of one component refer to each other in a cycle. /// /// Every node is its own component, so a lone node is only cyclic when it @@ -395,7 +426,7 @@ fn collect_held(ty: &RustType, out: &mut BTreeSet) { RustType::Named(name) => { out.insert(canonical(name)); } - RustType::Option(inner) => collect_held(inner, out), + RustType::Option(inner) | RustType::Nullable(inner) => collect_held(inner, out), _ => {} } } @@ -413,7 +444,7 @@ fn box_held(ty: &mut RustType, owner: &str, graph: &Graph) { *ty = RustType::Boxed(Box::new(inner)); } } - RustType::Option(inner) => box_held(inner, owner, graph), + RustType::Option(inner) | RustType::Nullable(inner) => box_held(inner, owner, graph), _ => {} } } @@ -539,25 +570,27 @@ mod tests { assert_eq!(field_type(&module, "Holder"), named("Node")); } - /// A union variant holds its type the way a field does, so it is boxed too. #[test] - fn a_union_variant_that_holds_its_own_enum_is_boxed() { + fn a_union_cycle_through_an_object_is_boxed() { let mut module = Module { - items: vec![Item::Enum(Enum { - name: to_ident("Expression", Case::Pascal), - doc: None, - deprecated: None, - kind: EnumKind::Union(vec![ - UnionVariant { - name: to_ident("Text", Case::Pascal), - ty: RustType::String, - }, - UnionVariant { - name: to_ident("Nested", Case::Pascal), - ty: named("Expression"), - }, - ]), - })], + items: vec![ + Item::Enum(Enum { + name: to_ident("Expression", Case::Pascal), + doc: None, + deprecated: None, + kind: EnumKind::Union(vec![ + UnionVariant { + name: to_ident("Text", Case::Pascal), + ty: RustType::String, + }, + UnionVariant { + name: to_ident("Nested", Case::Pascal), + ty: named("Nested"), + }, + ]), + }), + one_field("Nested", "expression", named("Expression")), + ], }; box_recursive_types(&mut module).expect("no alias cycle in this module"); let Item::Enum(enumeration) = &module.items[0] else { @@ -567,7 +600,50 @@ mod tests { panic!("the enum is a union"); }; assert_eq!(variants[0].ty, RustType::String); - assert_eq!(variants[1].ty, boxed(named("Expression"))); + assert_eq!(variants[1].ty, boxed(named("Nested"))); + assert_eq!(field_type(&module, "Nested"), boxed(named("Expression"))); + } + + #[test] + fn non_consuming_union_cycles_are_rejected() { + for inclusive in [false, true] { + for through_alias in [false, true] { + let variants = vec![ + UnionVariant { + name: to_ident("Text", Case::Pascal), + ty: RustType::String, + }, + UnionVariant { + name: to_ident("Loop", Case::Pascal), + ty: named(if through_alias { "Alias" } else { "Loop" }), + }, + ]; + let mut module = Module { + items: vec![Item::Enum(Enum { + name: to_ident("Loop", Case::Pascal), + doc: None, + deprecated: None, + kind: if inclusive { + EnumKind::AnyOf(variants) + } else { + EnumKind::Union(variants) + }, + })], + }; + if through_alias { + module.items.push(Item::Alias(Alias { + name: to_ident("Alias", Case::Pascal), + doc: None, + deprecated: None, + ty: named("Loop"), + })); + } + assert!(matches!( + box_recursive_types(&mut module), + Err(Error::UnsupportedSchema { .. }) + )); + } + } } /// An alias offers nothing to box, so a cycle running through one is broken diff --git a/crates/oapi-codegen/src/lower/rename.rs b/crates/oapi-codegen/src/lower/rename.rs index fbc0cbf..3225a77 100644 --- a/crates/oapi-codegen/src/lower/rename.rs +++ b/crates/oapi-codegen/src/lower/rename.rs @@ -349,7 +349,11 @@ fn visit_service_types(service: &mut Service, visit: &mut dyn FnMut(&mut RustTyp /// Recurse container types to their leaf, applying `visit` to the leaf in place. fn visit_type(ty: &mut RustType, visit: &mut dyn FnMut(&mut RustType)) { match ty { - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => { visit_type(inner, visit); } leaf => visit(leaf), @@ -368,7 +372,7 @@ fn rewrite_item(item: &mut Item, renames: &HashMap) { } } Item::Enum(enumeration) => { - if let EnumKind::Union(variants) = &mut enumeration.kind { + if let EnumKind::Union(variants) | EnumKind::AnyOf(variants) = &mut enumeration.kind { for variant in variants { rewrite_type(&mut variant.ty, renames); } @@ -386,7 +390,11 @@ fn rewrite_type(ty: &mut RustType, renames: &HashMap) { *name = custom.clone(); } } - RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => { + RustType::Vec(inner) + | RustType::Map(inner) + | RustType::Option(inner) + | RustType::Nullable(inner) + | RustType::Boxed(inner) => { rewrite_type(inner, renames); } _ => {} diff --git a/crates/oapi-codegen/src/lower/schema.rs b/crates/oapi-codegen/src/lower/schema.rs index 3156b50..3bf3a96 100644 --- a/crates/oapi-codegen/src/lower/schema.rs +++ b/crates/oapi-codegen/src/lower/schema.rs @@ -1,7 +1,6 @@ //! Lowering OpenAPI schemas into the [`crate::ir`] representation. use openapiv3::AdditionalProperties; -use openapiv3::Discriminator; use openapiv3::IntegerFormat; use openapiv3::IntegerType; use openapiv3::ObjectType; @@ -17,6 +16,7 @@ use crate::error::Error; use crate::error::Result; use crate::ir::Access; use crate::ir::Alias; +use crate::ir::Constraints; use crate::ir::Deprecation; use crate::ir::Enum; use crate::ir::EnumKind; @@ -148,6 +148,25 @@ impl Mapper<'_> { /// Lower a top-level named schema into a single item. fn named_to_item(&mut self, name: &str, schema: &Schema) -> Result { + if schema.schema_data.nullable { + let mut non_null = schema.clone(); + non_null.schema_data.nullable = false; + let value_name = format!("{name}Value"); + let item = self.named_to_item(&value_name, &non_null)?; + let ty = match item { + Item::Alias(alias) => alias.ty, + item => { + self.extra.push(item); + RustType::Named(value_name) + } + }; + return Ok(Item::Alias(Alias { + name: self.type_name_ident(name), + doc: doc_of(&schema.schema_data), + deprecated: deprecation_of(&schema.schema_data, name)?, + ty: nullable_type(self.spec, ty), + })); + } let data = &schema.schema_data; if let Some(verbatim) = extension_str(data, X_RUST_TYPE, name)? { @@ -168,9 +187,12 @@ impl Mapper<'_> { Item::Enum(self.integer_enum(name, &it.enumeration, &repr, data)?) } SchemaKind::Type(Type::Object(obj)) => self.object_to_item(name, obj, data)?, - SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => { - Item::Enum(self.make_union(name, one_of, data)?) - } + SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => Item::Enum(self.make_union( + name, + one_of, + data, + matches!(schema.schema_kind, SchemaKind::AnyOf { .. }), + )?), SchemaKind::AllOf { all_of } => match self.single_ref_all_of(all_of)? { Some(target) => Item::Alias(Alias { name: self.type_name_ident(name), @@ -189,11 +211,11 @@ impl Mapper<'_> { ty, }) } - SchemaKind::Any(_) => Item::Alias(Alias { + SchemaKind::Any(schema) => Item::Alias(Alias { name: self.type_name_ident(name), doc: doc_of(data), deprecated: deprecation_of(data, name)?, - ty: RustType::Value, + ty: self.unconstrained_type(name, schema), }), SchemaKind::Not { .. } => { return Err(Error::UnsupportedSchema { @@ -278,13 +300,10 @@ impl Mapper<'_> { // valid. let declared = data .filter(|_| return !required) + .filter(|_| return !matches!(prop, ReferenceOr::Item(schema) if crate::lower::default::unsupported_nullable_default(schema))) .and_then(|data| return data.default.as_ref()); - let nullable = data.map(|data| return data.nullable).unwrap_or(false); - // With a default, an absent property ends up the same as a present one, - // so `Option` would only ever hold `Some`. `nullable` is the exception, - // because there `null` is a value the property carries. - if (!required && declared.is_none()) || nullable { + if !required && declared.is_none() { ty = ty.optional(); } @@ -296,7 +315,7 @@ impl Mapper<'_> { return match item { Item::Enum(enom) if enom.name == ident => match &enom.kind { EnumKind::Strings(variants) => Some(variants.clone()), - EnumKind::Union(_) | EnumKind::Integers { .. } => None, + EnumKind::Union(_) | EnumKind::AnyOf(_) | EnumKind::Integers { .. } => None, }, _ => None, }; @@ -339,16 +358,28 @@ impl Mapper<'_> { Err(_) => Access::ReadWrite, }, }; - let constraints = match prop { - ReferenceOr::Item(schema) => crate::lower::constraints::constraints_of(schema), + let mut constraints = match prop { + ReferenceOr::Item(schema) => crate::lower::constraints::constraints_of(schema) + .or_else(|| return self.single_all_of_constraints(schema, 0)), // The alias a `$ref` makes carries no serde attribute, so the field // takes the checks the target declares. ReferenceOr::Reference { reference } => self .spec .resolve(reference) .ok() - .and_then(crate::lower::constraints::constraints_through_ref), + .and_then(|schema| return self.referenced_constraints(schema, 0)), + }; + let value_ty = match &ty { + RustType::Option(inner) => inner.as_ref(), + _ => &ty, }; + if matches!(value_ty, RustType::Nullable(_)) + && let Some(found) = &mut constraints + && let Some(RustType::Nullable(inner)) = &found.checked_as + { + // The field wrapper consumes nullability before the alias checks run. + found.checked_as = Some(*inner.clone()); + } let field = Field { name: ident, rename, @@ -366,6 +397,36 @@ impl Mapper<'_> { return Ok(field); } + fn referenced_constraints(&self, schema: &Schema, depth: usize) -> Option { + if let Some(found) = crate::lower::constraints::constraints_through_ref(schema) { + return Some(found); + } + let mut found = self.single_all_of_constraints(schema, depth)?; + if schema.schema_data.nullable + && let Some(checked) = found.checked_as.take() + { + found.checked_as = Some(nullable_type(self.spec, checked)); + } + return Some(found); + } + + fn single_all_of_constraints(&self, schema: &Schema, depth: usize) -> Option { + if depth >= MAX_SCHEMA_DEPTH || schema.schema_data.extensions.contains_key(X_RUST_TYPE) { + return None; + } + let SchemaKind::AllOf { all_of } = &schema.schema_kind else { + return None; + }; + let [member] = all_of.as_slice() else { + return None; + }; + let target = match member { + ReferenceOr::Reference { reference } => self.spec.resolve(reference).ok()?, + ReferenceOr::Item(schema) => schema, + }; + return self.referenced_constraints(target, depth + 1); + } + /// Return the referenced schema name when `members` is a single `$ref` /// member, for collapsing a one-element `allOf` at the top level into a type /// alias. Only `$ref` members qualify: a single inline member is left to the @@ -479,39 +540,53 @@ impl Mapper<'_> { } return Ok(()); } - fn make_union(&mut self, name: &str, members: &[ReferenceOr], data: &SchemaData) -> Result { - let variants = match &data.discriminator { - Some(disc) if !disc.mapping.is_empty() => self.union_variants_from_mapping(disc)?, - Some(_) | None => self.union_variants_from_members(name, members)?, - }; - check_variant_types(name, &variants)?; + + fn make_union( + &mut self, + name: &str, + members: &[ReferenceOr], + data: &SchemaData, + any_of: bool, + ) -> Result { + if members.is_empty() { + return Err(Error::UnsupportedSchema { + path: name.to_owned(), + reason: "a union must contain at least one alternative".to_owned(), + }); + } + let variants = self.union_variants_from_members(name, members, data)?; + if any_of { + let mut methods = std::collections::HashSet::from(["as_value".to_owned(), "into_value".to_owned()]); + for variant in &variants { + let method = format!("as_{}", to_ident(variant.name.logical(), Case::Snake).logical()); + if !methods.insert(method.clone()) { + return Err(Error::UnsupportedSchema { + path: name.to_owned(), + reason: format!( + "anyOf accessor `{method}` collides with another method. Rename the alternative with x-rust-name" + ), + }); + } + } + } return Ok(Enum { name: self.type_name_ident(name), doc: doc_of(data), deprecated: deprecation_of(data, name)?, - kind: EnumKind::Union(variants), + kind: if any_of { + EnumKind::AnyOf(variants) + } else { + EnumKind::Union(variants) + }, }); } - /// Variant list derived from a discriminator mapping (value -> $ref). - fn union_variants_from_mapping(&self, disc: &Discriminator) -> Result> { - let mut variants = Vec::with_capacity(disc.mapping.len()); - let mut seen = std::collections::HashSet::new(); - for (value, reference) in &disc.mapping { - let target = self.schema_ref_target(reference, "a discriminator mapping")?; - variants.push(UnionVariant { - name: crate::naming::deconflict_ident(to_ident(value, Case::Pascal), &mut seen), - ty: RustType::Named(target), - }); - } - return Ok(variants); - } - /// Variant list derived from the `oneOf`/`anyOf` member schemas directly. fn union_variants_from_members( &mut self, name: &str, members: &[ReferenceOr], + data: &SchemaData, ) -> Result> { let mut variants = Vec::with_capacity(members.len()); let mut seen = std::collections::HashSet::new(); @@ -520,12 +595,16 @@ impl Mapper<'_> { let variant = match member { ReferenceOr::Reference { reference } => { let target = self.schema_ref_target(reference, "a union member")?; - // Name the variant from the *resolved* type name, so an - // `x-rust-name` override or a configured collision suffix - // reaches the variant too. The raw target name would give a - // variant that contradicts its own payload type. + let variant_name = data + .discriminator + .as_ref() + .and_then(|disc| return disc.mapping.iter().find(|(_, mapped)| return *mapped == reference)) + .map_or_else( + || return self.type_name_ident(&target), + |(value, _)| return to_ident(value, Case::Pascal), + ); UnionVariant { - name: crate::naming::deconflict_ident(self.type_name_ident(&target), &mut seen), + name: crate::naming::deconflict_ident(variant_name, &mut seen), ty: RustType::Named(target), } } @@ -572,7 +651,10 @@ impl Mapper<'_> { let mut variants = Vec::new(); let mut seen = std::collections::HashSet::new(); let mut values_seen = std::collections::HashSet::new(); - for (index, value) in values.iter().flatten().enumerate() { + for (index, value) in values.iter().enumerate() { + let Some(value) = value else { + continue; + }; if !values_seen.insert(value.as_str()) { diagnostics.push(Error::UnsupportedSchema { path: name.to_owned(), @@ -619,7 +701,10 @@ impl Mapper<'_> { let mut variants = Vec::new(); let mut seen = std::collections::HashSet::new(); let mut values_seen = std::collections::HashSet::new(); - for (index, value) in values.iter().flatten().enumerate() { + for (index, value) in values.iter().enumerate() { + let Some(value) = value else { + continue; + }; if !values_seen.insert(*value) { diagnostics.push(Error::UnsupportedSchema { path: name.to_owned(), @@ -697,7 +782,12 @@ impl Mapper<'_> { }); } self.depth += 1; - let result = self.type_from_schema_inner(hint, schema); + let result = self.type_from_schema_inner(hint, schema).map(|ty| { + if schema.schema_data.nullable { + return nullable_type(self.spec, ty); + } + return ty; + }); self.depth -= 1; return result; } @@ -736,7 +826,12 @@ impl Mapper<'_> { } SchemaKind::Type(Type::Object(obj)) => self.inline_object_type(hint, obj, data)?, SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => { - let enom = self.make_union(hint, one_of, data)?; + let enom = self.make_union( + hint, + one_of, + data, + matches!(schema.schema_kind, SchemaKind::AnyOf { .. }), + )?; self.extra.push(Item::Enum(enom)); RustType::Named(hint.to_owned()) } @@ -749,7 +844,7 @@ impl Mapper<'_> { RustType::Named(hint.to_owned()) } } - SchemaKind::Any(_) => RustType::Value, + SchemaKind::Any(schema) => self.unconstrained_type(hint, schema), SchemaKind::Not { .. } => { return Err(Error::UnsupportedSchema { path: hint.to_owned(), @@ -772,6 +867,19 @@ impl Mapper<'_> { return Ok(RustType::Named(hint.to_owned())); } + fn unconstrained_type(&self, name: &str, schema: &openapiv3::AnySchema) -> RustType { + if *schema != openapiv3::AnySchema::default() { + crate::diagnostic::report_warnings( + &self.spec.source().display().to_string(), + &[crate::diagnostic::Warning::new( + name, + "this schema combination is not implemented and becomes an unconstrained JSON value", + )], + ); + } + return RustType::Value; + } + /// Element type for an object used purely as a map (`additionalProperties`). fn additional_properties_type(&mut self, hint: &str, obj: &ObjectType) -> Result { let element = match &obj.additional_properties { @@ -782,6 +890,44 @@ impl Mapper<'_> { } } +/// Add nullability once. Only local aliases reveal the nullability of a named type. +pub(crate) fn nullable_type(spec: &Spec, ty: RustType) -> RustType { + if matches!(ty, RustType::Nullable(_)) { + return ty; + } + if let RustType::Named(name) = &ty { + let mut name = name.as_str(); + let mut visited = std::collections::HashSet::new(); + while visited.insert(name) { + let reference = match spec.schemas().get(name) { + Some(ReferenceOr::Reference { reference }) => reference, + Some(ReferenceOr::Item(schema)) => { + if schema.schema_data.nullable { + return ty; + } + if schema.schema_data.extensions.contains_key(X_RUST_TYPE) { + break; + } + let SchemaKind::AllOf { all_of } = &schema.schema_kind else { + break; + }; + // A named inline allOf member uses struct merging, not an alias. + let [ReferenceOr::Reference { reference }] = all_of.as_slice() else { + break; + }; + reference + } + None => break, + }; + let Some(target) = ref_target_name(reference) else { + break; + }; + name = target; + } + } + return RustType::Nullable(Box::new(ty)); +} + /// Accumulates merged properties of an `allOf`, preserving first-seen order. #[derive(Default)] struct MergedObject { @@ -921,31 +1067,6 @@ fn type_variant_name(ty: &RustType) -> Option<&'static str> { }; } -/// Reject a union that holds one type more than once. -/// -/// The emitted enum is `#[serde(untagged)]`. Serde reads the variants in order -/// and takes the first that fits, so a repeated type makes the later variant -/// unreachable. A value built with that variant comes back as the earlier one, -/// which changes the value and reports nothing. -fn check_variant_types(name: &str, variants: &[UnionVariant]) -> Result<()> { - let mut diagnostics = crate::lower::validate::Diagnostics::new(); - for (index, variant) in variants.iter().enumerate() { - let Some(earlier) = variants.iter().take(index).find(|other| return other.ty == variant.ty) else { - continue; - }; - diagnostics.push(Error::UnsupportedSchema { - path: name.to_owned(), - reason: format!( - "the union holds `{}` twice, as `{}` and as `{}`", - variant.ty.label(), - earlier.name.logical(), - variant.name.logical() - ), - }); - } - return diagnostics.into_result(); -} - /// Map an integer schema to a Rust type. /// /// `format` gives the width. A lowest value of zero or more gives the sign: the @@ -1120,6 +1241,71 @@ mod tests { use super::*; + #[test] + fn nullable_type_follows_only_local_aliases() { + let doc = serde_yaml::from_str( + r##" +openapi: 3.0.3 +info: { title: t, version: '1' } +paths: {} +components: + schemas: + Text: { type: string, nullable: true } + Alias: { $ref: '#/components/schemas/Text' } + Wrapped: + allOf: [{ $ref: '#/components/schemas/Alias' }] + List: + type: array + items: { $ref: '#/components/schemas/Text' } + Map: + type: object + additionalProperties: { $ref: '#/components/schemas/Text' } + Custom: + x-rust-type: String + allOf: [{ $ref: '#/components/schemas/Text' }] + Inline: + allOf: + - type: object + nullable: true + properties: + value: { type: string } + Foreign: { $ref: 'other.yaml#/components/schemas/Text' } + CycleA: { $ref: '#/components/schemas/CycleB' } + CycleB: + allOf: [{ $ref: '#/components/schemas/CycleA' }] +"##, + ) + .expect("parse spec"); + let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml")); + for name in ["Text", "Alias", "Wrapped"] { + let ty = RustType::Named(name.to_owned()); + assert_eq!(nullable_type(&spec, ty.clone()), ty, "{name}"); + } + for name in [ + "List", "Map", "Custom", "Inline", "Foreign", "CycleA", "CycleB", "Missing", + ] { + let ty = RustType::Named(name.to_owned()); + assert_eq!( + nullable_type(&spec, ty.clone()), + RustType::Nullable(Box::new(ty)), + "{name}" + ); + } + let nullable = RustType::Nullable(Box::new(RustType::String)); + assert_eq!(nullable_type(&spec, nullable.clone()), nullable); + for ty in [ + RustType::Vec(Box::new(nullable.clone())), + RustType::Map(Box::new(nullable.clone())), + RustType::Option(Box::new(nullable)), + RustType::External { + module: "foreign".to_owned(), + name: "Text".to_owned(), + }, + ] { + assert_eq!(nullable_type(&spec, ty.clone()), RustType::Nullable(Box::new(ty))); + } + } + /// Parse an inline OpenAPI document and emit the generated Rust source. fn emit_yaml(yaml: &str) -> String { let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec"); diff --git a/crates/oapi-codegen/tests/check_mode.rs b/crates/oapi-codegen/tests/check_mode.rs index 7d907bc..a9629a9 100644 --- a/crates/oapi-codegen/tests/check_mode.rs +++ b/crates/oapi-codegen/tests/check_mode.rs @@ -174,6 +174,171 @@ fn stderr(output: &Output) -> String { return String::from_utf8_lossy(&output.stderr).into_owned(); } +#[test] +fn unknown_configuration_keys_warn_without_changing_the_output() { + let dir = TestDir::new("unknown-config"); + assert_eq!(code(&dir.run(false)), SUCCESS); + let original = read(&dir.output()); + dir.write( + "config.yaml", + "package: demo\nextra: true\ngenerate:\n models: true\n modles: true\noutput-options:\n skip-prun: true\n", + ); + let output = dir.run(true); + assert_eq!(code(&output), SUCCESS, "{}", stderr(&output)); + for pointer in ["/extra", "/generate/modles", "/output-options/skip-prun"] { + assert!(stderr(&output).contains(pointer), "{}", stderr(&output)); + } + assert_eq!(read(&dir.output()), original); +} + +#[test] +fn invalid_configuration_values_keep_their_parse_errors() { + let dir = TestDir::new("invalid-config"); + for configuration in ["null", "generate: null", "generate:\n models: wrong\n modles: true\n"] { + dir.write("config.yaml", configuration); + let output = dir.run(false); + assert_eq!(code(&output), FAILURE, "{}", stderr(&output)); + assert!( + stderr(&output).contains("failed to parse config"), + "{}", + stderr(&output) + ); + assert!(!dir.output().exists()); + } +} + +#[test] +fn unknown_spec_keys_fail_before_any_output_is_replaced() { + let dir = TestDir::new("unknown-spec"); + assert_eq!(code(&dir.run(false)), SUCCESS); + let original = read(&dir.output()); + dir.write( + "spec.yaml", + &SPEC.replace("type: object", "type: object\n requird: [name]\n const: {}"), + ); + for check in [false, true] { + let output = dir.run(check); + assert_eq!(code(&output), FAILURE, "{}", stderr(&output)); + assert!( + stderr(&output).contains("/components/schemas/Widget/requird"), + "{}", + stderr(&output) + ); + assert!( + stderr(&output).contains("/components/schemas/Widget/const"), + "{}", + stderr(&output) + ); + assert_eq!(read(&dir.output()), original); + } +} + +#[test] +fn ignored_features_warn_during_generation_and_check_mode() { + let dir = TestDir::new("unsupported-note"); + dir.write( + "spec.yaml", + &SPEC.replace("type: object", "type: object\n xml: {name: widget}"), + ); + for check in [false, true] { + let output = dir.run(check); + assert_eq!(code(&output), SUCCESS, "{}", stderr(&output)); + assert!( + stderr(&output).contains("/components/schemas/Widget/xml"), + "{}", + stderr(&output) + ); + assert!( + stderr(&output).contains("XML serialization is not implemented"), + "{}", + stderr(&output) + ); + } +} + +#[test] +fn referenced_documents_use_the_same_key_diagnostics() { + let dir = TestDir::new("referenced-key"); + dir.write("config.yaml", PACKAGE_CONFIG); + dir.write( + "spec.yaml", + "openapi: 3.0.3\ninfo: {title: Root, version: 1.0.0}\npaths:\n /widgets:\n get:\n responses:\n '200': {$ref: 'other.yaml#/components/responses/Widget'}\n", + ); + dir.write( + "other.yaml", + "openapi: 3.0.3\ninfo: {title: Other, version: 1.0.0}\npaths: {}\ncomponents:\n responses:\n Widget: {description: ok, contnet: {}}\n", + ); + let output = dir.run(false); + assert_eq!(code(&output), FAILURE, "{}", stderr(&output)); + assert!( + stderr(&output).contains("other.yaml#/components/responses/Widget/contnet"), + "{}", + stderr(&output) + ); + assert!(!dir.output().exists()); +} + +#[test] +fn referenced_document_warnings_are_emitted_once_per_loaded_document() { + let dir = TestDir::new("referenced-note"); + dir.write("config.yaml", PACKAGE_CONFIG); + dir.write( + "spec.yaml", + "openapi: 3.0.3\ninfo: {title: Root, version: 1.0.0}\npaths:\n /widgets:\n get:\n responses:\n '200': {$ref: 'other.yaml#/components/responses/Widget'}\n '201': {$ref: 'other.yaml#/components/responses/Widget'}\n", + ); + dir.write( + "other.yaml", + "openapi: 3.0.3\ninfo: {title: Other, version: 1.0.0}\npaths: {}\nx-vendor: true\ncomponents:\n responses:\n Widget: {description: ok}\n", + ); + let output = dir.run(false); + assert_eq!(code(&output), SUCCESS, "{}", stderr(&output)); + assert_eq!(stderr(&output).matches("/x-vendor").count(), 1, "{}", stderr(&output)); +} + +#[test] +fn json_documents_and_all_library_entry_points_reject_unknown_keys() { + let dir = TestDir::new("json-unknown"); + dir.write( + "spec.yaml", + r#"{"openapi":"3.0.3","info":{"title":"Demo","version":"1.0.0"},"paths":{},"components":{"schemas":{"Widget":{"type":"string","const":"x"}}}}"#, + ); + let spec = dir.join("spec.yaml"); + let config = oapi_codegen::Config::load(&dir.join("config.yaml")).expect("valid configuration"); + assert!(oapi_codegen::generate_models_string(&spec).is_err()); + assert!(oapi_codegen::generate(&spec, &config).is_err()); + assert!(oapi_codegen::generate_package(&spec, &config, &dir.output()).is_err()); + let output = dir.run(false); + assert_eq!(code(&output), FAILURE, "{}", stderr(&output)); + assert!( + stderr(&output).contains("/components/schemas/Widget/const"), + "{}", + stderr(&output) + ); + assert!(!dir.output().exists()); +} + +#[test] +fn unconstrained_schema_fallback_warns_but_an_empty_schema_does_not() { + let dir = TestDir::new("schema-fallback"); + let preamble = "openapi: 3.0.3\ninfo: {title: Demo, version: 1.0.0}\npaths: {}\ncomponents:\n schemas:\n"; + dir.write("spec.yaml", &format!("{preamble} Widget: {{}}\n")); + let empty = dir.run(false); + assert_eq!(code(&empty), SUCCESS, "{}", stderr(&empty)); + assert!(!stderr(&empty).contains("warning:"), "{}", stderr(&empty)); + dir.write( + "spec.yaml", + &format!("{preamble} Widget: {{type: string, minimum: 1}}\n"), + ); + let constrained = dir.run(false); + assert_eq!(code(&constrained), SUCCESS, "{}", stderr(&constrained)); + assert!( + stderr(&constrained).contains("unconstrained JSON value"), + "{}", + stderr(&constrained) + ); + assert!(read(&dir.output()).contains("pub type Widget = serde_json::Value;")); +} + /// Read `path`, which every case here has already generated. fn read(path: &Path) -> String { return std::fs::read_to_string(path).unwrap_or_else(|err| panic!("reading `{}` failed: {err}", path.display())); diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index 7017487..dac76c4 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -45,6 +45,21 @@ struct Feature { /// The complete catalogue of OpenAPI 3.0 schema features and their handling. const TEST_TABLE: &[Feature] = &[ + Feature { + element: "schema.anyOf.accessor-collision", + status: Status::Unsupported, + fixture: Some("unsupported_union_accessor_collision"), + }, + Feature { + element: "schema.union.rust-deserialization", + status: Status::Supported, + fixture: Some("union_semantics"), + }, + Feature { + element: "schema.union.prelude-names", + status: Status::Supported, + fixture: Some("union_prelude_names"), + }, // Schema kinds Feature { element: "schema.type.string", @@ -143,8 +158,8 @@ const TEST_TABLE: &[Feature] = &[ }, Feature { element: "schema.oneOf.duplicate-variant-type", - status: Status::Unsupported, - fixture: Some("unsupported_duplicate_union_variant"), + status: Status::Supported, + fixture: Some("union_duplicate"), }, Feature { element: "schema.oneOf.unnamed-inline-member", @@ -311,6 +326,11 @@ const TEST_TABLE: &[Feature] = &[ status: Status::Supported, fixture: Some("nullable"), }, + Feature { + element: "meta.nullable.presence-and-direction", + status: Status::Supported, + fixture: Some("nullable"), + }, Feature { element: "meta.title", status: Status::Ignored, @@ -517,6 +537,7 @@ const TEST_TABLE: &[Feature] = &[ /// [`TEST_TABLE`]): path parameters, JSON request bodies, and typed responses. /// Each must have a `#[test]` via [`server_generated_tests!`]. const SERVER_FIXTURES: &[&str] = &[ + "server_union_traits", "server_petstore", "server_refs", "server_query_params", @@ -586,6 +607,7 @@ const SERVER_UNSUPPORTED_FIXTURES: &[&str] = &[ /// header/cookie inputs, single-content request bodies, typed responses, and /// security schemes (bearer, basic, and API-key credentials). const CLIENT_FIXTURES: &[&str] = &[ + "client_union_traits", "client_widgets", "client_auth", "client_multipart_request", @@ -606,6 +628,7 @@ const CLIENT_UNSUPPORTED_FIXTURES: &[&str] = &[ /// flat crate-root layout in which the server and client share one file and the /// same per-operation types alongside the component models. const COMBINED_FIXTURES: &[&str] = &[ + "combined_inline_nullable", "combined_keyword_operations", "combined_read_write_only", "combined_prelude_value_names", @@ -641,6 +664,8 @@ const COMBINED_UNSUPPORTED_FIXTURES: &[&str] = &[ /// take one name. Both need a `response-type-suffix` to reach the clash, so both /// succeed with the default config and fail with that option set. const NAMING_COLLISION_FIXTURES: &[&str] = &[ + "nullable_name_collision", + "nullable_rename_collision", "type_name_collision_error", "type_name_collision_suffix", "type_name_collision_pruned", @@ -746,6 +771,9 @@ generated_tests!( oneof_discriminator, oneof_untagged, oneof_variant_naming, + union_duplicate, + union_semantics, + union_prelude_names, compose_shared, primitive_scalars, recursive_schema, @@ -849,6 +877,7 @@ macro_rules! server_generated_tests { } server_generated_tests!( + server_union_traits, server_petstore, server_refs, server_query_params, @@ -1221,6 +1250,7 @@ macro_rules! client_generated_tests { } client_generated_tests!( + client_union_traits, client_widgets, client_auth, client_multipart_request, @@ -1330,6 +1360,7 @@ macro_rules! combined_generated_tests { } combined_generated_tests!( + combined_inline_nullable, combined_keyword_operations, combined_read_write_only, combined_prelude_value_names, @@ -1402,6 +1433,8 @@ macro_rules! package_generated_tests { } package_generated_tests!( + combined_inline_nullable, + nullable, combined_keyword_operations, combined_prelude_value_names, combined_server_client, @@ -2238,6 +2271,33 @@ fn dependency_report_reflects_generated_output() { for name in &names { assert!(KNOWN.contains(name), "report named an unexpected crate `{name}`"); } + + for stem in ["oneof_untagged", "anyof_untagged", "union_semantics"] { + let fixture = tests_dir().join("fixtures").join(format!("{stem}.yaml")); + let code = oapi_codegen::generate_models_string(&fixture).expect("union generation"); + let dependencies = oapi_codegen::deps::required_dependencies(&code); + assert!( + dependencies + .iter() + .any(|dependency| return dependency.name == "serde_json"), + "{stem}" + ); + assert!( + dependencies.iter().all(|dependency| return dependency.name != "regex"), + "{stem}" + ); + } +} + +#[test] +fn anyof_accessor_collisions_name_the_conflicting_method() { + let fixture = tests_dir() + .join("fixtures") + .join("unsupported_union_accessor_collision.yaml"); + let error = oapi_codegen::generate_models_string(&fixture).expect_err("accessor collision"); + let message = error.to_string(); + assert!(message.contains("as_value"), "{message}"); + assert!(message.contains("x-rust-name"), "{message}"); } /// with the same name at the crate root. @@ -2279,6 +2339,34 @@ fn reserved_interface_name_is_target_scoped() { .expect("a schema named `Api` must not collide when only the client is generated"); } +#[test] +fn nullable_helper_name_is_reserved_only_when_emitted() { + for fixture in ["nullable_name_collision", "nullable_rename_collision"] { + let path = tests_dir().join("fixtures").join(format!("{fixture}.yaml")); + let mut config = oapi_codegen::Config::default(); + config.generate.models = true; + assert!(matches!( + oapi_codegen::generate(&path, &config), + Err(oapi_codegen::Error::TypeNameCollision { name, .. }) if name == "Nullable" + )); + config.output_options.exclude_schemas = vec!["Text".to_owned()]; + let source = oapi_codegen::generate(&path, &config).expect("unused helper name is available"); + assert!(source.contains("pub type Nullable = String;")); + assert!(!source.contains("pub enum Nullable")); + } + let path = tests_dir().join("fixtures/combined_inline_nullable.yaml"); + let mut config = combined_config(); + config.output_options.skip_prune = true; + assert!(matches!( + oapi_codegen::generate(&path, &config), + Err(oapi_codegen::Error::TypeNameCollision { name, .. }) if name == "Nullable" + )); + assert!(matches!( + oapi_codegen::generate_package(&path, &config, std::path::Path::new("unused.rs")), + Err(oapi_codegen::Error::TypeNameCollision { name, .. }) if name == "Nullable" + )); +} + /// Whether `generated` declares `name` as a `trait`, `struct`, or `enum` item. /// The match requires an item keyword before the name and a non-identifier /// character after it, so a longer identifier that merely shares the prefix @@ -2299,9 +2387,12 @@ fn declares_type(generated: &str, name: &str) -> bool { #[test] fn reserved_names_are_declared_in_combined_output() { let dir = tests_dir(); - let fixture = dir.join("fixtures").join("combined_server_client.yaml"); + let fixture = dir.join("fixtures").join("nullable.yaml"); let generated = oapi_codegen::generate(&fixture, &combined_config()).expect("generating combined server+client output failed"); + assert!(generated.contains("DirectionalValueRequest")); + assert!(generated.contains("DirectionalValueResponse")); + assert!(!generated.contains("struct Account")); let targets = oapi_codegen::emit::Targets { server: true, client: true, diff --git a/crates/oapi-codegen/tests/diagnostics_operations.rs b/crates/oapi-codegen/tests/diagnostics_operations.rs new file mode 100644 index 0000000..b75aba5 --- /dev/null +++ b/crates/oapi-codegen/tests/diagnostics_operations.rs @@ -0,0 +1,314 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; + +const CONFIG: &str = "package: demo\ngenerate:\n models: true\n std-http-server: true\n"; + +const SPEC: &str = "\ +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 +paths: + /items: + post: + operationId: createItem + requestBody: + required: true + content: + application/json: + schema: + type: string + responses: + '200': + description: The item + content: + application/json: + schema: + type: string +"; + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(name: &str) -> Self { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_else(|err| panic!("the system clock must be after the Unix epoch: {err}")); + let path = std::env::temp_dir().join(format!( + "oapi-codegen-diagnostics-operations-{name}-{}-{}", + std::process::id(), + elapsed.as_nanos() + )); + std::fs::create_dir_all(path.join("generated")) + .unwrap_or_else(|err| panic!("cannot create the test directory: {err}")); + let dir = Self { path }; + dir.write("config.yaml", CONFIG); + return dir; + } + + fn write(&self, name: &str, contents: &str) { + std::fs::write(self.path.join(name), contents) + .unwrap_or_else(|err| panic!("cannot write the test input: {err}")); + } + + fn run(&self) -> Output { + return Command::new(env!("CARGO_BIN_EXE_oapi-codegen")) + .arg("--config-file") + .arg(self.path.join("config.yaml")) + .arg("--output-file") + .arg(self.path.join("generated/output.rs")) + .arg(self.path.join("spec.yaml")) + .output() + .unwrap_or_else(|err| panic!("cannot run the generator: {err}")); + } + + fn generated(&self) -> BTreeMap { + return read_tree(&self.path.join("generated")); + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn read_tree(path: &Path) -> BTreeMap { + let mut files = BTreeMap::new(); + for entry in std::fs::read_dir(path).unwrap_or_else(|err| panic!("cannot read the generated directory: {err}")) { + let path = entry + .unwrap_or_else(|err| panic!("cannot read the generated entry: {err}")) + .path(); + if path.is_dir() { + files.extend(read_tree(&path)); + } else { + let contents = + std::fs::read_to_string(&path).unwrap_or_else(|err| panic!("cannot read the generated file: {err}")); + files.insert(path, contents); + } + } + return files; +} + +fn successful_stderr(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!(output.status.success(), "{stderr}"); + return stderr; +} + +#[test] +fn mixed_unsupported_media_warns_without_changing_generated_bodies() { + let dir = TestDir::new("mixed-media"); + dir.write("spec.yaml", SPEC); + successful_stderr(&dir.run()); + let original = dir.generated(); + let spec = SPEC + .replace( + "\n application/json:", + "\n application/xml:\n schema:\n type: integer\n application/json:", + ) + .replace( + "\n application/json:", + "\n application/octet-stream:\n schema:\n type: integer\n application/json:", + ); + dir.write("spec.yaml", &spec); + let stderr = successful_stderr(&dir.run()); + assert!( + stderr.contains("unsupported media type `application/xml` is ignored"), + "{stderr}" + ); + assert!( + stderr.contains("unsupported media type `application/octet-stream` is ignored"), + "{stderr}" + ); + assert!(stderr.contains("post /items request body"), "{stderr}"); + assert!(stderr.contains("post /items `200` response"), "{stderr}"); + assert_eq!(dir.generated(), original); +} + +#[test] +fn duplicate_body_kinds_warn_and_keep_the_first_representation() { + let dir = TestDir::new("duplicate-media"); + let first = SPEC.replace("application/json:", "application/vnd.first+json:"); + dir.write("spec.yaml", &first); + successful_stderr(&dir.run()); + let original = dir.generated(); + let spec = first + .replace( + " type: string\n responses:", + " type: string\n application/json:\n schema:\n type: integer\n responses:", + ) + + " application/json:\n schema:\n type: integer\n"; + dir.write("spec.yaml", &spec); + let stderr = successful_stderr(&dir.run()); + let message = "media type `application/json` is ignored because `application/vnd.first+json` is the first representation of the same body kind"; + assert_eq!(stderr.matches(message).count(), 2, "{stderr}"); + assert_eq!(dir.generated(), original); +} + +#[test] +fn resolved_response_media_warns_with_the_origin_file() { + let dir = TestDir::new("referenced-media"); + dir.write( + "spec.yaml", + "openapi: 3.0.3\ninfo:\n title: Demo\n version: 1.0.0\npaths:\n /items:\n get:\n responses:\n '200':\n $ref: 'responses.yaml#/components/responses/Item'\n", + ); + let response = "openapi: 3.0.3\ninfo:\n title: Responses\n version: 1.0.0\npaths: {}\ncomponents:\n responses:\n Item:\n description: The item\n content:\n application/json:\n schema:\n type: string\n"; + dir.write("responses.yaml", response); + successful_stderr(&dir.run()); + let original = dir.generated(); + dir.write( + "responses.yaml", + &format!("{response} application/xml:\n schema:\n type: integer\n application/vnd.second+json:\n schema:\n type: integer\n"), + ); + let stderr = successful_stderr(&dir.run()); + assert!(stderr.contains("get /items `200` response"), "{stderr}"); + assert!(stderr.contains("resolved from `responses.yaml`"), "{stderr}"); + assert!( + stderr.contains("unsupported media type `application/xml` is ignored"), + "{stderr}" + ); + assert!( + stderr.contains("media type `application/vnd.second+json` is ignored"), + "{stderr}" + ); + assert_eq!(dir.generated(), original); +} + +#[test] +fn scalar_styles_warn_but_scalar_explode_settings_do_not() { + let dir = TestDir::new("scalar-styles"); + let spec = "openapi: 3.0.3\ninfo:\n title: Demo\n version: 1.0.0\npaths:\n /items/{id}:\n get:\n parameters:\n - in: path\n name: id\n required: true\n style: simple\n schema:\n type: string\n - in: query\n name: search\n style: form\n schema:\n type: string\n - in: header\n name: X-Item\n style: simple\n explode: true\n schema:\n type: string\n - in: cookie\n name: item\n style: form\n explode: false\n schema:\n type: string\n responses:\n '204':\n description: No content\n"; + dir.write("spec.yaml", spec); + let stderr = successful_stderr(&dir.run()); + assert!(!stderr.contains("unsupported scalar style"), "{stderr}"); + assert!(!stderr.contains("unsupported style"), "{stderr}"); + assert!(!stderr.contains("explode"), "{stderr}"); + let original = dir.generated(); + for style in ["label", "matrix"] { + let changed = spec.replacen("style: simple", &format!("style: {style}"), 1).replacen( + "style: form", + "style: spaceDelimited", + 1, + ); + dir.write("spec.yaml", &changed); + let stderr = successful_stderr(&dir.run()); + assert!( + stderr.contains("path parameter `id` uses unsupported style"), + "{stderr}" + ); + assert!( + stderr.contains("query parameter `search` uses unsupported scalar style"), + "{stderr}" + ); + assert_eq!(dir.generated(), original); + } +} + +#[test] +fn unsupported_only_content_still_fails_without_replacing_output() { + let dir = TestDir::new("unsupported-only"); + dir.write("spec.yaml", SPEC); + successful_stderr(&dir.run()); + let original = dir.generated(); + for indentation in [" ", " "] { + let changed = SPEC.replace( + &format!("\n{indentation}application/json:"), + &format!("\n{indentation}application/xml:"), + ); + dir.write("spec.yaml", &changed); + let output = dir.run(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{stderr}"); + assert!(stderr.contains("application/xml"), "{stderr}"); + assert!( + !stderr.contains("unsupported media type `application/xml` is ignored"), + "{stderr}" + ); + assert_eq!(dir.generated(), original); + } +} + +#[test] +fn resolved_parameter_style_warns_with_the_origin_file() { + let dir = TestDir::new("referenced-parameter"); + dir.write( + "spec.yaml", + "openapi: 3.0.3\ninfo:\n title: Demo\n version: 1.0.0\npaths:\n /items/{id}:\n get:\n parameters:\n - $ref: 'parameters.yaml#/components/parameters/Id'\n responses:\n '204':\n description: No content\n", + ); + let parameter = "openapi: 3.0.3\ninfo:\n title: Parameters\n version: 1.0.0\npaths: {}\ncomponents:\n parameters:\n Id:\n in: path\n name: id\n required: true\n style: simple\n schema:\n type: string\n"; + dir.write("parameters.yaml", parameter); + successful_stderr(&dir.run()); + let original = dir.generated(); + dir.write("parameters.yaml", ¶meter.replace("style: simple", "style: matrix")); + let stderr = successful_stderr(&dir.run()); + assert!(stderr.contains("get /items/{id}"), "{stderr}"); + assert!( + stderr.contains("path parameter `id` uses unsupported style"), + "{stderr}" + ); + assert!(stderr.contains("resolved from `parameters.yaml`"), "{stderr}"); + assert_eq!(dir.generated(), original); +} + +#[test] +fn scalar_parameter_enums_warn_without_changing_generated_types() { + for location in ["path", "query", "header", "cookie"] { + for (kind, values) in [("string", "[one, two]"), ("integer", "[1, 2]")] { + let dir = TestDir::new(&format!("enum-{location}-{kind}")); + let path = if location == "path" { "/items/{value}" } else { "/items" }; + let spec = format!( + "openapi: 3.0.3\ninfo:\n title: Demo\n version: 1.0.0\npaths:\n {path}:\n get:\n parameters:\n - in: {location}\n name: value\n required: true\n schema:\n type: {kind}\n responses:\n '204':\n description: No content\n" + ); + dir.write("spec.yaml", &spec); + let stderr = successful_stderr(&dir.run()); + assert!(!stderr.contains("the declared `enum` is ignored"), "{stderr}"); + let original = dir.generated(); + dir.write( + "spec.yaml", + &spec.replace( + &format!("type: {kind}"), + &format!("type: {kind}\n enum: {values}"), + ), + ); + let stderr = successful_stderr(&dir.run()); + assert!(stderr.contains(&format!("{location} parameter `value`")), "{stderr}"); + assert_eq!(stderr.matches("the declared `enum` is ignored").count(), 1, "{stderr}"); + assert_eq!(dir.generated(), original); + } + } +} + +#[test] +fn resolved_parameter_schema_enums_warn_for_scalars_and_array_items() { + for array in [false, true] { + let dir = TestDir::new(if array { "enum-ref-array" } else { "enum-ref-scalar" }); + dir.write( + "spec.yaml", + "openapi: 3.0.3\ninfo:\n title: Demo\n version: 1.0.0\npaths:\n /items:\n get:\n parameters:\n - $ref: 'parameters.yaml#/components/parameters/Value'\n responses:\n '204':\n description: No content\n", + ); + let schema = if array { + " type: array\n items:\n $ref: '#/components/schemas/Value'" + } else { + " $ref: '#/components/schemas/Value'" + }; + let parameter = format!( + "openapi: 3.0.3\ninfo:\n title: Parameters\n version: 1.0.0\npaths: {{}}\ncomponents:\n parameters:\n Value:\n in: query\n name: value\n schema:\n{schema}\n schemas:\n Value:\n type: string\n" + ); + dir.write("parameters.yaml", ¶meter); + let stderr = successful_stderr(&dir.run()); + assert!(!stderr.contains("the declared `enum` is ignored"), "{stderr}"); + let original = dir.generated(); + dir.write("parameters.yaml", &format!("{parameter} enum: [one, two]\n")); + let stderr = successful_stderr(&dir.run()); + assert!(stderr.contains("get /items query parameter `value`"), "{stderr}"); + assert!(stderr.contains("resolved from `parameters.yaml`"), "{stderr}"); + assert_eq!(stderr.matches("the declared `enum` is ignored").count(), 1, "{stderr}"); + assert_eq!(dir.generated(), original); + } +} diff --git a/crates/oapi-codegen/tests/fixtures/client_union_traits.yaml b/crates/oapi-codegen/tests/fixtures/client_union_traits.yaml new file mode 100644 index 0000000..9bdfabe --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/client_union_traits.yaml @@ -0,0 +1,53 @@ +openapi: "3.0.3" +info: + title: Client directional foreign union traits + version: "1.0.0" +paths: + /exchange: + post: + operationId: exchange + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Input" } + responses: + "200": + description: The result. + content: + application/json: + schema: { $ref: "#/components/schemas/Output" } +components: + schemas: + Incoming: + type: string + x-rust-type: crate::restricted::SerializeOnly + x-rust-derive: [] + Outgoing: + type: string + x-rust-type: crate::restricted::DeserializeOnly + x-rust-derive: [] + Input: + type: object + required: [exclusive, inclusive] + properties: + exclusive: + oneOf: + - $ref: "#/components/schemas/Incoming" + - type: boolean + inclusive: + anyOf: + - $ref: "#/components/schemas/Incoming" + - type: boolean + Output: + type: object + required: [exclusive, inclusive] + properties: + exclusive: + oneOf: + - $ref: "#/components/schemas/Outgoing" + - type: boolean + inclusive: + anyOf: + - $ref: "#/components/schemas/Outgoing" + - type: boolean diff --git a/crates/oapi-codegen/tests/fixtures/combined_inline_nullable.yaml b/crates/oapi-codegen/tests/fixtures/combined_inline_nullable.yaml new file mode 100644 index 0000000..9aeed1f --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/combined_inline_nullable.yaml @@ -0,0 +1,107 @@ +openapi: "3.0.3" +info: + title: Inline nullable bodies + version: "1.0.0" +components: + schemas: + Nullable: { type: string } + NullableText: { type: string, nullable: true } + TextAlias: + $ref: "#/components/schemas/NullableText" + WrappedText: + allOf: + - $ref: "#/components/schemas/TextAlias" +paths: + /array: + post: + operationId: array + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - type: array + nullable: true + items: + nullable: true + allOf: + - { type: string, nullable: true } + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - type: array + nullable: true + items: { type: string, nullable: true } + /scalar: + post: + operationId: scalar + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - nullable: true + allOf: + - { type: integer, nullable: true } + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - { type: integer, nullable: true } + /map: + post: + operationId: map + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - type: object + nullable: true + additionalProperties: { type: boolean, nullable: true } + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - type: object + nullable: true + additionalProperties: { type: boolean, nullable: true } + /alias: + post: + operationId: alias + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/WrappedText" + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/NullableText" diff --git a/crates/oapi-codegen/tests/fixtures/combined_read_write_only.yaml b/crates/oapi-codegen/tests/fixtures/combined_read_write_only.yaml index 1b09f14..b5cdbb0 100644 --- a/crates/oapi-codegen/tests/fixtures/combined_read_write_only.yaml +++ b/crates/oapi-codegen/tests/fixtures/combined_read_write_only.yaml @@ -3,6 +3,20 @@ info: title: Combined Accounts API version: 1.0.0 paths: + /union: + post: + operationId: exchangeUnion + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UnionEnvelope" } + responses: + "200": + description: The union response. + content: + application/json: + schema: { $ref: "#/components/schemas/UnionEnvelope" } /accounts: post: operationId: createAccount @@ -77,6 +91,20 @@ paths: description: The avatar was stored. components: schemas: + UnionEnvelope: + type: object + required: [exclusive, inclusive] + properties: + exclusive: { $ref: "#/components/schemas/ExclusiveAccount" } + inclusive: { $ref: "#/components/schemas/InclusiveAccount" } + ExclusiveAccount: + oneOf: + - $ref: "#/components/schemas/Account" + - type: boolean + InclusiveAccount: + anyOf: + - $ref: "#/components/schemas/Account" + - type: boolean Account: description: An account, used as a request body and as a response body. type: object diff --git a/crates/oapi-codegen/tests/fixtures/combined_x_rust_derive.yaml b/crates/oapi-codegen/tests/fixtures/combined_x_rust_derive.yaml index e5a5867..d9c6ce1 100644 --- a/crates/oapi-codegen/tests/fixtures/combined_x_rust_derive.yaml +++ b/crates/oapi-codegen/tests/fixtures/combined_x_rust_derive.yaml @@ -83,6 +83,14 @@ components: type: string opaque: $ref: "#/components/schemas/Opaque" + exclusive: + oneOf: + - $ref: "#/components/schemas/Opaque" + - type: boolean + inclusive: + anyOf: + - $ref: "#/components/schemas/Opaque" + - type: boolean UploadRequest: type: object required: [opaque, note] diff --git a/crates/oapi-codegen/tests/fixtures/nullable.yaml b/crates/oapi-codegen/tests/fixtures/nullable.yaml index e51d288..3df610f 100644 --- a/crates/oapi-codegen/tests/fixtures/nullable.yaml +++ b/crates/oapi-codegen/tests/fixtures/nullable.yaml @@ -2,9 +2,85 @@ openapi: "3.0.3" info: title: Nullable properties version: "1.0.0" -paths: {} +paths: + /parity: + post: + operationId: parity + requestBody: + required: true + content: + application/json: + schema: + type: array + nullable: true + items: { type: string, nullable: true } + responses: + "200": + description: Result + content: + application/json: + schema: + $ref: "#/components/schemas/NullableArray" + /wrapped: + post: + operationId: wrapped + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/Plain" + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/Plain" + /direction: + post: + operationId: exchange + requestBody: + required: true + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/DirectionalAlias" + responses: + "200": + description: Result + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/DirectionalAlias" components: schemas: + DirectionalAlias: + nullable: true + allOf: + - $ref: "#/components/schemas/Directional" + NullableArray: + type: array + nullable: true + items: { type: string, nullable: true } + Directional: + type: object + nullable: true + required: [id, secret, value] + properties: + id: { type: string, readOnly: true } + secret: { type: string, writeOnly: true } + value: { type: string, nullable: true } + next: + $ref: "#/components/schemas/Directional" Account: type: object required: [id, deactivated_at] @@ -15,3 +91,202 @@ components: type: string format: date-time nullable: true + nickname: + type: string + plain: + $ref: "#/components/schemas/Plain" + plain_state: + type: string + enum: [active, inactive] + patch: + type: string + nullable: true + minLength: 2 + bounded: + type: integer + nullable: true + minimum: 1 + only_null: + type: integer + nullable: true + minimum: 1 + maximum: -1 + out_of_range: + type: integer + format: int32 + nullable: true + maximum: -2147483649 + labels: + type: array + uniqueItems: true + items: + type: string + nullable: true + values: + type: object + additionalProperties: + $ref: "#/components/schemas/NullableText" + nullable_list: + type: array + nullable: true + minItems: 1 + items: { type: string } + nullable_map: + type: object + nullable: true + minProperties: 1 + additionalProperties: { type: string } + empty_list: + type: array + nullable: true + default: [] + items: { type: string } + ignored_default: + type: array + nullable: true + default: [value] + items: { type: string } + child: + $ref: "#/components/schemas/NullableNode" + state: + $ref: "#/components/schemas/NullableState" + text: + $ref: "#/components/schemas/TextAlias" + direct_text: + $ref: "#/components/schemas/NullableText" + wrapped_text: + $ref: "#/components/schemas/WrappedText" + inline_wrapped_text: + nullable: true + allOf: + - nullable: true + allOf: + - $ref: "#/components/schemas/TextAlias" + nested_text: + nullable: true + allOf: + - nullable: true + allOf: + - { type: string, nullable: true, minLength: 2 } + nested_plain_text: + nullable: true + allOf: + - nullable: true + allOf: + - $ref: "#/components/schemas/PlainText" + reference: + nullable: true + allOf: + - $ref: "#/components/schemas/Plain" + inline: + type: object + nullable: true + required: [name] + properties: + name: { type: string } + custom: + type: string + nullable: true + x-rust-type: String + minLength: 2 + skipped: + type: string + nullable: true + x-rust-serde-skip: true + local: + type: string + x-rust-type: std::time::Instant + x-rust-serde-skip: true + skipped_default: + type: string + default: ignored + x-rust-serde-skip: true + custom_allof: + nullable: true + x-rust-type: i64 + allOf: + - $ref: "#/components/schemas/NullableText" + custom_chain: + $ref: "#/components/schemas/CustomChain" + fallback: + type: string + nullable: true + default: fallback + enum_default: + type: string + nullable: true + enum: [null, active] + default: active + null_default: + type: string + nullable: true + default: null + Plain: + type: object + required: [name] + properties: + name: { type: string } + PlainText: + type: string + minLength: 2 + CustomChain: + nullable: true + allOf: + - $ref: "#/components/schemas/CustomAllOf" + CustomAllOf: + nullable: true + x-rust-type: i64 + allOf: + - $ref: "#/components/schemas/NullableText" + NullableText: + type: string + nullable: true + minLength: 2 + TextAlias: + $ref: "#/components/schemas/NullableText" + WrappedText: + nullable: true + allOf: + - $ref: "#/components/schemas/TextAlias" + NullableCode: + type: integer + nullable: true + enum: [null, 1, 2] + NullableState: + type: string + nullable: true + enum: [null, active, inactive] + x-enum-varnames: ["Null", Enabled, Disabled] + NullableNode: + type: object + nullable: true + x-rust-name: Node + required: [name] + properties: + name: { type: string } + child: + $ref: "#/components/schemas/NullableNode" + RequiredValues: + type: object + required: [plain, text, state, node, bounded, defaulted] + properties: + plain: { type: string } + text: + $ref: "#/components/schemas/WrappedText" + state: + $ref: "#/components/schemas/NullableState" + node: + $ref: "#/components/schemas/NullableNode" + bounded: + type: integer + nullable: true + minimum: 1 + defaulted: + type: string + nullable: true + default: value + NullableUnion: + nullable: true + oneOf: + - type: string + - type: integer diff --git a/crates/oapi-codegen/tests/fixtures/nullable_name_collision.yaml b/crates/oapi-codegen/tests/fixtures/nullable_name_collision.yaml new file mode 100644 index 0000000..e147474 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/nullable_name_collision.yaml @@ -0,0 +1,7 @@ +openapi: "3.0.3" +info: { title: Nullable name collision, version: "1.0.0" } +paths: {} +components: + schemas: + Nullable: { type: string } + Text: { type: string, nullable: true } diff --git a/crates/oapi-codegen/tests/fixtures/nullable_rename_collision.yaml b/crates/oapi-codegen/tests/fixtures/nullable_rename_collision.yaml new file mode 100644 index 0000000..cfa2fb2 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/nullable_rename_collision.yaml @@ -0,0 +1,7 @@ +openapi: "3.0.3" +info: { title: Nullable rename collision, version: "1.0.0" } +paths: {} +components: + schemas: + Custom: { type: string, x-rust-name: Nullable } + Text: { type: string, nullable: true } diff --git a/crates/oapi-codegen/tests/fixtures/recursive_schema.yaml b/crates/oapi-codegen/tests/fixtures/recursive_schema.yaml index 7d9eb34..da9283b 100644 --- a/crates/oapi-codegen/tests/fixtures/recursive_schema.yaml +++ b/crates/oapi-codegen/tests/fixtures/recursive_schema.yaml @@ -56,12 +56,16 @@ components: properties: parent: $ref: "#/components/schemas/Parent" - # A union variant that holds the union. The variant is a field in all but - # name, so it takes a box too. + # The object property consumes input before the union recurs. Expression: oneOf: - type: string - - $ref: "#/components/schemas/Expression" + - x-rust-name: Nested + type: object + required: [nested] + properties: + nested: + $ref: "#/components/schemas/Expression" # A cycle that runs through an alias. The alias offers nothing to box, so the # struct field on the cycle takes the box instead. Wrapper: diff --git a/crates/oapi-codegen/tests/fixtures/server_union_traits.yaml b/crates/oapi-codegen/tests/fixtures/server_union_traits.yaml new file mode 100644 index 0000000..7a5084b --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/server_union_traits.yaml @@ -0,0 +1,53 @@ +openapi: "3.0.3" +info: + title: Directional foreign union traits + version: "1.0.0" +paths: + /exchange: + post: + operationId: exchange + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Input" } + responses: + "200": + description: The result. + content: + application/json: + schema: { $ref: "#/components/schemas/Output" } +components: + schemas: + Incoming: + type: string + x-rust-type: crate::restricted::DeserializeOnly + x-rust-derive: [] + Outgoing: + type: string + x-rust-type: crate::restricted::SerializeOnly + x-rust-derive: [] + Input: + type: object + required: [exclusive, inclusive] + properties: + exclusive: + oneOf: + - $ref: "#/components/schemas/Incoming" + - type: boolean + inclusive: + anyOf: + - $ref: "#/components/schemas/Incoming" + - type: boolean + Output: + type: object + required: [exclusive, inclusive] + properties: + exclusive: + oneOf: + - $ref: "#/components/schemas/Outgoing" + - type: boolean + inclusive: + anyOf: + - $ref: "#/components/schemas/Outgoing" + - type: boolean diff --git a/crates/oapi-codegen/tests/fixtures/unsupported_duplicate_union_variant.yaml b/crates/oapi-codegen/tests/fixtures/union_duplicate.yaml similarity index 59% rename from crates/oapi-codegen/tests/fixtures/unsupported_duplicate_union_variant.yaml rename to crates/oapi-codegen/tests/fixtures/union_duplicate.yaml index 2fdabe8..f9b3721 100644 --- a/crates/oapi-codegen/tests/fixtures/unsupported_duplicate_union_variant.yaml +++ b/crates/oapi-codegen/tests/fixtures/union_duplicate.yaml @@ -11,9 +11,6 @@ components: properties: meow: type: string - # Both members lower to `Cat`. The emitted enum is untagged, so serde reads - # the variants in order and takes the first that fits. The second variant - # never matches, and a value built with it comes back as the first. Pet: oneOf: - $ref: "#/components/schemas/Cat" diff --git a/crates/oapi-codegen/tests/fixtures/union_prelude_names.yaml b/crates/oapi-codegen/tests/fixtures/union_prelude_names.yaml new file mode 100644 index 0000000..708475b --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/union_prelude_names.yaml @@ -0,0 +1,19 @@ +openapi: "3.0.3" +info: + title: Union prelude name collisions + version: "1.0.0" +paths: {} +components: + schemas: + TryFrom: + type: string + Result: + type: string + PreludeOne: + oneOf: + - $ref: "#/components/schemas/TryFrom" + - type: boolean + PreludeAny: + anyOf: + - $ref: "#/components/schemas/Result" + - type: boolean diff --git a/crates/oapi-codegen/tests/fixtures/union_semantics.yaml b/crates/oapi-codegen/tests/fixtures/union_semantics.yaml new file mode 100644 index 0000000..c825257 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/union_semantics.yaml @@ -0,0 +1,110 @@ +openapi: "3.0.3" +info: + title: Rust union matching + version: "1.0.0" +paths: {} +components: + schemas: + Numeric: + oneOf: + - type: integer + - type: number + Low: + type: integer + minimum: 0 + maximum: 10 + High: + type: integer + minimum: 11 + maximum: 20 + Ranges: + oneOf: + - $ref: "#/components/schemas/Low" + - $ref: "#/components/schemas/High" + Choice: + type: string + enum: [red, blue] + OtherChoice: + type: string + enum: [blue, green] + Colors: + oneOf: + - $ref: "#/components/schemas/Choice" + - $ref: "#/components/schemas/OtherChoice" + Left: + type: object + required: [left] + properties: + left: { type: string } + Right: + type: object + required: [right] + properties: + right: { type: string } + Objects: + oneOf: + - $ref: "#/components/schemas/Left" + - $ref: "#/components/schemas/Right" + RawObjects: + anyOf: + - $ref: "#/components/schemas/Left" + - $ref: "#/components/schemas/Right" + Closed: + type: object + required: [left] + additionalProperties: false + properties: + left: { type: string } + ClosedChoice: + oneOf: + - $ref: "#/components/schemas/Closed" + - $ref: "#/components/schemas/Right" + Nested: + oneOf: + - $ref: "#/components/schemas/Colors" + - type: boolean + NestedAny: + oneOf: + - $ref: "#/components/schemas/RawObjects" + - type: boolean + DuplicateAny: + anyOf: + - $ref: "#/components/schemas/Left" + - $ref: "#/components/schemas/Left" + Discriminated: + oneOf: + - $ref: "#/components/schemas/Left" + - $ref: "#/components/schemas/Right" + discriminator: + propertyName: kind + mapping: + chosen: "#/components/schemas/Left" + Merged: + allOf: + - $ref: "#/components/schemas/Left" + - type: object + required: [tag] + properties: + tag: { type: string, enum: [tagged] } + Composed: + oneOf: + - $ref: "#/components/schemas/Merged" + - type: boolean + Containers: + type: object + required: [choice, raw] + properties: + choice: + oneOf: + - type: string + - type: boolean + raw: + anyOf: + - $ref: "#/components/schemas/Left" + - $ref: "#/components/schemas/Right" + Recursive: + anyOf: + - type: string + - x-rust-name: List + type: array + items: { $ref: "#/components/schemas/Recursive" } diff --git a/crates/oapi-codegen/tests/fixtures/unsupported_union_accessor_collision.yaml b/crates/oapi-codegen/tests/fixtures/unsupported_union_accessor_collision.yaml new file mode 100644 index 0000000..076c181 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/unsupported_union_accessor_collision.yaml @@ -0,0 +1,13 @@ +openapi: "3.0.3" +info: + title: Conflicting anyOf accessor + version: "1.0.0" +paths: {} +components: + schemas: + Value: + type: string + Choice: + anyOf: + - $ref: "#/components/schemas/Value" + - type: boolean diff --git a/crates/oapi-codegen/tests/generated.rs b/crates/oapi-codegen/tests/generated.rs index 84dd363..41704c5 100644 --- a/crates/oapi-codegen/tests/generated.rs +++ b/crates/oapi-codegen/tests/generated.rs @@ -11,6 +11,378 @@ //! attribute that turns off every lint that is about first-party source, and //! `include!` cannot carry one. So this file needs no lint exceptions of its own. +#[test] +fn nullable_form_values_preserve_scalar_conversion() { + use generated::nullable::Nullable; + + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Values { + count: Nullable, + enabled: Nullable, + ratio: Nullable, + } + + let form = "count=3&enabled=true&ratio=1.5"; + let expected = Values { + count: Nullable::Value(3), + enabled: Nullable::Value(true), + ratio: Nullable::Value(1.5_f64), + }; + let parsed: Values = serde_urlencoded::from_str(form).expect("nullable form scalars"); + assert_eq!(parsed, expected); + assert_eq!(serde_urlencoded::to_string(&parsed).expect("serialize form"), form); + let wrapped: Nullable = serde_urlencoded::from_str(form).expect("nullable form object"); + assert_eq!(wrapped, Nullable::Value(expected)); + assert_eq!(serde_urlencoded::to_string(&wrapped).expect("serialize object"), form); + for input in ["enabled=true&ratio=1.5", "count=bad&enabled=true&ratio=1.5"] { + assert!(serde_urlencoded::from_str::(input).is_err()); + } + assert!(serde_json::from_str::(r#"{"enabled":true,"ratio":1.5}"#).is_err()); +} + +#[test] +fn nullable_custom_types_skip_inherited_constraints_and_serde() { + use generated::nullable::Account; + + for value in [serde_json::Value::Null, serde_json::json!(1_i64)] { + let input = serde_json::json!({ + "id": "a", + "deactivated_at": null, + "custom_allof": value, + "custom_chain": value, + "local": {"ignored": true} + }); + let account: Account = serde_json::from_value(input).expect("custom integer or null"); + assert!(account.local.is_none()); + assert!(account.skipped_default.is_empty()); + let output = serde_json::to_value(account).expect("serialize custom fields"); + assert_eq!(output["custom_allof"], value); + assert_eq!(output["custom_chain"], value); + assert!(output.get("local").is_none()); + assert!(output.get("skipped_default").is_none()); + } + for field in ["custom_allof", "custom_chain"] { + let mut input = serde_json::json!({"id": "a", "deactivated_at": null}); + input[field] = serde_json::json!("not an integer"); + assert!(serde_json::from_value::(input).is_err()); + } +} + +macro_rules! inline_nullable_runtime_tests { + ($name:ident, $module:ident) => { + #[test] + fn $name() { + use generated::$module::*; + + #[derive(Clone)] + struct Service; + + impl Api for Service { + async fn array(&self, body: Nullable>>) -> ArrayResponse { + return ArrayResponse::Ok(body); + } + + async fn scalar(&self, body: Nullable) -> ScalarResponse { + return ScalarResponse::Ok(body); + } + + async fn map( + &self, + body: Nullable>>, + ) -> MapResponse { + return MapResponse::Ok(body); + } + + async fn alias(&self, body: Nullable) -> AliasResponse { + return AliasResponse::Ok(body); + } + } + + let _router: axum::Router = router(Service); + for input in [serde_json::json!(null), serde_json::json!([null, "value"])] { + let body = serde_json::from_value::>>>(input.clone()) + .expect("nullable array"); + let ArrayResponse::Ok(body) = ArrayResponse::Ok(body); + assert_eq!(serde_json::to_value(body).expect("serialize array response"), input); + } + for input in [serde_json::json!(null), serde_json::json!(4_i64)] { + let body = serde_json::from_value::>(input.clone()).expect("nullable integer"); + let ScalarResponse::Ok(body) = ScalarResponse::Ok(body); + assert_eq!(serde_json::to_value(body).expect("serialize scalar response"), input); + } + for input in [serde_json::json!(null), serde_json::json!({"a": null, "b": true})] { + let body = serde_json::from_value::>>>( + input.clone(), + ) + .expect("nullable map"); + let MapResponse::Ok(body) = MapResponse::Ok(body); + assert_eq!(serde_json::to_value(body).expect("serialize map response"), input); + } + for input in [serde_json::json!([1_i64]), serde_json::json!({}), serde_json::json!("bad")] { + assert!(serde_json::from_value::>>>(input).is_err()); + } + assert!(serde_json::from_value::>(serde_json::json!("bad")).is_err()); + assert!( + serde_json::from_value::>>>( + serde_json::json!({"a": "bad"}) + ) + .is_err() + ); + } + }; +} + +inline_nullable_runtime_tests!(inline_nullable_flat_json_round_trips, combined_inline_nullable); +inline_nullable_runtime_tests!( + inline_nullable_package_json_round_trips, + package_combined_inline_nullable +); + +#[test] +fn nullable_inline_and_referenced_bodies_have_the_same_json_type() { + use generated::package_nullable::Nullable; + use generated::package_nullable::NullableArray; + use generated::package_nullable::ParityResponse; + use generated::package_nullable::Plain; + use generated::package_nullable::WrappedResponse; + + for input in [serde_json::json!(null), serde_json::json!([null, "value"])] { + let inline: Nullable>> = serde_json::from_value(input.clone()).expect("inline array"); + let referenced: NullableArray = inline; + let ParityResponse::Ok(body) = ParityResponse::Ok(referenced); + assert_eq!(serde_json::to_value(body).expect("referenced response"), input); + } + for input in [serde_json::json!(null), serde_json::json!({"name": "value"})] { + let body: Nullable = serde_json::from_value(input.clone()).expect("nullable reference"); + let WrappedResponse::Ok(body) = WrappedResponse::Ok(body); + assert_eq!(serde_json::to_value(body).expect("wrapped response"), input); + } + assert!(serde_json::from_value::>(serde_json::json!({})).is_err()); +} + +#[test] +fn nullable_presence_round_trips() { + use generated::nullable::Account; + use generated::nullable::Nullable; + + let base = serde_json::json!({"id": "a", "deactivated_at": null}); + let account: Account = serde_json::from_value(base.clone()).expect("required nullable null"); + assert!(matches!(account.deactivated_at, Nullable::Null)); + assert!(account.nickname.is_none()); + assert!(account.patch.is_none()); + assert!(matches!(account.fallback, Nullable::Value(ref value) if value == "fallback")); + assert!(matches!( + account.enum_default, + Nullable::Value(generated::nullable::AccountEnumDefault::Active) + )); + assert!(account.null_default.is_none()); + assert!(account.ignored_default.is_none()); + assert!(matches!(account.empty_list, Nullable::Value(ref value) if value.is_empty())); + for (wire, values) in [ + ("nickname", vec![serde_json::json!("name")]), + ("plain", vec![serde_json::json!({"name": "n"})]), + ("plain_state", vec![serde_json::json!("active")]), + ("patch", vec![serde_json::Value::Null, serde_json::json!("name")]), + ("bounded", vec![serde_json::Value::Null, serde_json::json!(2_i32)]), + ("only_null", vec![serde_json::Value::Null]), + ("out_of_range", vec![serde_json::Value::Null]), + ("labels", vec![serde_json::json!([null, "name"])]), + ("values", vec![serde_json::json!({"a": null, "b": "name"})]), + ( + "nullable_list", + vec![serde_json::Value::Null, serde_json::json!(["name"])], + ), + ( + "nullable_map", + vec![serde_json::Value::Null, serde_json::json!({"key": "name"})], + ), + ( + "child", + vec![serde_json::Value::Null, serde_json::json!({"name": "n", "child": null})], + ), + ("state", vec![serde_json::Value::Null, serde_json::json!("active")]), + ("text", vec![serde_json::Value::Null, serde_json::json!("name")]), + ("direct_text", vec![serde_json::Value::Null, serde_json::json!("name")]), + ("wrapped_text", vec![serde_json::Value::Null, serde_json::json!("name")]), + ("nested_text", vec![serde_json::Value::Null, serde_json::json!("name")]), + ( + "nested_plain_text", + vec![serde_json::Value::Null, serde_json::json!("name")], + ), + ( + "inline_wrapped_text", + vec![serde_json::Value::Null, serde_json::json!("name")], + ), + ( + "reference", + vec![serde_json::Value::Null, serde_json::json!({"name": "n"})], + ), + ( + "inline", + vec![serde_json::Value::Null, serde_json::json!({"name": "n"})], + ), + ("custom", vec![serde_json::Value::Null, serde_json::json!("name")]), + ("fallback", vec![serde_json::Value::Null, serde_json::json!("custom")]), + ] { + for value in values { + let mut input = base.clone(); + input[wire] = value.clone(); + let parsed: Account = serde_json::from_value(input).expect(wire); + let output = serde_json::to_value(parsed).expect("serialize"); + assert_eq!(output.get(wire), Some(&value), "{wire}"); + } + } + let output = serde_json::to_value(account).expect("serialize absent fields"); + assert!(output.get("patch").is_none()); + assert!(output.get("nickname").is_none()); + for (wire, value) in [ + ("nickname", serde_json::Value::Null), + ("plain", serde_json::Value::Null), + ("plain_state", serde_json::Value::Null), + ("patch", serde_json::json!("x")), + ("bounded", serde_json::json!(0_i32)), + ("only_null", serde_json::json!(1_i32)), + ("out_of_range", serde_json::json!(i32::MIN)), + ("id", serde_json::Value::Null), + ("direct_text", serde_json::json!("x")), + ("wrapped_text", serde_json::json!("x")), + ("inline_wrapped_text", serde_json::json!("x")), + ("nested_text", serde_json::json!("x")), + ("nested_plain_text", serde_json::json!("x")), + ("labels", serde_json::json!([null, null])), + ("nullable_list", serde_json::json!([])), + ("nullable_list", serde_json::json!([null])), + ("nullable_map", serde_json::json!({})), + ("nullable_map", serde_json::json!({"key": null})), + ] { + let mut input = base.clone(); + input[wire] = value; + assert!(serde_json::from_value::(input).is_err(), "{wire}"); + } + for wire in ["id", "deactivated_at"] { + let mut input = base.clone(); + input.as_object_mut().expect("object").remove(wire); + assert!(serde_json::from_value::(input).is_err(), "{wire}"); + } +} + +#[test] +fn nullable_aliases_have_one_typed_null_state() { + use generated::nullable::*; + + let text: WrappedText = Nullable::Value("value".to_owned()); + let _: Nullable = text; + let custom: CustomChain = Nullable::Value(3_i64); + let _: Nullable = custom; + let directional: DirectionalAliasRequest = Nullable::Value(DirectionalValueRequest { + secret: "secret".to_owned(), + value: Nullable::Null, + next: None, + }); + let _: DirectionalRequest = directional; + + let mut account: Account = + serde_json::from_value(serde_json::json!({"id": "a", "deactivated_at": null})).expect("account"); + for field in [ + &mut account.wrapped_text, + &mut account.inline_wrapped_text, + &mut account.nested_text, + &mut account.nested_plain_text, + ] { + assert_eq!(*field, None); + *field = Some(Nullable::Null); + assert_eq!(*field, Some(Nullable::::Null)); + *field = Some(Nullable::Value("value".to_owned())); + } + let output = serde_json::to_value(account).expect("serialize values"); + assert_eq!(output["wrapped_text"], "value"); + + let account: Account = serde_json::from_value(serde_json::json!({ + "id": "a", + "deactivated_at": null, + "wrapped_text": null, + "inline_wrapped_text": null, + "nested_text": null, + "nested_plain_text": null + })) + .expect("explicit nulls"); + for field in [ + account.wrapped_text, + account.inline_wrapped_text, + account.nested_text, + account.nested_plain_text, + ] { + assert_eq!(field, Some(Nullable::::Null)); + } +} + +#[test] +fn required_nullable_references_and_constraints_require_presence() { + use generated::nullable::RequiredValues; + + let base = serde_json::json!({ + "plain": "value", "text": null, "state": null, "node": null, + "bounded": null, "defaulted": null + }); + let parsed: RequiredValues = serde_json::from_value(base.clone()).expect("present nulls"); + assert_eq!(serde_json::to_value(parsed).expect("serialize"), base); + for wire in ["plain", "text", "state", "node", "bounded", "defaulted"] { + let mut input = base.clone(); + input.as_object_mut().expect("object").remove(wire); + assert!(serde_json::from_value::(input).is_err(), "{wire}"); + } + for (wire, value) in [("text", serde_json::json!("x")), ("bounded", serde_json::json!(0_i32))] { + let mut input = base.clone(); + input[wire] = value; + assert!(serde_json::from_value::(input).is_err(), "{wire}"); + } +} + +#[test] +fn nullable_named_types_accept_null_outside_properties() { + use generated::nullable::Node; + use generated::nullable::NullableCode; + use generated::nullable::NullableState; + use generated::nullable::NullableText; + use generated::nullable::NullableUnion; + use generated::nullable::TextAlias; + + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("1").is_ok()); + assert!(serde_json::from_str::("\"value\"").is_ok()); + assert!(serde_json::from_str::("\"unknown\"").is_err()); + assert!(serde_json::from_str::("null").is_ok()); + assert!(serde_json::from_str::("1").is_ok()); + assert!(serde_json::from_str::("3").is_err()); +} + +#[test] +fn nullable_directional_models_retain_presence_and_recursion() { + use generated::nullable::DirectionalRequest; + use generated::nullable::DirectionalResponse; + + for value in [ + serde_json::Value::Null, + serde_json::json!({"secret": "s", "value": null, "next": null}), + ] { + let parsed: DirectionalRequest = serde_json::from_value(value.clone()).expect("request"); + assert_eq!(serde_json::to_value(parsed).expect("serialize"), value); + } + for value in [ + serde_json::Value::Null, + serde_json::json!({"id": "i", "value": null, "next": null}), + ] { + let parsed: DirectionalResponse = serde_json::from_value(value.clone()).expect("response"); + assert_eq!(serde_json::to_value(parsed).expect("serialize"), value); + } + assert!(serde_json::from_value::(serde_json::json!({"secret": "s"})).is_err()); + assert!(serde_json::from_value::(serde_json::json!({"id": "i"})).is_err()); +} + mod generated { #[path = "allof_merge.rs"] pub mod allof_merge; @@ -28,8 +400,12 @@ mod generated { pub mod client_negotiated_request; #[path = "client_negotiated_response.rs"] pub mod client_negotiated_response; + #[path = "client_union_traits.rs"] + pub mod client_union_traits; #[path = "client_widgets.rs"] pub mod client_widgets; + #[path = "combined_inline_nullable.rs"] + pub mod combined_inline_nullable; #[path = "combined_keyword_operations.rs"] pub mod combined_keyword_operations; #[path = "combined_prelude_value_names.rs"] @@ -64,6 +440,18 @@ mod generated { pub mod object_additional_properties; #[path = "object_deny_unknown_fields.rs"] pub mod object_deny_unknown_fields; + #[path = "package_combined_inline_nullable.rs"] + pub mod package_combined_inline_nullable; + #[path = "package_nullable.rs"] + pub mod package_nullable; + #[path = "server_union_traits.rs"] + pub mod server_union_traits; + #[path = "union_duplicate.rs"] + pub mod union_duplicate; + #[path = "union_prelude_names.rs"] + pub mod union_prelude_names; + #[path = "union_semantics.rs"] + pub mod union_semantics; #[path = "compose_shared.rs"] pub mod compose_shared; @@ -164,6 +552,216 @@ mod generated { pub mod package_combined_x_rust_derive; } +#[test] +fn union_models_do_not_shadow_prelude_traits_or_types() { + use generated::union_prelude_names::PreludeAny; + use generated::union_prelude_names::PreludeOne; + use generated::union_prelude_names::Result; + use generated::union_prelude_names::TryFrom; + + let text: TryFrom = "text".to_owned(); + let other: Result = text.clone(); + assert!(serde_json::from_value::(serde_json::json!(text)).is_ok()); + assert!(serde_json::from_value::(serde_json::json!(true)).is_ok()); + let model = PreludeAny::try_from(serde_json::json!(other)).expect("string alternative matches"); + assert_eq!(model.as_result().expect("decode string"), "text"); + assert!(serde_json::from_str::("false").is_ok()); + assert!(PreludeAny::try_from(serde_json::json!(42_i32)).is_err()); +} + +#[test] +fn oneof_counts_successful_rust_deserializations() { + use generated::union_semantics::Numeric; + + for json in ["null", "true", "\"text\"", "1"] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } + for json in ["1.0", "1.5"] { + assert!(matches!( + serde_json::from_str::(json).expect("only f64 decodes"), + Numeric::F64(_) + )); + } +} + +#[test] +fn oneof_disjoint_schema_bounds_remain_ambiguous_rust_aliases() { + use generated::union_semantics::High; + use generated::union_semantics::Low; + use generated::union_semantics::Ranges; + + for value in [0_u64, 10, 11, 20, 21] { + let json = serde_json::json!(value); + assert!(serde_json::from_value::(json.clone()).is_ok()); + assert!(serde_json::from_value::(json.clone()).is_ok()); + assert!(serde_json::from_value::(json).is_err(), "{value}"); + } +} + +#[test] +fn oneof_checks_required_properties_and_closed_objects() { + use generated::union_semantics::ClosedChoice; + use generated::union_semantics::Objects; + + for json in [r#"{}"#, r#"{"left":"a","right":"b"}"#, r#"{"left":null}"#] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } + assert!(serde_json::from_str::(r#"{"left":"a","extra":42}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"left":"a","extra":42}"#).is_err()); + assert!(serde_json::from_str::(r#"{"left":"a","right":"b"}"#).is_ok()); +} + +#[test] +fn union_decoding_checks_nested_unions_enums_and_merged_objects() { + use generated::union_semantics::Colors; + use generated::union_semantics::Composed; + use generated::union_semantics::Nested; + use generated::union_semantics::NestedAny; + + for json in [r#""blue""#, r#""unknown""#, "42"] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } + for json in [r#""red""#, r#""green""#] { + assert!(serde_json::from_str::(json).is_ok(), "{json}"); + assert!(serde_json::from_str::(json).is_ok(), "{json}"); + } + assert!(serde_json::from_str::("true").is_ok()); + assert!(serde_json::from_str::(r#"{"left":"a","right":"b"}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"left":"a","tag":"tagged"}"#).is_ok()); + for json in [r#"{"left":"a"}"#, r#"{"left":"a","tag":"wrong"}"#] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } +} + +#[test] +fn discriminator_and_duplicate_alternatives_do_not_hide_overlap() { + use generated::union_duplicate::Pet; + use generated::union_semantics::Discriminated; + use generated::union_semantics::DuplicateAny; + + assert!(serde_json::from_str::(r#"{"meow":"yes"}"#).is_err()); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::(r#"{"left":"a","extra":1}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"kind":"chosen","left":"a","right":"b"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"right":"b"}"#).is_ok()); + let chosen: Discriminated = + serde_json::from_str(r#"{"left":"a"}"#).expect("mapping does not require a discriminator"); + assert!(matches!(&chosen, Discriminated::Chosen(_))); + assert_eq!( + serde_json::to_value(chosen).expect("serialize without added discriminator"), + serde_json::json!({"left":"a"}) + ); +} + +#[test] +fn anyof_preserves_the_entire_json_value_and_exposes_all_matching_views() { + use generated::union_semantics::RawObjects; + + let value = serde_json::json!({"left":"a","right":"b","extra":{"array":[null,true,1.5_f64]}}); + let model = RawObjects::try_from(value.clone()).expect("both alternatives match"); + assert_eq!(model.as_value(), &value); + assert_eq!(model.as_left().expect("left view").left, "a"); + assert_eq!(model.as_right().expect("right view").right, "b"); + assert_eq!(serde_json::to_value(&model).expect("serialize"), value); + let back: RawObjects = serde_json::from_value(value.clone()).expect("deserialize"); + assert_eq!(back.into_value(), value); + let only_left = RawObjects::try_from(serde_json::json!({"left":"a"})).expect("left matches"); + let missing_right: Result = only_left.as_right(); + assert!(missing_right.is_err()); + let no_match: Result = RawObjects::try_from(serde_json::json!({})); + assert!(no_match.is_err()); + assert!(serde_json::from_str::("null").is_err()); +} + +#[test] +fn union_recursion_through_properties_consumes_input() { + use generated::recursive_schema::Expression; + + for input in [ + serde_json::json!("leaf"), + serde_json::json!({"nested": "leaf"}), + serde_json::json!({"nested": {"nested": "leaf"}}), + ] { + let value: Expression = serde_json::from_value(input.clone()).expect("recursive expression"); + assert_eq!(serde_json::to_value(value).expect("serialize expression"), input); + } + assert!(serde_json::from_value::(serde_json::json!({"nested": true})).is_err()); +} + +#[test] +fn inline_and_recursive_unions_decode_without_losing_json() { + use generated::union_semantics::Containers; + use generated::union_semantics::Recursive; + + let input = serde_json::json!({"choice":true,"raw":{"left":"a","right":"b","extra":1_i32}}); + let model: Containers = serde_json::from_value(input.clone()).expect("inline unions"); + assert_eq!(serde_json::to_value(model).expect("serialize"), input); + for input in [serde_json::json!("leaf"), serde_json::json!(["leaf", ["nested"], []])] { + let model: Recursive = serde_json::from_value(input.clone()).expect("recursive union"); + assert_eq!(model.into_value(), input); + } + + assert!(serde_json::from_str::(r#"["leaf",42]"#).is_err()); +} + +#[test] +fn union_matching_uses_the_projected_request_and_response_types() { + use generated::combined_read_write_only::UnionEnvelopeRequest; + use generated::combined_read_write_only::UnionEnvelopeResponse; + + let request = serde_json::json!({"email":"a@example.test","password":"secret"}); + let response = serde_json::json!({"email":"a@example.test","id":"42"}); + let request = serde_json::json!({"exclusive":request,"inclusive":request}); + let response = serde_json::json!({"exclusive":response,"inclusive":response}); + let parsed: UnionEnvelopeRequest = serde_json::from_value(request.clone()).expect("request projection"); + assert_eq!(parsed.inclusive.as_value(), &request["inclusive"]); + let parsed: UnionEnvelopeResponse = serde_json::from_value(response.clone()).expect("response projection"); + assert_eq!(parsed.inclusive.as_value(), &response["inclusive"]); + assert!(serde_json::from_value::(response).is_err()); + assert!(serde_json::from_value::(request).is_err()); +} + +#[test] +fn union_matching_does_not_add_traits_to_foreign_payloads() { + use generated::server_union_traits::InputExclusive; + use generated::server_union_traits::InputInclusive; + use generated::server_union_traits::OutputInclusive; + + let exclusive: InputExclusive = serde_json::from_str(r#""incoming""#).expect("non-Clone payload"); + match exclusive { + InputExclusive::Incoming(payload) => assert_eq!(payload.0, "incoming"), + InputExclusive::Bool(_) => panic!("expected the decoded foreign payload"), + } + let input: InputInclusive = serde_json::from_str(r#""incoming""#).expect("deserialize-only payload"); + let payload = input.as_incoming().expect("typed conversion"); + assert_eq!(payload.0, "incoming"); + for value in [serde_json::json!("outgoing"), serde_json::json!({"unvalidated":42_i32})] { + let output = OutputInclusive::from(value.clone()); + assert_eq!(output.as_value(), &value); + assert_eq!(serde_json::to_value(&output).expect("serialize-only payload"), value); + assert_eq!(output.into_value(), value); + } +} + +#[test] +fn client_union_matching_keeps_foreign_traits_directional() { + use generated::client_union_traits::InputInclusive; + use generated::client_union_traits::OutputExclusive; + use generated::client_union_traits::OutputInclusive; + + let exclusive: OutputExclusive = serde_json::from_str(r#""outgoing""#).expect("non-Clone response payload"); + match exclusive { + OutputExclusive::Outgoing(payload) => assert_eq!(payload.0, "outgoing"), + OutputExclusive::Bool(_) => panic!("expected the decoded foreign payload"), + } + let output: OutputInclusive = serde_json::from_str(r#""outgoing""#).expect("deserialize-only response"); + assert_eq!(output.as_outgoing().expect("typed conversion").0, "outgoing"); + let value = serde_json::json!({"unvalidated":true}); + let input = InputInclusive::from(value.clone()); + assert_eq!(serde_json::to_value(input).expect("serialize-only request"), value); +} + /// Stand-in for the foreign types the `ext_x_rust_derive` fixture points its /// `x-rust-type` targets at (`crate::restricted`). /// @@ -175,6 +773,12 @@ mod generated { reason = "test-only stand-in for the x-rust-derive fixture's foreign targets; nothing here is constructed" )] mod restricted { + #[derive(serde::Serialize)] + pub struct SerializeOnly(pub String); + + #[derive(serde::Deserialize)] + pub struct DeserializeOnly(pub String); + /// Declares `Debug` alone. #[derive(serde::Serialize, serde::Deserialize, Debug)] pub struct Opaque(pub String); diff --git a/crates/oapi-codegen/tests/generated/allof_merge.rs b/crates/oapi-codegen/tests/generated/allof_merge.rs index 35a6209..01bc67b 100644 --- a/crates/oapi-codegen/tests/generated/allof_merge.rs +++ b/crates/oapi-codegen/tests/generated/allof_merge.rs @@ -16,14 +16,54 @@ pub struct Base { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Timestamped { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Timestamped::validate_created_at", + default + )] pub created_at: Option>, } +impl Timestamped { + /// The rules the document gives `created_at`, checked on the way in. + fn validate_created_at<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Entity { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Entity::validate_created_at", + default + )] pub created_at: Option>, pub name: String, } +impl Entity { + /// The rules the document gives `created_at`, checked on the way in. + fn validate_created_at<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/anyof_untagged.rs b/crates/oapi-codegen/tests/generated/anyof_untagged.rs index b0de9aa..56a850e 100644 --- a/crates/oapi-codegen/tests/generated/anyof_untagged.rs +++ b/crates/oapi-codegen/tests/generated/anyof_untagged.rs @@ -19,9 +19,54 @@ pub struct NumberNote { pub value: i64, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] -#[serde(untagged)] -pub enum Note { - TextNote(TextNote), - NumberNote(NumberNote), +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct Note { + value: serde_json::Value, +} +impl Note { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `TextNote` Rust alternative. + pub fn as_text_note(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `NumberNote` Rust alternative. + pub fn as_number_note( + &self, + ) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for Note { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for Note { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } } diff --git a/crates/oapi-codegen/tests/generated/client_union_traits.rs b/crates/oapi-codegen/tests/generated/client_union_traits.rs new file mode 100644 index 0000000..ddba8e5 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/client_union_traits.rs @@ -0,0 +1,241 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +pub type Incoming = crate::restricted::SerializeOnly; + +pub type Outgoing = crate::restricted::DeserializeOnly; + +#[derive(serde::Serialize)] +pub struct Input { + pub exclusive: InputExclusive, + pub inclusive: InputInclusive, +} + +#[derive(serde::Deserialize)] +pub struct Output { + pub exclusive: OutputExclusive, + pub inclusive: OutputInclusive, +} + +#[derive(serde::Serialize)] +#[serde(untagged)] +pub enum InputExclusive { + Incoming(Incoming), + Bool(bool), +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct InputInclusive { + value: serde_json::Value, +} +impl InputInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } +} +/// Construct a raw JSON wrapper without alternative checks. +impl ::std::convert::From for InputInclusive { + fn from(value: serde_json::Value) -> Self { + return Self { value }; + } +} + +pub enum OutputExclusive { + Outgoing(Outgoing), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for OutputExclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Outgoing(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OutputInclusive { + value: serde_json::Value, +} +impl OutputInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Outgoing` Rust alternative. + pub fn as_outgoing(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for OutputInclusive { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for OutputInclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +pub enum ExchangeResponse { + /// The result. + Ok(Output), +} + +/// Errors returned by the generated client. +#[derive(Debug)] +pub enum ClientError { + /// The `reqwest` request failed to send or complete, including any + /// body decoding `reqwest` performs internally (such as JSON). + Http(reqwest::Error), + /// The server returned a status code the operation does not declare. + UnexpectedStatus(reqwest::StatusCode), + /// The response `Content-Type` matched none of the representations the + /// operation declares for its status. + UnexpectedContentType(String), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + Decode(String), +} +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Http(error) => return write!(f, "HTTP request failed: {error}"), + ClientError::UnexpectedStatus(status) => { + return write!(f, "unexpected response status: {status}"); + } + ClientError::UnexpectedContentType(content_type) => { + return write!(f, "unexpected response content type: {content_type}"); + } + ClientError::Decode(message) => { + return write!(f, "failed to decode response: {message}"); + } + } + } +} +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Http(error) => return Some(error), + ClientError::UnexpectedStatus(_) + | ClientError::UnexpectedContentType(_) + | ClientError::Decode(_) => return None, + } + } +} +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + return ClientError::Http(error); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `https://api.example.com`). +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + http: reqwest::blocking::Client, +} + +impl Client { + /// Build a client targeting `base_url` with a default blocking + /// `reqwest::blocking::Client`. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::blocking::Client::builder().build()?; + return Ok(Self { + base_url: base_url.into(), + http, + }); + } + /// Build a client targeting `base_url` with a caller-provided + /// `reqwest::blocking::Client` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } + pub fn exchange(&self, body: Input) -> Result { + let url = format!("{}/exchange", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Output = response.json()?; + return Ok(ExchangeResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/client_widgets.rs b/crates/oapi-codegen/tests/generated/client_widgets.rs index 367bbaa..5f7b3c8 100644 --- a/crates/oapi-codegen/tests/generated/client_widgets.rs +++ b/crates/oapi-codegen/tests/generated/client_widgets.rs @@ -13,9 +13,27 @@ pub struct Widget { pub id: String, pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Widget::validate_tags", + default + )] pub tags: Option>, } +impl Widget { + /// The rules the document gives `tags`, checked on the way in. + fn validate_tags<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, Debug, Clone, PartialEq)] pub struct NewWidget { diff --git a/crates/oapi-codegen/tests/generated/combined_inline_nullable.rs b/crates/oapi-codegen/tests/generated/combined_inline_nullable.rs new file mode 100644 index 0000000..96ddab8 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/combined_inline_nullable.rs @@ -0,0 +1,368 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + +pub type NullableText = Nullable; + +pub type TextAlias = NullableText; + +pub type WrappedText = TextAlias; + +#[derive(Debug, Clone, PartialEq)] +pub enum ArrayResponse { + /// Result + Ok(Nullable>>), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ScalarResponse { + /// Result + Ok(Nullable), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MapResponse { + /// Result + Ok(Nullable>>), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AliasResponse { + /// Result + Ok(NullableText), +} + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn array( + &self, + body: Nullable>>, + ) -> impl std::future::Future + Send; + fn scalar( + &self, + body: Nullable, + ) -> impl std::future::Future + Send; + fn map( + &self, + body: Nullable>>, + ) -> impl std::future::Future + Send; + fn alias( + &self, + body: WrappedText, + ) -> impl std::future::Future + Send; +} + +impl axum::response::IntoResponse for ArrayResponse { + fn into_response(self) -> axum::response::Response { + match self { + ArrayResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for ScalarResponse { + fn into_response(self) -> axum::response::Response { + match self { + ScalarResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for MapResponse { + fn into_response(self) -> axum::response::Response { + match self { + MapResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for AliasResponse { + fn into_response(self) -> axum::response::Response { + match self { + AliasResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/array", axum::routing::post(array_handler::)) + .route("/scalar", axum::routing::post(scalar_handler::)) + .route("/map", axum::routing::post(map_handler::)) + .route("/alias", axum::routing::post(alias_handler::)) + .with_state(api) +} + +async fn array_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>>>, +) -> ArrayResponse { + api.array(body).await +} + +async fn scalar_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>, +) -> ScalarResponse { + api.scalar(body).await +} + +async fn map_handler( + axum::extract::State(api): axum::extract::State, + axum::Json( + body, + ): axum::Json>>>, +) -> MapResponse { + api.map(body).await +} + +async fn alias_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> AliasResponse { + api.alias(body).await +} + +/// Errors returned by the generated client. +#[derive(Debug)] +pub enum ClientError { + /// The `reqwest` request failed to send or complete, including any + /// body decoding `reqwest` performs internally (such as JSON). + Http(reqwest::Error), + /// The server returned a status code the operation does not declare. + UnexpectedStatus(reqwest::StatusCode), + /// The response `Content-Type` matched none of the representations the + /// operation declares for its status. + UnexpectedContentType(String), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + Decode(String), +} +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Http(error) => return write!(f, "HTTP request failed: {error}"), + ClientError::UnexpectedStatus(status) => { + return write!(f, "unexpected response status: {status}"); + } + ClientError::UnexpectedContentType(content_type) => { + return write!(f, "unexpected response content type: {content_type}"); + } + ClientError::Decode(message) => { + return write!(f, "failed to decode response: {message}"); + } + } + } +} +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Http(error) => return Some(error), + ClientError::UnexpectedStatus(_) + | ClientError::UnexpectedContentType(_) + | ClientError::Decode(_) => return None, + } + } +} +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + return ClientError::Http(error); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `https://api.example.com`). +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + http: reqwest::blocking::Client, +} + +impl Client { + /// Build a client targeting `base_url` with a default blocking + /// `reqwest::blocking::Client`. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::blocking::Client::builder().build()?; + return Ok(Self { + base_url: base_url.into(), + http, + }); + } + /// Build a client targeting `base_url` with a caller-provided + /// `reqwest::blocking::Client` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } + pub fn array( + &self, + body: Nullable>>, + ) -> Result { + let url = format!("{}/array", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable>> = response.json()?; + return Ok(ArrayResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn scalar(&self, body: Nullable) -> Result { + let url = format!("{}/scalar", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable = response.json()?; + return Ok(ScalarResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn map( + &self, + body: Nullable>>, + ) -> Result { + let url = format!("{}/map", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable>> = response + .json()?; + return Ok(MapResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn alias(&self, body: WrappedText) -> Result { + let url = format!("{}/alias", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: NullableText = response.json()?; + return Ok(AliasResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs b/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs index b20a2e6..85301bb 100644 --- a/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs +++ b/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs @@ -11,28 +11,92 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Ok { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ok::validate_id", + default + )] pub id: Option, } +impl Ok { + /// The rules the document gives `id`, checked on the way in. + fn validate_id<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Err { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Err::validate_message", + default + )] pub message: Option, } +impl Err { + /// The rules the document gives `message`, checked on the way in. + fn validate_message<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Some { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Some::validate_value", + default + )] pub value: Option, } +impl Some { + /// The rules the document gives `value`, checked on the way in. + fn validate_value<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct None { pub note: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "None::validate_holder", + default + )] pub holder: Option, } +impl None { + /// The rules the document gives `holder`, checked on the way in. + fn validate_holder<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(Debug, Clone)] pub struct UploadMultipart { @@ -49,9 +113,25 @@ pub enum UploadResponse { #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct ProbeQuery { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ProbeQuery::validate_maybe", + default + )] pub maybe: Option, } +impl ProbeQuery { + /// The rules the document gives `maybe`, checked on the way in. + fn validate_maybe<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(Debug, Clone, PartialEq)] pub enum ProbeResponse { diff --git a/crates/oapi-codegen/tests/generated/combined_read_write_only.rs b/crates/oapi-codegen/tests/generated/combined_read_write_only.rs index 7eb09c8..d380b0e 100644 --- a/crates/oapi-codegen/tests/generated/combined_read_write_only.rs +++ b/crates/oapi-codegen/tests/generated/combined_read_write_only.rs @@ -9,6 +9,210 @@ reason = "generated code, not first-party source" )] +/// The request shape of `UnionEnvelope`. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct UnionEnvelopeRequest { + pub exclusive: ExclusiveAccountRequest, + pub inclusive: InclusiveAccountRequest, +} + +/// The response shape of `UnionEnvelope`. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct UnionEnvelopeResponse { + pub exclusive: ExclusiveAccountResponse, + pub inclusive: InclusiveAccountResponse, +} + +/// The request shape of `ExclusiveAccount`. +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum ExclusiveAccountRequest { + Account(AccountRequest), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for ExclusiveAccountRequest { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Account(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +/// The response shape of `ExclusiveAccount`. +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum ExclusiveAccountResponse { + Account(AccountResponse), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for ExclusiveAccountResponse { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Account(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +/// The request shape of `InclusiveAccount`. +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct InclusiveAccountRequest { + value: serde_json::Value, +} +impl InclusiveAccountRequest { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Account` Rust alternative. + pub fn as_account( + &self, + ) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for InclusiveAccountRequest { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for InclusiveAccountRequest { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +/// The response shape of `InclusiveAccount`. +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct InclusiveAccountResponse { + value: serde_json::Value, +} +impl InclusiveAccountResponse { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Account` Rust alternative. + pub fn as_account( + &self, + ) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for InclusiveAccountResponse { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for InclusiveAccountResponse { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + /// An account, used as a request body and as a response body. /// /// The request shape of `Account`. A `readOnly` property is not part of it. @@ -51,6 +255,12 @@ pub struct AuditEntry { pub action: String, } +#[derive(Debug, Clone, PartialEq)] +pub enum ExchangeUnionResponse { + /// The union response. + Ok(UnionEnvelopeResponse), +} + /// List every account. #[derive(Debug, Clone, PartialEq)] pub enum ListAccountsResponse { @@ -144,6 +354,10 @@ where /// Server behaviour: implement one method per operation. pub trait Api: Clone + Send + Sync + 'static { + fn exchange_union( + &self, + body: UnionEnvelopeRequest, + ) -> impl std::future::Future + Send; /// List every account. fn list_accounts( &self, @@ -168,6 +382,22 @@ pub trait Api: Clone + Send + Sync + 'static { ) -> impl std::future::Future + Send; } +impl axum::response::IntoResponse for ExchangeUnionResponse { + fn into_response(self) -> axum::response::Response { + match self { + ExchangeUnionResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + impl axum::response::IntoResponse for ListAccountsResponse { fn into_response(self) -> axum::response::Response { match self { @@ -251,6 +481,7 @@ impl axum::response::IntoResponse for UploadAvatarResponse { /// Build an axum `Router` that dispatches each route to `api`. pub fn router(api: T) -> axum::Router { axum::Router::new() + .route("/union", axum::routing::post(exchange_union_handler::)) .route( "/accounts", axum::routing::get(list_accounts_handler::) @@ -265,6 +496,13 @@ pub fn router(api: T) -> axum::Router { .with_state(api) } +async fn exchange_union_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> ExchangeUnionResponse { + api.exchange_union(body).await +} + async fn list_accounts_handler( axum::extract::State(api): axum::extract::State, ) -> ListAccountsResponse { @@ -384,6 +622,21 @@ impl Client { http, }; } + pub fn exchange_union( + &self, + body: UnionEnvelopeRequest, + ) -> Result { + let url = format!("{}/union", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: UnionEnvelopeResponse = response.json()?; + return Ok(ExchangeUnionResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } /// List every account. pub fn list_accounts(&self) -> Result { let url = format!("{}/accounts", self.base_url); diff --git a/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs b/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs index 2780507..7bfb589 100644 --- a/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs +++ b/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs @@ -12,9 +12,25 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct GetWidgetResponse { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "GetWidgetResponse::validate_label", + default + )] pub label: Option, } +impl GetWidgetResponse { + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} /// Fetch a single widget by identifier. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/oapi-codegen/tests/generated/combined_server_client.rs b/crates/oapi-codegen/tests/generated/combined_server_client.rs index 7d8f26f..b170394 100644 --- a/crates/oapi-codegen/tests/generated/combined_server_client.rs +++ b/crates/oapi-codegen/tests/generated/combined_server_client.rs @@ -13,9 +13,25 @@ pub struct Order { pub id: String, pub total: f64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Order::validate_note", + default + )] pub note: Option, } +impl Order { + /// The rules the document gives `note`, checked on the way in. + fn validate_note<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} /// Fetch a single order by identifier. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs b/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs index f4d5cfb..5858795 100644 --- a/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs +++ b/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs @@ -15,6 +15,135 @@ pub type Opaque = crate::restricted::Opaque; pub struct Thing { pub id: String, pub opaque: Opaque, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Thing::validate_exclusive", + default + )] + pub exclusive: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Thing::validate_inclusive", + default + )] + pub inclusive: Option, +} +impl Thing { + /// The rules the document gives `exclusive`, checked on the way in. + fn validate_exclusive<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `inclusive`, checked on the way in. + fn validate_inclusive<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +#[derive(serde::Serialize, Debug)] +#[serde(untagged)] +pub enum ThingExclusive { + Opaque(Opaque), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for ThingExclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Opaque(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct ThingInclusive { + value: serde_json::Value, +} +impl ThingInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Opaque` Rust alternative. + pub fn as_opaque(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for ThingInclusive { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for ThingInclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } } /// A response body whose model drops Clone and PartialEq. diff --git a/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs b/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs index dfc3ac3..eb042ed 100644 --- a/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs +++ b/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs @@ -21,13 +21,57 @@ pub struct HasReference { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Ordered { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ordered::validate_b_first", + default + )] pub b_first: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ordered::validate_c_second", + default + )] pub c_second: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ordered::validate_a_last", + default + )] pub a_last: Option, } +impl Ordered { + /// The rules the document gives `b_first`, checked on the way in. + fn validate_b_first<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `c_second`, checked on the way in. + fn validate_c_second<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `a_last`, checked on the way in. + fn validate_a_last<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum ClientTypeVarnames { @@ -50,15 +94,60 @@ pub enum ClientTypeNames { pub struct Client { #[deprecated(note = "use full_name instead")] pub name: String, - #[serde(rename = "account_id", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "account_id", + skip_serializing_if = "Option::is_none", + deserialize_with = "Client::validate_account_identifier", + default + )] pub account_identifier: Option, + #[serde(deserialize_with = "Client::validate_keep_null", default)] pub keep_null: Option, #[serde(skip)] pub internal: Option, } +#[allow(deprecated)] +impl Client { + /// The rules the document gives `account_identifier`, checked on the way in. + fn validate_account_identifier<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `keep_null`, checked on the way in. + fn validate_keep_null<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct ClientInternal { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ClientInternal::validate_secret", + default + )] pub secret: Option, } +impl ClientInternal { + /// The rules the document gives `secret`, checked on the way in. + fn validate_secret<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/integer_enum.rs b/crates/oapi-codegen/tests/generated/integer_enum.rs index ee733c2..f5d4cc6 100644 --- a/crates/oapi-codegen/tests/generated/integer_enum.rs +++ b/crates/oapi-codegen/tests/generated/integer_enum.rs @@ -96,11 +96,41 @@ impl TryFrom for Level { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Job { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Job::validate_priority", + default + )] pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Job::validate_retries", + default + )] pub retries: Option, } +impl Job { + /// The rules the document gives `priority`, checked on the way in. + fn validate_priority<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `retries`, checked on the way in. + fn validate_retries<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] #[serde(try_from = "i64", into = "i64")] diff --git a/crates/oapi-codegen/tests/generated/nullable.rs b/crates/oapi-codegen/tests/generated/nullable.rs index f47b885..8a71ea4 100644 --- a/crates/oapi-codegen/tests/generated/nullable.rs +++ b/crates/oapi-codegen/tests/generated/nullable.rs @@ -9,8 +9,1020 @@ reason = "generated code, not first-party source" )] +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + +/// The request shape of `DirectionalAlias`. +pub type DirectionalAliasRequest = DirectionalRequest; + +/// The response shape of `DirectionalAlias`. +pub type DirectionalAliasResponse = DirectionalResponse; + +pub type NullableArray = Nullable>>; + +/// The request shape of `Directional`. +pub type DirectionalRequest = Nullable; + +/// The response shape of `Directional`. +pub type DirectionalResponse = Nullable; + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Account { pub id: String, - pub deactivated_at: Option>, + pub deactivated_at: Nullable>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_nickname", + default + )] + pub nickname: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_plain", + default + )] + pub plain: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_plain_state", + default + )] + pub plain_state: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_patch", + default + )] + pub patch: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_bounded", + default + )] + pub bounded: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_only_null", + default + )] + pub only_null: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_out_of_range", + default + )] + pub out_of_range: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_labels", + default + )] + pub labels: Option>>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_values", + default + )] + pub values: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_nullable_list", + default + )] + pub nullable_list: Option>>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_nullable_map", + default + )] + pub nullable_map: Option>>, + #[serde(default = "Account::default_empty_list")] + pub empty_list: Nullable>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_ignored_default", + default + )] + pub ignored_default: Option>>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_child", + default + )] + pub child: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_state", + default + )] + pub state: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_text", + default + )] + pub text: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_direct_text", + default + )] + pub direct_text: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_wrapped_text", + default + )] + pub wrapped_text: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_inline_wrapped_text", + default + )] + pub inline_wrapped_text: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_nested_text", + default + )] + pub nested_text: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_nested_plain_text", + default + )] + pub nested_plain_text: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_reference", + default + )] + pub reference: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_inline", + default + )] + pub inline: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_custom", + default + )] + pub custom: Option>, + #[serde(skip)] + pub skipped: Option>, + #[serde(skip)] + pub local: Option, + #[serde(skip)] + pub skipped_default: String, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_custom_allof", + default + )] + pub custom_allof: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_custom_chain", + default + )] + pub custom_chain: Option, + #[serde(default = "Account::default_fallback")] + pub fallback: Nullable, + #[serde(default = "Account::default_enum_default")] + pub enum_default: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Account::validate_null_default", + default + )] + pub null_default: Option>, +} +impl Account { + /// The rules the document gives `nickname`, checked on the way in. + fn validate_nickname<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `plain`, checked on the way in. + fn validate_plain<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `plain_state`, checked on the way in. + fn validate_plain_state<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `patch`, checked on the way in. + fn validate_patch<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`patch` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `bounded`, checked on the way in. + fn validate_bounded<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if *item < 1 { + return Err( + serde::de::Error::custom("`bounded` must be 1 or more"), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `only_null`, checked on the way in. + fn validate_only_null<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if { + let _ = item; + true + } { + return Err( + serde::de::Error::custom( + "`only_null` has no non-null value within the declared bounds", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `out_of_range`, checked on the way in. + fn validate_out_of_range<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if { + let _ = item; + true + } { + return Err( + serde::de::Error::custom( + "`out_of_range` has no non-null value within the declared bounds", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `labels`, checked on the way in. + fn validate_labels<'de, D>( + deserializer: D, + ) -> ::core::result::Result>>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + > as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if { + let mut seen = std::collections::HashSet::with_capacity(item.len()); + item.iter().any(|entry| return !seen.insert(entry)) + } { + return Err( + serde::de::Error::custom("`labels` must not repeat an item"), + ); + } + } + } + return Ok(value); + } + /// The rules the document gives `values`, checked on the way in. + fn validate_values<'de, D>( + deserializer: D, + ) -> ::core::result::Result< + Option>, + D::Error, + > + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `nullable_list`, checked on the way in. + fn validate_nullable_list<'de, D>( + deserializer: D, + ) -> ::core::result::Result>>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + > as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.len() < 1usize { + return Err( + serde::de::Error::custom( + "`nullable_list` must hold 1 or more items", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `nullable_map`, checked on the way in. + fn validate_nullable_map<'de, D>( + deserializer: D, + ) -> ::core::result::Result< + Option>>, + D::Error, + > + where + D: serde::Deserializer<'de>, + { + let value = Some( + , + > as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.len() < 1usize { + return Err( + serde::de::Error::custom( + "`nullable_map` must hold 1 or more properties", + ), + ); + } + } + } + } + return Ok(value); + } + /// The `default` the document gives `empty_list`. + fn default_empty_list() -> Nullable> { + Nullable::Value(Default::default()) + } + /// The rules the document gives `ignored_default`, checked on the way in. + fn validate_ignored_default<'de, D>( + deserializer: D, + ) -> ::core::result::Result>>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + > as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `child`, checked on the way in. + fn validate_child<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `state`, checked on the way in. + fn validate_state<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `text`, checked on the way in. + fn validate_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `direct_text`, checked on the way in. + fn validate_direct_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`direct_text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `wrapped_text`, checked on the way in. + fn validate_wrapped_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`wrapped_text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `inline_wrapped_text`, checked on the way in. + fn validate_inline_wrapped_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`inline_wrapped_text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `nested_text`, checked on the way in. + fn validate_nested_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`nested_text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `nested_plain_text`, checked on the way in. + fn validate_nested_plain_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + { + let item = &value; + if let Some(item) = item.as_ref() { + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom( + "`nested_plain_text` must hold 2 or more characters", + ), + ); + } + } + } + } + return Ok(value); + } + /// The rules the document gives `reference`, checked on the way in. + fn validate_reference<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `inline`, checked on the way in. + fn validate_inline<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `custom`, checked on the way in. + fn validate_custom<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `custom_allof`, checked on the way in. + fn validate_custom_allof<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `custom_chain`, checked on the way in. + fn validate_custom_chain<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The `default` the document gives `fallback`. + fn default_fallback() -> Nullable { + Nullable::Value("fallback".to_owned()) + } + /// The `default` the document gives `enum_default`. + fn default_enum_default() -> Nullable { + Nullable::Value(AccountEnumDefault::Active) + } + /// The rules the document gives `null_default`, checked on the way in. + fn validate_null_default<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Plain { + pub name: String, +} + +pub type PlainText = String; + +pub type CustomChain = CustomAllOf; + +pub type CustomAllOf = Nullable; + +pub type NullableText = Nullable; + +pub type TextAlias = NullableText; + +pub type WrappedText = TextAlias; + +pub type NullableCode = Nullable; + +pub type NullableState = Nullable; + +pub type Node = Nullable; + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct RequiredValues { + pub plain: String, + #[serde(deserialize_with = "RequiredValues::validate_text")] + pub text: WrappedText, + pub state: NullableState, + pub node: Node, + #[serde(deserialize_with = "RequiredValues::validate_bounded")] + pub bounded: Nullable, + pub defaulted: Nullable, +} +impl RequiredValues { + /// The rules the document gives `text`, checked on the way in. + fn validate_text<'de, D>( + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + { + let item = &value; + if let Some(item) = item.as_ref() { + if item.chars().nth(1usize).is_none() { + return Err( + serde::de::Error::custom("`text` must hold 2 or more characters"), + ); + } + } + } + return Ok(value); + } + /// The rules the document gives `bounded`, checked on the way in. + fn validate_bounded<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = as serde::Deserialize>::deserialize(deserializer)?; + { + let item = &value; + if let Some(item) = item.as_ref() { + if *item < 1 { + return Err(serde::de::Error::custom("`bounded` must be 1 or more")); + } + } + } + return Ok(value); + } +} + +pub type NullableUnion = Nullable; + +/// The request shape of `DirectionalValue`. A `readOnly` property is not part of it. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct DirectionalValueRequest { + pub secret: String, + pub value: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "DirectionalValueRequest::validate_next", + default + )] + pub next: Option>, +} +impl DirectionalValueRequest { + /// The rules the document gives `next`, checked on the way in. + fn validate_next<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +/// The response shape of `DirectionalValue`. A `writeOnly` property is not part of it. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct DirectionalValueResponse { + pub id: String, + pub value: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "DirectionalValueResponse::validate_next", + default + )] + pub next: Option>, +} +impl DirectionalValueResponse { + /// The rules the document gives `next`, checked on the way in. + fn validate_next<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum AccountPlainState { + #[serde(rename = "active")] + Active, + #[serde(rename = "inactive")] + Inactive, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct AccountInline { + pub name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum AccountEnumDefault { + #[serde(rename = "active")] + Active, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(try_from = "i64", into = "i64")] +#[repr(i64)] +pub enum NullableCodeValue { + Value1 = 1, + Value2 = 2, +} +impl From for i64 { + fn from(value: NullableCodeValue) -> Self { + return match value { + NullableCodeValue::Value1 => 1, + NullableCodeValue::Value2 => 2, + }; + } +} +impl TryFrom for NullableCodeValue { + type Error = String; + fn try_from(value: i64) -> Result { + return match value { + 1 => Ok(NullableCodeValue::Value1), + 2 => Ok(NullableCodeValue::Value2), + other => { + Err(format!("`{}` is not a value of `{}`", other, "NullableCodeValue")) + } + }; + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum NullableStateValue { + #[serde(rename = "active")] + Enabled, + #[serde(rename = "inactive")] + Disabled, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct NullableNodeValue { + pub name: String, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "NullableNodeValue::validate_child", + default + )] + pub child: Option>, +} +impl NullableNodeValue { + /// The rules the document gives `child`, checked on the way in. + fn validate_child<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( as serde::Deserialize>::deserialize(deserializer)?); + return Ok(value); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum NullableUnionValue { + String(String), + I64(i64), +} +impl<'de> serde::Deserialize<'de> for NullableUnionValue { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::String(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::I64(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } } diff --git a/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs b/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs index 0153c24..15b2dbb 100644 --- a/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs +++ b/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs @@ -14,9 +14,25 @@ #[serde(deny_unknown_fields)] pub struct Closed { pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Closed::validate_size", + default + )] pub size: Option, } +impl Closed { + /// The rules the document gives `size`, checked on the way in. + fn validate_size<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} /// An absent key permits an unknown key and drops it. #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] diff --git a/crates/oapi-codegen/tests/generated/object_nested_inline.rs b/crates/oapi-codegen/tests/generated/object_nested_inline.rs index dac5576..b936ce9 100644 --- a/crates/oapi-codegen/tests/generated/object_nested_inline.rs +++ b/crates/oapi-codegen/tests/generated/object_nested_inline.rs @@ -17,6 +17,22 @@ pub struct Order { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct OrderShipping { pub street: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "OrderShipping::validate_note", + default + )] pub note: Option, } +impl OrderShipping { + /// The rules the document gives `note`, checked on the way in. + fn validate_note<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/object_optional_required.rs b/crates/oapi-codegen/tests/generated/object_optional_required.rs index 3a663f2..6461dd3 100644 --- a/crates/oapi-codegen/tests/generated/object_optional_required.rs +++ b/crates/oapi-codegen/tests/generated/object_optional_required.rs @@ -12,6 +12,22 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Profile { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Profile::validate_nickname", + default + )] pub nickname: Option, } +impl Profile { + /// The rules the document gives `nickname`, checked on the way in. + fn validate_nickname<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/oneof_discriminator.rs b/crates/oapi-codegen/tests/generated/oneof_discriminator.rs index 1fda768..1029ddd 100644 --- a/crates/oapi-codegen/tests/generated/oneof_discriminator.rs +++ b/crates/oapi-codegen/tests/generated/oneof_discriminator.rs @@ -19,9 +19,43 @@ pub struct Bank { pub iban: String, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Payment { Card(Card), Bank(Bank), } +impl<'de> serde::Deserialize<'de> for Payment { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Card(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bank(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} diff --git a/crates/oapi-codegen/tests/generated/oneof_untagged.rs b/crates/oapi-codegen/tests/generated/oneof_untagged.rs index 5b10acc..f0d0de0 100644 --- a/crates/oapi-codegen/tests/generated/oneof_untagged.rs +++ b/crates/oapi-codegen/tests/generated/oneof_untagged.rs @@ -19,9 +19,43 @@ pub struct Dog { pub bark: String, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Pet { Cat(Cat), Dog(Dog), } +impl<'de> serde::Deserialize<'de> for Pet { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Cat(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Dog(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} diff --git a/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs b/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs index 60bdf74..5e5414c 100644 --- a/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs +++ b/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs @@ -9,7 +9,7 @@ reason = "generated code, not first-party source" )] -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Scalars { String(String), @@ -18,49 +18,313 @@ pub enum Scalars { F64(f64), Bool(bool), } +impl<'de> serde::Deserialize<'de> for Scalars { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::String(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::I32(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::I64(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::F64(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Formats { Date(chrono::NaiveDate), DateTime(chrono::DateTime), Uuid(uuid::Uuid), } +impl<'de> serde::Deserialize<'de> for Formats { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Date(payload)); + } + if let ::std::result::Result::Ok(payload) = as serde::Deserialize>::deserialize(&value) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::DateTime(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Uuid(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Named { Cat(NamedCat), Dog(NamedDog), } +impl<'de> serde::Deserialize<'de> for Named { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Cat(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Dog(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Widget { pub sku: String, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Referenced { Widget(Widget), Tally(Vec), } +impl<'de> serde::Deserialize<'de> for Referenced { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Widget(payload)); + } + if let ::std::result::Result::Ok(payload) = as serde::Deserialize>::deserialize(&value) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Tally(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Mixed { String(String), Widget(Widget), Detail(MixedDetail), } +impl<'de> serde::Deserialize<'de> for Mixed { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::String(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Widget(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Detail(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Signal { Red(SignalRed), Amber(SignalAmber), Other(SignalOther), } +impl<'de> serde::Deserialize<'de> for Signal { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Red(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Amber(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Other(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct NamedCat { diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable.rs new file mode 100644 index 0000000..9735f31 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_inline_nullable/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_inline_nullable/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_inline_nullable/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_inline_nullable/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client.rs new file mode 100644 index 0000000..174ed99 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client.rs @@ -0,0 +1,100 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/array.rs"] +mod array; +#[path = "client/scalar.rs"] +mod scalar; +#[path = "client/map.rs"] +mod map; +#[path = "client/alias.rs"] +mod alias; + +/// Errors returned by the generated client. +#[derive(Debug)] +pub enum ClientError { + /// The `reqwest` request failed to send or complete, including any + /// body decoding `reqwest` performs internally (such as JSON). + Http(reqwest::Error), + /// The server returned a status code the operation does not declare. + UnexpectedStatus(reqwest::StatusCode), + /// The response `Content-Type` matched none of the representations the + /// operation declares for its status. + UnexpectedContentType(String), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + Decode(String), +} +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Http(error) => return write!(f, "HTTP request failed: {error}"), + ClientError::UnexpectedStatus(status) => { + return write!(f, "unexpected response status: {status}"); + } + ClientError::UnexpectedContentType(content_type) => { + return write!(f, "unexpected response content type: {content_type}"); + } + ClientError::Decode(message) => { + return write!(f, "failed to decode response: {message}"); + } + } + } +} +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Http(error) => return Some(error), + ClientError::UnexpectedStatus(_) + | ClientError::UnexpectedContentType(_) + | ClientError::Decode(_) => return None, + } + } +} +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + return ClientError::Http(error); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `https://api.example.com`). +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + http: reqwest::blocking::Client, +} + +impl Client { + /// Build a client targeting `base_url` with a default blocking + /// `reqwest::blocking::Client`. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::blocking::Client::builder().build()?; + return Ok(Self { + base_url: base_url.into(), + http, + }); + } + /// Build a client targeting `base_url` with a caller-provided + /// `reqwest::blocking::Client` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/alias.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/alias.rs new file mode 100644 index 0000000..6041eda --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/alias.rs @@ -0,0 +1,29 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn alias(&self, body: WrappedText) -> Result { + let url = format!("{}/alias", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: NullableText = response.json()?; + return Ok(AliasResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/array.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/array.rs new file mode 100644 index 0000000..f069705 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/array.rs @@ -0,0 +1,32 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn array( + &self, + body: Nullable>>, + ) -> Result { + let url = format!("{}/array", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable>> = response.json()?; + return Ok(ArrayResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/map.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/map.rs new file mode 100644 index 0000000..81c3251 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/map.rs @@ -0,0 +1,33 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn map( + &self, + body: Nullable>>, + ) -> Result { + let url = format!("{}/map", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable>> = response + .json()?; + return Ok(MapResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/scalar.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/scalar.rs new file mode 100644 index 0000000..1a173bc --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/client/scalar.rs @@ -0,0 +1,29 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn scalar(&self, body: Nullable) -> Result { + let url = format!("{}/scalar", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable = response.json()?; + return Ok(ScalarResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/models.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/models.rs new file mode 100644 index 0000000..3b112d5 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/models.rs @@ -0,0 +1,84 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + +pub type NullableText = Nullable; + +pub type TextAlias = NullableText; + +pub type WrappedText = TextAlias; diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations.rs new file mode 100644 index 0000000..3075d27 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/array.rs"] +mod array; +pub use array::*; +#[path = "operations/scalar.rs"] +mod scalar; +pub use scalar::*; +#[path = "operations/map.rs"] +mod map; +pub use map::*; +#[path = "operations/alias.rs"] +mod alias; +pub use alias::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/alias.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/alias.rs new file mode 100644 index 0000000..0940104 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/alias.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum AliasResponse { + /// Result + Ok(NullableText), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/array.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/array.rs new file mode 100644 index 0000000..53dbb9b --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/array.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum ArrayResponse { + /// Result + Ok(Nullable>>), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/map.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/map.rs new file mode 100644 index 0000000..89559a8 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/map.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum MapResponse { + /// Result + Ok(Nullable>>), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/scalar.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/scalar.rs new file mode 100644 index 0000000..43c4d22 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/operations/scalar.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum ScalarResponse { + /// Result + Ok(Nullable), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server.rs new file mode 100644 index 0000000..6b113b5 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server.rs @@ -0,0 +1,56 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/array.rs"] +mod array; +use array::array_handler; +#[path = "server/scalar.rs"] +mod scalar; +use scalar::scalar_handler; +#[path = "server/map.rs"] +mod map; +use map::map_handler; +#[path = "server/alias.rs"] +mod alias; +use alias::alias_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn array( + &self, + body: Nullable>>, + ) -> impl std::future::Future + Send; + fn scalar( + &self, + body: Nullable, + ) -> impl std::future::Future + Send; + fn map( + &self, + body: Nullable>>, + ) -> impl std::future::Future + Send; + fn alias( + &self, + body: WrappedText, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/array", axum::routing::post(array_handler::)) + .route("/scalar", axum::routing::post(scalar_handler::)) + .route("/map", axum::routing::post(map_handler::)) + .route("/alias", axum::routing::post(alias_handler::)) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/alias.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/alias.rs new file mode 100644 index 0000000..33a0ba7 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/alias.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for AliasResponse { + fn into_response(self) -> axum::response::Response { + match self { + AliasResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn alias_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> AliasResponse { + api.alias(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/array.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/array.rs new file mode 100644 index 0000000..320e1b3 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/array.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ArrayResponse { + fn into_response(self) -> axum::response::Response { + match self { + ArrayResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn array_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>>>, +) -> ArrayResponse { + api.array(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/map.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/map.rs new file mode 100644 index 0000000..6ea1e38 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/map.rs @@ -0,0 +1,39 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for MapResponse { + fn into_response(self) -> axum::response::Response { + match self { + MapResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn map_handler( + axum::extract::State(api): axum::extract::State, + axum::Json( + body, + ): axum::Json>>>, +) -> MapResponse { + api.map(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/scalar.rs b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/scalar.rs new file mode 100644 index 0000000..7a181cd --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_inline_nullable/server/scalar.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ScalarResponse { + fn into_response(self) -> axum::response::Response { + match self { + ScalarResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn scalar_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>, +) -> ScalarResponse { + api.scalar(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs index de4e933..29ef539 100644 --- a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs @@ -11,25 +11,89 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Ok { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ok::validate_id", + default + )] pub id: Option, } +impl Ok { + /// The rules the document gives `id`, checked on the way in. + fn validate_id<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Err { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Err::validate_message", + default + )] pub message: Option, } +impl Err { + /// The rules the document gives `message`, checked on the way in. + fn validate_message<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Some { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Some::validate_value", + default + )] pub value: Option, } +impl Some { + /// The rules the document gives `value`, checked on the way in. + fn validate_value<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct None { pub note: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "None::validate_holder", + default + )] pub holder: Option, } +impl None { + /// The rules the document gives `holder`, checked on the way in. + fn validate_holder<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs index 57c52d0..051c1e9 100644 --- a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs @@ -13,9 +13,25 @@ use super::super::models::*; #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct ProbeQuery { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ProbeQuery::validate_maybe", + default + )] pub maybe: Option, } +impl ProbeQuery { + /// The rules the document gives `maybe`, checked on the way in. + fn validate_maybe<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(Debug, Clone, PartialEq)] pub enum ProbeResponse { diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs index ee411d0..9f11fc4 100644 --- a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs @@ -12,6 +12,22 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct GetWidgetResponse { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "GetWidgetResponse::validate_label", + default + )] pub label: Option, } +impl GetWidgetResponse { + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs index 5331dbb..18b539f 100644 --- a/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs @@ -13,6 +13,22 @@ pub struct Order { pub id: String, pub total: f64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Order::validate_note", + default + )] pub note: Option, } +impl Order { + /// The rules the document gives `note`, checked on the way in. + fn validate_note<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs index b21dcff..59ee605 100644 --- a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs @@ -15,4 +15,133 @@ pub type Opaque = crate::restricted::Opaque; pub struct Thing { pub id: String, pub opaque: Opaque, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Thing::validate_exclusive", + default + )] + pub exclusive: Option, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Thing::validate_inclusive", + default + )] + pub inclusive: Option, +} +impl Thing { + /// The rules the document gives `exclusive`, checked on the way in. + fn validate_exclusive<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `inclusive`, checked on the way in. + fn validate_inclusive<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +#[derive(serde::Serialize, Debug)] +#[serde(untagged)] +pub enum ThingExclusive { + Opaque(Opaque), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for ThingExclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Opaque(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct ThingInclusive { + value: serde_json::Value, +} +impl ThingInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Opaque` Rust alternative. + pub fn as_opaque(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for ThingInclusive { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for ThingInclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } } diff --git a/crates/oapi-codegen/tests/generated/package_nullable.rs b/crates/oapi-codegen/tests/generated/package_nullable.rs new file mode 100644 index 0000000..f586daf --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_nullable/models.rs"] +mod models; +pub use models::*; +#[path = "package_nullable/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_nullable/server.rs"] +mod server; +pub use server::*; +#[path = "package_nullable/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_nullable/client.rs b/crates/oapi-codegen/tests/generated/package_nullable/client.rs new file mode 100644 index 0000000..2c363ce --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/client.rs @@ -0,0 +1,98 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/parity.rs"] +mod parity; +#[path = "client/wrapped.rs"] +mod wrapped; +#[path = "client/exchange.rs"] +mod exchange; + +/// Errors returned by the generated client. +#[derive(Debug)] +pub enum ClientError { + /// The `reqwest` request failed to send or complete, including any + /// body decoding `reqwest` performs internally (such as JSON). + Http(reqwest::Error), + /// The server returned a status code the operation does not declare. + UnexpectedStatus(reqwest::StatusCode), + /// The response `Content-Type` matched none of the representations the + /// operation declares for its status. + UnexpectedContentType(String), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + Decode(String), +} +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Http(error) => return write!(f, "HTTP request failed: {error}"), + ClientError::UnexpectedStatus(status) => { + return write!(f, "unexpected response status: {status}"); + } + ClientError::UnexpectedContentType(content_type) => { + return write!(f, "unexpected response content type: {content_type}"); + } + ClientError::Decode(message) => { + return write!(f, "failed to decode response: {message}"); + } + } + } +} +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Http(error) => return Some(error), + ClientError::UnexpectedStatus(_) + | ClientError::UnexpectedContentType(_) + | ClientError::Decode(_) => return None, + } + } +} +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + return ClientError::Http(error); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `https://api.example.com`). +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + http: reqwest::blocking::Client, +} + +impl Client { + /// Build a client targeting `base_url` with a default blocking + /// `reqwest::blocking::Client`. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::blocking::Client::builder().build()?; + return Ok(Self { + base_url: base_url.into(), + http, + }); + } + /// Build a client targeting `base_url` with a caller-provided + /// `reqwest::blocking::Client` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/client/exchange.rs b/crates/oapi-codegen/tests/generated/package_nullable/client/exchange.rs new file mode 100644 index 0000000..27c3120 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/client/exchange.rs @@ -0,0 +1,32 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn exchange( + &self, + body: DirectionalAliasRequest, + ) -> Result { + let url = format!("{}/direction", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: DirectionalAliasResponse = response.json()?; + return Ok(ExchangeResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/client/parity.rs b/crates/oapi-codegen/tests/generated/package_nullable/client/parity.rs new file mode 100644 index 0000000..fb57778 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/client/parity.rs @@ -0,0 +1,32 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn parity( + &self, + body: Nullable>>, + ) -> Result { + let url = format!("{}/parity", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: NullableArray = response.json()?; + return Ok(ParityResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/client/wrapped.rs b/crates/oapi-codegen/tests/generated/package_nullable/client/wrapped.rs new file mode 100644 index 0000000..9a86a0a --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/client/wrapped.rs @@ -0,0 +1,32 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn wrapped( + &self, + body: Nullable, + ) -> Result { + let url = format!("{}/wrapped", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Nullable = response.json()?; + return Ok(WrappedResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/models.rs b/crates/oapi-codegen/tests/generated/package_nullable/models.rs new file mode 100644 index 0000000..429b842 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/models.rs @@ -0,0 +1,151 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + +/// The request shape of `DirectionalAlias`. +pub type DirectionalAliasRequest = DirectionalRequest; + +/// The response shape of `DirectionalAlias`. +pub type DirectionalAliasResponse = DirectionalResponse; + +pub type NullableArray = Nullable>>; + +/// The request shape of `Directional`. +pub type DirectionalRequest = Nullable; + +/// The response shape of `Directional`. +pub type DirectionalResponse = Nullable; + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Plain { + pub name: String, +} + +/// The request shape of `DirectionalValue`. A `readOnly` property is not part of it. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct DirectionalValueRequest { + pub secret: String, + pub value: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "DirectionalValueRequest::validate_next", + default + )] + pub next: Option>, +} +impl DirectionalValueRequest { + /// The rules the document gives `next`, checked on the way in. + fn validate_next<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} + +/// The response shape of `DirectionalValue`. A `writeOnly` property is not part of it. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct DirectionalValueResponse { + pub id: String, + pub value: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "DirectionalValueResponse::validate_next", + default + )] + pub next: Option>, +} +impl DirectionalValueResponse { + /// The rules the document gives `next`, checked on the way in. + fn validate_next<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/operations.rs b/crates/oapi-codegen/tests/generated/package_nullable/operations.rs new file mode 100644 index 0000000..e5e0545 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/operations.rs @@ -0,0 +1,20 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/parity.rs"] +mod parity; +pub use parity::*; +#[path = "operations/wrapped.rs"] +mod wrapped; +pub use wrapped::*; +#[path = "operations/exchange.rs"] +mod exchange; +pub use exchange::*; diff --git a/crates/oapi-codegen/tests/generated/package_nullable/operations/exchange.rs b/crates/oapi-codegen/tests/generated/package_nullable/operations/exchange.rs new file mode 100644 index 0000000..b52ae77 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/operations/exchange.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum ExchangeResponse { + /// Result + Ok(DirectionalAliasResponse), +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/operations/parity.rs b/crates/oapi-codegen/tests/generated/package_nullable/operations/parity.rs new file mode 100644 index 0000000..afc7f90 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/operations/parity.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum ParityResponse { + /// Result + Ok(NullableArray), +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/operations/wrapped.rs b/crates/oapi-codegen/tests/generated/package_nullable/operations/wrapped.rs new file mode 100644 index 0000000..22c207a --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/operations/wrapped.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum WrappedResponse { + /// Result + Ok(Nullable), +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/server.rs b/crates/oapi-codegen/tests/generated/package_nullable/server.rs new file mode 100644 index 0000000..82386da --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/server.rs @@ -0,0 +1,48 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/parity.rs"] +mod parity; +use parity::parity_handler; +#[path = "server/wrapped.rs"] +mod wrapped; +use wrapped::wrapped_handler; +#[path = "server/exchange.rs"] +mod exchange; +use exchange::exchange_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn parity( + &self, + body: Nullable>>, + ) -> impl std::future::Future + Send; + fn wrapped( + &self, + body: Nullable, + ) -> impl std::future::Future + Send; + fn exchange( + &self, + body: DirectionalAliasRequest, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/parity", axum::routing::post(parity_handler::)) + .route("/wrapped", axum::routing::post(wrapped_handler::)) + .route("/direction", axum::routing::post(exchange_handler::)) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/server/exchange.rs b/crates/oapi-codegen/tests/generated/package_nullable/server/exchange.rs new file mode 100644 index 0000000..e02d94d --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/server/exchange.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ExchangeResponse { + fn into_response(self) -> axum::response::Response { + match self { + ExchangeResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn exchange_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> ExchangeResponse { + api.exchange(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/server/parity.rs b/crates/oapi-codegen/tests/generated/package_nullable/server/parity.rs new file mode 100644 index 0000000..7e6dcfe --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/server/parity.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ParityResponse { + fn into_response(self) -> axum::response::Response { + match self { + ParityResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn parity_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>>>, +) -> ParityResponse { + api.parity(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_nullable/server/wrapped.rs b/crates/oapi-codegen/tests/generated/package_nullable/server/wrapped.rs new file mode 100644 index 0000000..7132004 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_nullable/server/wrapped.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for WrappedResponse { + fn into_response(self) -> axum::response::Response { + match self { + WrappedResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn wrapped_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json>, +) -> WrappedResponse { + api.wrapped(body).await +} diff --git a/crates/oapi-codegen/tests/generated/prelude_result_name.rs b/crates/oapi-codegen/tests/generated/prelude_result_name.rs index 8c9cb6e..3aa980a 100644 --- a/crates/oapi-codegen/tests/generated/prelude_result_name.rs +++ b/crates/oapi-codegen/tests/generated/prelude_result_name.rs @@ -11,6 +11,22 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Result { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Result::validate_state", + default + )] pub state: Option, } +impl Result { + /// The rules the document gives `state`, checked on the way in. + fn validate_state<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/prelude_value_names.rs b/crates/oapi-codegen/tests/generated/prelude_value_names.rs index e2ef620..bc637ac 100644 --- a/crates/oapi-codegen/tests/generated/prelude_value_names.rs +++ b/crates/oapi-codegen/tests/generated/prelude_value_names.rs @@ -11,33 +11,129 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Ok { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Ok::validate_id", + default + )] pub id: Option, } +impl Ok { + /// The rules the document gives `id`, checked on the way in. + fn validate_id<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Err { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Err::validate_message", + default + )] pub message: Option, } +impl Err { + /// The rules the document gives `message`, checked on the way in. + fn validate_message<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Some { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Some::validate_value", + default + )] pub value: Option, } +impl Some { + /// The rules the document gives `value`, checked on the way in. + fn validate_value<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct None { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "None::validate_note", + default + )] pub note: Option, } +impl None { + /// The rules the document gives `note`, checked on the way in. + fn validate_note<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Holder { pub certain: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Holder::validate_maybe", + default + )] pub maybe: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Holder::validate_many", + default + )] pub many: Option>, } +impl Holder { + /// The rules the document gives `maybe`, checked on the way in. + fn validate_maybe<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `many`, checked on the way in. + fn validate_many<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/read_write_only.rs b/crates/oapi-codegen/tests/generated/read_write_only.rs index f1bba31..36092a5 100644 --- a/crates/oapi-codegen/tests/generated/read_write_only.rs +++ b/crates/oapi-codegen/tests/generated/read_write_only.rs @@ -27,11 +27,32 @@ pub struct AccountRequest { pub struct AccountResponse { /// Assigned by the server, so only a response carries it. pub id: uuid::Uuid, - #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "createdAt", + skip_serializing_if = "Option::is_none", + deserialize_with = "AccountResponse::validate_created_at", + default + )] pub created_at: Option>, pub email: String, pub nickname: String, } +impl AccountResponse { + /// The rules the document gives `created_at`, checked on the way in. + fn validate_created_at<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} /// A holder splits too, because its field type differs per direction. /// @@ -39,9 +60,27 @@ pub struct AccountResponse { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct EnvelopeRequest { pub account: AccountRequest, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "EnvelopeRequest::validate_accounts", + default + )] pub accounts: Option>, } +impl EnvelopeRequest { + /// The rules the document gives `accounts`, checked on the way in. + fn validate_accounts<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} /// A holder splits too, because its field type differs per direction. /// @@ -49,9 +88,27 @@ pub struct EnvelopeRequest { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct EnvelopeResponse { pub account: AccountResponse, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "EnvelopeResponse::validate_accounts", + default + )] pub accounts: Option>, } +impl EnvelopeResponse { + /// The rules the document gives `accounts`, checked on the way in. + fn validate_accounts<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} /// An alias to a split model splits as well. /// @@ -66,6 +123,22 @@ pub type AccountListResponse = Vec; /// No mark reaches this schema, so it keeps its name. #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Untouched { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Untouched::validate_label", + default + )] pub label: Option, } +impl Untouched { + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/crates/oapi-codegen/tests/generated/recursive_schema.rs b/crates/oapi-codegen/tests/generated/recursive_schema.rs index 214a5d8..5576fd7 100644 --- a/crates/oapi-codegen/tests/generated/recursive_schema.rs +++ b/crates/oapi-codegen/tests/generated/recursive_schema.rs @@ -18,21 +18,79 @@ pub struct Node { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Comment { pub text: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Comment::validate_reply", + default + )] pub reply: Option>, } +impl Comment { + /// The rules the document gives `reply`, checked on the way in. + fn validate_reply<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Tree { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Tree::validate_children", + default + )] pub children: Option>, } +impl Tree { + /// The rules the document gives `children`, checked on the way in. + fn validate_children<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( as serde::Deserialize>::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Registry { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Registry::validate_entries", + default + )] pub entries: Option>, } +impl Registry { + /// The rules the document gives `entries`, checked on the way in. + fn validate_entries<'de, D>( + deserializer: D, + ) -> ::core::result::Result< + Option>, + D::Error, + > + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Parent { @@ -44,11 +102,45 @@ pub struct Kid { pub parent: Box, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Expression { String(String), - Expression(Box), + Nested(Box), +} +impl<'de> serde::Deserialize<'de> for Expression { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::String(payload)); + } + if let ::std::result::Result::Ok(payload) = as serde::Deserialize>::deserialize(&value) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Nested(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } } pub type Wrapper = Holder; @@ -57,3 +149,8 @@ pub type Wrapper = Holder; pub struct Holder { pub wrapped: Box, } + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct ExpressionNested { + pub nested: Box, +} diff --git a/crates/oapi-codegen/tests/generated/schema_defaults.rs b/crates/oapi-codegen/tests/generated/schema_defaults.rs index cb687a1..c8919f7 100644 --- a/crates/oapi-codegen/tests/generated/schema_defaults.rs +++ b/crates/oapi-codegen/tests/generated/schema_defaults.rs @@ -9,6 +9,74 @@ reason = "generated code, not first-party source" )] +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum Status { #[serde(rename = "in-progress")] @@ -37,11 +105,19 @@ pub struct Widget { pub attributes: std::collections::HashMap, #[serde(default = "Widget::default_state")] pub state: WidgetState, - #[serde(skip_serializing_if = "Option::is_none", default = "Widget::default_note")] - pub note: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cleared: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "Widget::default_note")] + pub note: Nullable, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Widget::validate_cleared", + default + )] + pub cleared: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Widget::validate_extra", + default + )] pub extra: Option, } impl Widget { @@ -78,8 +154,30 @@ impl Widget { WidgetState::InProgress } /// The `default` the document gives `note`. - fn default_note() -> Option { - Some("none given".to_owned()) + fn default_note() -> Nullable { + Nullable::Value("none given".to_owned()) + } + /// The rules the document gives `cleared`, checked on the way in. + fn validate_cleared<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `extra`, checked on the way in. + fn validate_extra<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); } } diff --git a/crates/oapi-codegen/tests/generated/server_component_param_ref.rs b/crates/oapi-codegen/tests/generated/server_component_param_ref.rs index aa1041a..6519663 100644 --- a/crates/oapi-codegen/tests/generated/server_component_param_ref.rs +++ b/crates/oapi-codegen/tests/generated/server_component_param_ref.rs @@ -11,9 +11,25 @@ #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct GetWidgetQuery { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "GetWidgetQuery::validate_verbose", + default + )] pub verbose: Option, } +impl GetWidgetQuery { + /// The rules the document gives `verbose`, checked on the way in. + fn validate_verbose<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(Debug, Clone)] pub struct GetWidgetHeaders { diff --git a/crates/oapi-codegen/tests/generated/server_multi_content_request.rs b/crates/oapi-codegen/tests/generated/server_multi_content_request.rs index 5e8de9e..1465605 100644 --- a/crates/oapi-codegen/tests/generated/server_multi_content_request.rs +++ b/crates/oapi-codegen/tests/generated/server_multi_content_request.rs @@ -12,9 +12,25 @@ #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct Widget { pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Widget::validate_weight", + default + )] pub weight: Option, } +impl Widget { + /// The rules the document gives `weight`, checked on the way in. + fn validate_weight<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(Debug, Clone, PartialEq)] pub enum CreateWidgetRequestBody { diff --git a/crates/oapi-codegen/tests/generated/server_petstore.rs b/crates/oapi-codegen/tests/generated/server_petstore.rs index 3eb574b..cf08049 100644 --- a/crates/oapi-codegen/tests/generated/server_petstore.rs +++ b/crates/oapi-codegen/tests/generated/server_petstore.rs @@ -20,9 +20,25 @@ pub struct Pet { #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct NewPet { pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "NewPet::validate_tag", + default + )] pub tag: Option, } +impl NewPet { + /// The rules the document gives `tag`, checked on the way in. + fn validate_tag<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, Debug, Clone, PartialEq)] pub struct Error { diff --git a/crates/oapi-codegen/tests/generated/server_query_params.rs b/crates/oapi-codegen/tests/generated/server_query_params.rs index 07c90c3..87be585 100644 --- a/crates/oapi-codegen/tests/generated/server_query_params.rs +++ b/crates/oapi-codegen/tests/generated/server_query_params.rs @@ -20,23 +20,84 @@ pub struct ListBooksQuery { /// Region filter; the operation-level definition wins. pub region: String, /// Restrict results to a single author. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_author", + default + )] pub author: Option, #[serde(default = "ListBooksQuery::default_limit")] pub limit: i32, pub available: bool, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_tags", + default + )] pub tags: Option>, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_status", + default + )] pub status: Option>, - #[serde(rename = "pageToken", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "pageToken", + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_page_token", + default + )] pub page_token: Option, } impl ListBooksQuery { + /// The rules the document gives `author`, checked on the way in. + fn validate_author<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } /// The `default` the document gives `limit`. fn default_limit() -> i32 { 20 } + /// The rules the document gives `tags`, checked on the way in. + fn validate_tags<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `status`, checked on the way in. + fn validate_status<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `page_token`, checked on the way in. + fn validate_page_token<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } } /// List books, filtered by query parameters. diff --git a/crates/oapi-codegen/tests/generated/server_union_traits.rs b/crates/oapi-codegen/tests/generated/server_union_traits.rs new file mode 100644 index 0000000..d114ad2 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/server_union_traits.rs @@ -0,0 +1,186 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +pub type Incoming = crate::restricted::DeserializeOnly; + +pub type Outgoing = crate::restricted::SerializeOnly; + +#[derive(serde::Deserialize)] +pub struct Input { + pub exclusive: InputExclusive, + pub inclusive: InputInclusive, +} + +#[derive(serde::Serialize)] +pub struct Output { + pub exclusive: OutputExclusive, + pub inclusive: OutputInclusive, +} + +pub enum InputExclusive { + Incoming(Incoming), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for InputExclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Incoming(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct InputInclusive { + value: serde_json::Value, +} +impl InputInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Incoming` Rust alternative. + pub fn as_incoming(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for InputInclusive { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for InputInclusive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +#[derive(serde::Serialize)] +#[serde(untagged)] +pub enum OutputExclusive { + Outgoing(Outgoing), + Bool(bool), +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct OutputInclusive { + value: serde_json::Value, +} +impl OutputInclusive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } +} +/// Construct a raw JSON wrapper without alternative checks. +impl ::std::convert::From for OutputInclusive { + fn from(value: serde_json::Value) -> Self { + return Self { value }; + } +} + +pub enum ExchangeResponse { + /// The result. + Ok(Output), +} + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn exchange( + &self, + body: Input, + ) -> impl std::future::Future + Send; +} + +impl axum::response::IntoResponse for ExchangeResponse { + fn into_response(self) -> axum::response::Response { + match self { + ExchangeResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/exchange", axum::routing::post(exchange_handler::)) + .with_state(api) +} + +async fn exchange_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> ExchangeResponse { + api.exchange(body).await +} diff --git a/crates/oapi-codegen/tests/generated/server_xfile_refs.rs b/crates/oapi-codegen/tests/generated/server_xfile_refs.rs index ea116b8..e426d43 100644 --- a/crates/oapi-codegen/tests/generated/server_xfile_refs.rs +++ b/crates/oapi-codegen/tests/generated/server_xfile_refs.rs @@ -12,9 +12,26 @@ #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct ListThingsQuery { /// Maximum number of items to return. - #[serde(rename = "pageSize", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "pageSize", + skip_serializing_if = "Option::is_none", + deserialize_with = "ListThingsQuery::validate_page_size", + default + )] pub page_size: Option, } +impl ListThingsQuery { + /// The rules the document gives `page_size`, checked on the way in. + fn validate_page_size<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} /// List things. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/oapi-codegen/tests/generated/type_name_collisions.rs b/crates/oapi-codegen/tests/generated/type_name_collisions.rs index ca5af4c..81e173e 100644 --- a/crates/oapi-codegen/tests/generated/type_name_collisions.rs +++ b/crates/oapi-codegen/tests/generated/type_name_collisions.rs @@ -11,28 +11,112 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct OrderItem { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "OrderItem::validate_sku", + default + )] pub sku: Option, } +impl OrderItem { + /// The rules the document gives `sku`, checked on the way in. + fn validate_sku<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct OrderItemQuantity { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "OrderItemQuantity::validate_quantity", + default + )] pub quantity: Option, } +impl OrderItemQuantity { + /// The rules the document gives `quantity`, checked on the way in. + fn validate_quantity<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Cart { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Cart::validate_item", + default + )] pub item: Option, } +impl Cart { + /// The rules the document gives `item`, checked on the way in. + fn validate_item<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + ::deserialize(deserializer)?, + ); + return Ok(value); + } +} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[derive(serde::Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum Reference { OrderItem(OrderItem), OrderItemQuantity(OrderItemQuantity), } +impl<'de> serde::Deserialize<'de> for Reference { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::OrderItem(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::OrderItemQuantity(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum Stage { diff --git a/crates/oapi-codegen/tests/generated/union_duplicate.rs b/crates/oapi-codegen/tests/generated/union_duplicate.rs new file mode 100644 index 0000000..f8c620c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/union_duplicate.rs @@ -0,0 +1,56 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Cat { + pub meow: String, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Pet { + Cat(Cat), + Cat2(Cat), +} +impl<'de> serde::Deserialize<'de> for Pet { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Cat(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Cat2(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} diff --git a/crates/oapi-codegen/tests/generated/union_prelude_names.rs b/crates/oapi-codegen/tests/generated/union_prelude_names.rs new file mode 100644 index 0000000..d73462c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/union_prelude_names.rs @@ -0,0 +1,105 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +pub type TryFrom = String; + +pub type Result = String; + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum PreludeOne { + TryFrom(TryFrom), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for PreludeOne { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::TryFrom(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct PreludeAny { + value: serde_json::Value, +} +impl PreludeAny { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Result` Rust alternative. + pub fn as_result(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Bool` Rust alternative. + pub fn as_bool(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for PreludeAny { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for PreludeAny { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} diff --git a/crates/oapi-codegen/tests/generated/union_semantics.rs b/crates/oapi-codegen/tests/generated/union_semantics.rs new file mode 100644 index 0000000..263b1e1 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/union_semantics.rs @@ -0,0 +1,674 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Numeric { + I64(i64), + F64(f64), +} +impl<'de> serde::Deserialize<'de> for Numeric { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::I64(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::F64(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +pub type Low = u64; + +pub type High = u64; + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Ranges { + Low(Low), + High(High), +} +impl<'de> serde::Deserialize<'de> for Ranges { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Low(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::High(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum Choice { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum OtherChoice { + #[serde(rename = "blue")] + Blue, + #[serde(rename = "green")] + Green, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Colors { + Choice(Choice), + OtherChoice(OtherChoice), +} +impl<'de> serde::Deserialize<'de> for Colors { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Choice(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::OtherChoice(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Left { + pub left: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Right { + pub right: String, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Objects { + Left(Left), + Right(Right), +} +impl<'de> serde::Deserialize<'de> for Objects { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Left(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Right(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct RawObjects { + value: serde_json::Value, +} +impl RawObjects { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Left` Rust alternative. + pub fn as_left(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Right` Rust alternative. + pub fn as_right(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for RawObjects { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for RawObjects { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Closed { + pub left: String, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum ClosedChoice { + Closed(Closed), + Right(Right), +} +impl<'de> serde::Deserialize<'de> for ClosedChoice { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Closed(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Right(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Nested { + Colors(Colors), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for Nested { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Colors(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum NestedAny { + RawObjects(RawObjects), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for NestedAny { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::RawObjects(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct DuplicateAny { + value: serde_json::Value, +} +impl DuplicateAny { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Left` Rust alternative. + pub fn as_left(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Left2` Rust alternative. + pub fn as_left2(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for DuplicateAny { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for DuplicateAny { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Discriminated { + Chosen(Left), + Right(Right), +} +impl<'de> serde::Deserialize<'de> for Discriminated { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Chosen(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Right(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Merged { + pub left: String, + pub tag: MergedTag, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Composed { + Merged(Merged), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for Composed { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Merged(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Containers { + pub choice: ContainersChoice, + pub raw: ContainersRaw, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct Recursive { + value: serde_json::Value, +} +impl Recursive { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `String` Rust alternative. + pub fn as_string(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `List` Rust alternative. + pub fn as_list(&self) -> ::std::result::Result, serde_json::Error> { + return as serde::Deserialize>::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for Recursive { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || as serde::Deserialize>::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for Recursive { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum MergedTag { + #[serde(rename = "tagged")] + Tagged, +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum ContainersChoice { + String(String), + Bool(bool), +} +impl<'de> serde::Deserialize<'de> for ContainersChoice { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + let mut selected = ::std::option::Option::None; + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::String(payload)); + } + if let ::std::result::Result::Ok(payload) = ::deserialize( + &value, + ) { + if selected.is_some() { + return ::std::result::Result::Err( + serde::de::Error::custom("oneOf matched multiple Rust alternatives"), + ); + } + selected = ::std::option::Option::Some(Self::Bool(payload)); + } + return selected + .ok_or_else(|| serde::de::Error::custom( + "oneOf matched no Rust alternative", + )); + } +} + +#[derive(serde::Serialize, Debug, Clone, PartialEq)] +#[serde(transparent)] +pub struct ContainersRaw { + value: serde_json::Value, +} +impl ContainersRaw { + /// Borrow the complete JSON value. + pub fn as_value(&self) -> &serde_json::Value { + return &self.value; + } + /// Consume the wrapper and return the complete JSON value. + pub fn into_value(self) -> serde_json::Value { + return self.value; + } + ///Decode the `Left` Rust alternative. + pub fn as_left(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } + ///Decode the `Right` Rust alternative. + pub fn as_right(&self) -> ::std::result::Result { + return ::deserialize(&self.value); + } +} +impl ::std::convert::TryFrom for ContainersRaw { + type Error = serde_json::Error; + fn try_from(value: serde_json::Value) -> ::std::result::Result { + if !(::deserialize(&value).is_ok() + || ::deserialize(&value).is_ok()) + { + return ::std::result::Result::Err( + ::custom( + "anyOf matched no Rust alternative", + ), + ); + } + return ::std::result::Result::Ok(Self { value }); + } +} +impl<'de> serde::Deserialize<'de> for ContainersRaw { + fn deserialize<__Deserializer: serde::Deserializer<'de>>( + deserializer: __Deserializer, + ) -> ::std::result::Result { + let value = ::deserialize( + deserializer, + )?; + return >::try_from(value) + .map_err(serde::de::Error::custom); + } +} diff --git a/crates/oapi-codegen/tests/generated/value_constraints.rs b/crates/oapi-codegen/tests/generated/value_constraints.rs index e5ec967..631f253 100644 --- a/crates/oapi-codegen/tests/generated/value_constraints.rs +++ b/crates/oapi-codegen/tests/generated/value_constraints.rs @@ -226,12 +226,17 @@ impl Account { where D: serde::Deserializer<'de>, { - let value = as serde::Deserialize>::deserialize(deserializer)?; - if let Some(item) = value.as_ref() { - if item.chars().nth(10usize).is_some() { - return Err( - serde::de::Error::custom("`note` must hold 10 or fewer characters"), - ); + let value = Some(::deserialize(deserializer)?); + { + let item = &value; + if let Some(item) = item.as_ref() { + if item.chars().nth(10usize).is_some() { + return Err( + serde::de::Error::custom( + "`note` must hold 10 or fewer characters", + ), + ); + } } } return Ok(value); @@ -243,17 +248,24 @@ impl Account { where D: serde::Deserializer<'de>, { - let value = as serde::Deserialize>::deserialize(deserializer)?; - if let Some(item) = value.as_ref() { - if item.chars().nth(2usize).is_none() { - return Err( - serde::de::Error::custom("`email` must hold 3 or more characters"), - ); - } - if item.chars().nth(60usize).is_some() { - return Err( - serde::de::Error::custom("`email` must hold 60 or fewer characters"), - ); + let value = Some(::deserialize(deserializer)?); + { + let item = &value; + if let Some(item) = item.as_ref() { + if item.chars().nth(2usize).is_none() { + return Err( + serde::de::Error::custom( + "`email` must hold 3 or more characters", + ), + ); + } + if item.chars().nth(60usize).is_some() { + return Err( + serde::de::Error::custom( + "`email` must hold 60 or fewer characters", + ), + ); + } } } return Ok(value); diff --git a/crates/oapi-codegen/tests/integration/client/Dockerfile b/crates/oapi-codegen/tests/integration/client/Dockerfile index f3c33b6..ab10a16 100644 --- a/crates/oapi-codegen/tests/integration/client/Dockerfile +++ b/crates/oapi-codegen/tests/integration/client/Dockerfile @@ -4,10 +4,11 @@ # start; pre-building with `--no-run` means startup goes straight to the tests. # The build context is the repository root (see docker-compose.yml). -# Kept in sync with rust-toolchain.toml by the Makefile: a mismatch makes -# rustup download a second toolchain inside the image on every build. +# Rust toolchains can be available before matching Docker image tags. +FROM rust:bookworm AS builder ARG RUST_VERSION=1.97.1 -FROM rust:${RUST_VERSION}-bookworm AS builder +RUN rustup toolchain install "${RUST_VERSION}" --profile minimal --no-self-update \ + && rustup default "${RUST_VERSION}" WORKDIR /src COPY . . RUN cargo test --no-run --locked -p bookstore-example --features client --test e2e diff --git a/crates/oapi-codegen/tests/integration/server/Dockerfile b/crates/oapi-codegen/tests/integration/server/Dockerfile index 69fa306..608d7b3 100644 --- a/crates/oapi-codegen/tests/integration/server/Dockerfile +++ b/crates/oapi-codegen/tests/integration/server/Dockerfile @@ -1,10 +1,11 @@ # Build the bookstore server binary from the workspace, then run it from a slim # runtime image. The build context is the repository root (see docker-compose.yml). -# Kept in sync with rust-toolchain.toml by the Makefile: a mismatch makes -# rustup download a second toolchain inside the image on every build. +# Rust toolchains can be available before matching Docker image tags. +FROM rust:bookworm AS builder ARG RUST_VERSION=1.97.1 -FROM rust:${RUST_VERSION}-bookworm AS builder +RUN rustup toolchain install "${RUST_VERSION}" --profile minimal --no-self-update \ + && rustup default "${RUST_VERSION}" WORKDIR /src COPY . . RUN cargo build --locked -p bookstore-example --bin bookstore-server diff --git a/docs/configuration.md b/docs/configuration.md index 061a6bb..235135b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,9 +23,12 @@ For a full, auto-generated reference of every flag and argument, see ## Config file -Keys mirror [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen)'s -YAML config; unknown keys are ignored, so an existing Go config can be reused -as-is. Only the subset below is interpreted. +Keys mirror the YAML configuration of +[`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen). +Unknown keys produce warnings but do not stop generation. +This also applies inside `generate` and `output-options`. +An invalid value for a recognized key remains an error. +The names inside `import-mapping` are file names, not configuration keys. | Key | Type | Purpose | | ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/design.md b/docs/design.md index abb86bc..9504a44 100644 --- a/docs/design.md +++ b/docs/design.md @@ -190,8 +190,8 @@ An inline schema carries no name of its own, so `x-rust-name` on that schema has nothing to override. The remedy acts on the component schema that encloses it, or it moves the inline schema into a component of its own. This matches Go's `oapi-codegen`, which documents `x-go-name` on a component schema and on a property, -and not on an inline schema. A member of a `oneOf` list is the one exception, -because that member becomes a variant that needs a name of its own. See +and not on an inline schema. A member of a `oneOf` or `anyOf` list is the exception. +Its variant or typed accessor needs a name of its own. See [Union variants](extensions.md#union-variants). A per-operation type can take the name of a model. A schema named `Response` is @@ -275,6 +275,51 @@ A document must also declare no `webhooks:` key. That key carries operations, and the generator emits no handler for them. Silence about the key reads as "the document declares no such operation", so the generator rejects the key. +## Diagnose unhandled spec content + +The generator inspects OpenAPI object keys before typed deserialization. +The inspection covers the root document and each referenced document that the +loader reads. It runs before filtering, so an excluded operation cannot hide an +invalid key. + +The catalogue in `crates/oapi-codegen/src/coverage.rs` distinguishes handled keys, annotations, and +unsupported features. An unknown key is an error with the document name and a +JSON pointer. An invalid object, map, or array structure is also an error. +The generator reports independent key errors together, before it writes +output. + +A valid but unimplemented feature produces a warning. Examples include callbacks, +XML serialization, response links, server overrides, and body encoding options. +Existing errors remain errors. For example, an unsupported body type or a `not` +schema still stops generation. + +Annotations that have no generated representation remain intentionally omitted. +These include document information, schema titles, external documentation, and +examples. Compatibility extensions with an `x-go-` prefix are also intentionally +ignored. Other unhandled extensions produce warnings. + +Property names, schema names, security scheme names, and media types are data, +not fixed OpenAPI keys. Payloads in `example`, `default`, and example `value` +fields are also data. The inspection does not interpret their contents as +OpenAPI objects. However, it still inspects objects inside unsupported features, +such as callback operations. + +Warnings also identify several limits of the current translation. These include +Rust union matching, merged `allOf` members, nullability, and unconstrained fallback +types. Every `oneOf` and `anyOf` produces a warning: Rust deserialization checks +do not enforce all schema constraints. Body selection reports discarded media entries. +The warnings expose these limits without changing the generated types. +The catalogue is not a complete OpenAPI value validator. Lowering still applies +its own value and combination checks. + +Warnings go to stderr for library calls and CLI commands, including `--check`. +A warning alone does not change the exit code. Drift and generation errors still +make `--check` fail. + +Configuration diagnostics use the serialized shape of `Config::default()`. +This keeps recognized configuration keys in the Rust types rather than in a +second catalogue. Dynamic `import-mapping` entries do not enter this comparison. + ## A body must declare a content type the generator can represent A request body must declare one of `application/json`, @@ -384,26 +429,49 @@ reason: the literal does not fit. A `number` or `boolean` `enum` still lowers to a bare `f64` or `bool`. A float is not a legal discriminant, and a boolean enum names nothing useful. -## A union cannot hold one type twice +## Union matching follows Rust deserialization + +`oneOf` and `anyOf` have different default representations. Both changes break +compatibility with first-match deserialization. + +A `oneOf` remains a tag-free Rust enum. Its deserializer tries each Rust payload type against a shared reference to a `serde_json::Value`. +Exactly one decode must succeed. Zero successes and multiple successes produce an error. +The implementation returns the decoded variant without a payload clone. Repeated alternatives count separately. + +Union recursion must pass through an object property or array element. The generator rejects cycles through only unions and aliases to prevent infinite deserialization. + +Discriminator mappings affect variant names only. They do not filter alternatives or add discriminator properties to serialized payloads. +A discriminator field affects decoding only through its Rust representation. -A `oneOf` or an `anyOf` becomes an enum with `#[serde(untagged)]`, so no tag -appears on the wire and serde picks a variant by shape. It reads the variants in -declaration order and takes the first that fits. +An `anyOf` becomes a struct with a private `serde_json::Value` field. +In deserialize-capable directions, `TryFrom` requires at least one successful Rust alternative decode and returns `Result`. +`Deserialize` delegates to this constructor. Serialization preserves the full JSON value, including unknown properties and properties from other matches. +It does not preserve source whitespace, object key order, or duplicate keys. -That order makes a repeated type unreachable. A union whose members give `Cat` -twice compiles, and the second variant never matches: a value built with it -serializes like the first and reads back as the first. The value changes, and -nothing reports it. So a repeated type is an error, and the message names the two -variants that share it. +The wrapper exposes these methods: -The check reads the lowered Rust type, not the schema. Two members that differ in -the document but reach one type still collide, which is the case that matters, -because the wire is all serde sees. +- `as_value()` borrows the full JSON value without mutation. +- `into_value()` consumes the wrapper and returns the full JSON value. +- In deserialize-capable directions, `as_()` returns `Result`. + Each call decodes that alternative from the stored value. An error means that this Rust type cannot decode the value. -The check does not read shapes. Two distinct types with the same fields still -shadow each other at run time, and the generator accepts them. Deciding that in -general means comparing every optional field and every subset, so the generator -draws the line at a repeated type, where the fault is exact. +Serialize-only wrappers instead provide `From` as an explicit, unvalidated constructor. +They have no typed accessors and require no payload `Deserialize` implementation. +They have no public raw field or mutable raw accessor. +An accessor name that conflicts with another method produces a generation error. + +### Deliberate limits + +Every union produces a generation warning: Rust deserialization checks do not enforce all schema constraints, which can affect match counts. +Constraints affect matching only where the Rust representation enforces them. There is no separate schema validation engine. + +Two schemas with disjoint numeric bounds can lower to aliases of the same Rust type. +Both aliases accept the same values, so their `oneOf` rejects those values as ambiguous. +An integer JSON value can also decode as both `i64` and `f64`. +Objects with both required property sets can match two alternatives when serde discards unknown properties. +Nullable types and merged `allOf` objects retain their existing representation limits. +Foreign `x-rust-type` implementations determine their own decoding behavior. +Request and response projections retain their directional traits. Matching needs no additional payload `Clone`, `Debug`, or `Serialize` implementation. A variant takes its name from `x-rust-name`, else from the type a `$ref` names, else from the type an inline member holds when it hoists none of its own. An @@ -589,29 +657,62 @@ The function is associated, not free. This keeps it out of the crate root, where every generated type lives. Field names are unique within a struct, so these names are unique too. -`nullable` is the exception. There `null` is a value that the property carries. -The `Option` stays, and the default fills its `Some` side. A `default: null` does -nothing. The parser reads it as no default at all, and serde already leaves a -missing `Option` as `None`. +A nullable property uses `Nullable`. A non-null default fills its +`Nullable::Value` variant. The parser discards `default: null`, so it cannot +fill an absent property with explicit null. The generator reports this limitation. -The generator ignores a `default` on a **required** property. The property is -always present. Use the default, and a payload that omits a required property -becomes valid. +The generator ignores a `default` on a **required** property. A required property must remain present. -Only a value with a literal form works: a string, a number, a boolean, an enum -value, an empty array, and an empty object. "An empty object" means a free-form -map, from `additionalProperties`. A `default: {}` on a property with named -properties is an error, because a struct has no such literal. A non-empty array, -a non-empty object, and a value of the wrong type are errors too, not silent -drops. A dropped default leaves the document and the code in disagreement. An -`int32` property with a default outside the range of `i32` is an error for the -same reason. The alternative is generated code that does not compile. +Supported defaults include strings, numbers, booleans, string enum values, empty arrays, and empty maps. +An empty object means a map from `additionalProperties`, not a struct with named properties. +Unsupported defaults on non-nullable properties produce errors. Unsupported defaults on nullable properties produce warnings and are ignored. +A default of the wrong type or outside the Rust integer range remains an error. A query parameter follows the same rule. A `default: 20` on `limit` gives a plain `i32` field. The default applies when the request omits `limit`. An empty `?limit=` is not an omission. It is the text `""`, and a parse of it into an `i32` fails. +## Property presence and nullability + +Presence and nullability are independent: + +| Schema | Rust field | Accepted JSON states | +| ---------------------- | --------------------- | ---------------------- | +| Required, non-nullable | `T` | Value | +| Optional, non-nullable | `Option` | Absent or value | +| Required, nullable | `Nullable` | Null or value | +| Optional, nullable | `Option>` | Absent, null, or value | + +`Nullable::Null` represents JSON null. `Nullable::Value(value)` represents a value. +An absent optional field becomes `None`. An explicit null never becomes an absent field. +Serialization omits `None` and retains `Some(Nullable::Null)`, which supports PATCH round trips. +An optional field with a non-null default uses the default when absent, as described above. + +Nullable named schemas remain aliases. Named objects and enums have a separate +`Value` type for their non-null representation. References, array items, and map values retain nullability. +The recursion pass inserts `Box` through both presence and nullability wrappers. + +Inline JSON request and response bodies retain nullability for primitives, arrays, and maps. +A single-member `allOf` can wrap a body reference with `nullable: true`. +The generator emits and reserves the `Nullable` helper only when a model or body needs it. +Package output imports the helper from the models module, even without component models. +Inline nullable parameters and non-JSON body schemas produce warnings because their wire formats have no supported null representation. + +`x-rust-type` replaces the value type, not its presence or nullability. +`x-rust-serde-skip` disables these rules for the skipped field. +Skipped fields need no generated deserializer or default function. +An `x-rust-type` override also stops inherited `allOf` constraints and produces a warning. +`x-omitempty: false` can serialize an absent field as null, so it does not preserve PATCH states. +Unconstrained schemas and custom types can accept null themselves. +Union matching and allOf merging retain their documented limitations. + +Constraints on nullable custom types, formatted strings, enums, and objects with named properties produce warnings instead of generated checks. +For optional nullable properties, unsupported defaults produce warnings and leave the property optional. +This includes non-empty collections, custom types, formatted strings, integer enums, and composed schemas. +Defaults on referenced schemas do not fill absent properties. +The raw-document diagnostic reports null defaults before the OpenAPI parser discards them. + ## The server names its security but does not enforce it A document's `security` says which credential an operation expects. The diff --git a/docs/extensions.md b/docs/extensions.md index 4262fc1..7f9750e 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -198,19 +198,22 @@ components: ## Union variants -A `oneOf` or an `anyOf` becomes an untagged enum. Each member becomes a variant, -and the name of that variant comes from the first rule below that applies. +A `oneOf` becomes a tag-free enum. An `anyOf` becomes a JSON wrapper +with typed accessors. Each alternative gets a name from the first applicable rule: | Member | Variant name | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| A `$ref` with a discriminator mapping | The first mapping key for that reference | | Carries `x-rust-name` | The name it gives | | A `$ref` | The name of the type it points to | | A string `enum` of one value | That value | | An inline member that hoists no type of its own | The type it holds: `String`, `I32`, `I64`, `U32`, `U64`, `F64`, `Bool`, `Date`, `DateTime`, `Uuid`, `Bytes` | | Any other inline member | None. Generation stops. | -A member that hoists no type is named by the type it holds. A union cannot hold -one type twice, so these names stay unique. +A member that hoists no type takes the name of its Rust type. +Repeated alternative names receive numeric suffixes. +For an `anyOf`, the alternative name gives the `as_()` accessor name. +An accessor that conflicts with a reserved method produces a generation error. A member that holds one `enum` value stands for a constant, and that value says what the member is. A document that writes a Rust enum as a `oneOf` gives every @@ -246,7 +249,10 @@ says which union it belongs to. `Signal::Red(SignalRed)` reads once at the use site and stays unique at the crate root. Two unions that both hold a member named `Unknown` would otherwise take one name and stop generation. -Two members that lower to one type also end generation. Serde reads an untagged -enum in order and takes the first variant that fits, so the second one never -matches. A value built with it comes back as the first variant, which changes the -value and reports nothing. Remove the repeated member. +Two members can share a Rust type. A `oneOf` requires exactly one successful Rust payload decode. +Discriminator mappings affect variant names only. They do not add or change payload properties. + +An `anyOf` preserves the full JSON value. Deserialize-capable wrappers require at least one successful Rust alternative decode. +Serialize-only wrappers instead accept unvalidated JSON without a payload `Deserialize` requirement. +Rust deserialization checks do not enforce all schema constraints. Every union produces a generation warning about this limit. +See [Union matching](design.md#union-matching-follows-rust-deserialization) for the API and its limits. diff --git a/examples/bookstore/generated/apimodel/catalog.rs b/examples/bookstore/generated/apimodel/catalog.rs index 610a531..34a7cac 100644 --- a/examples/bookstore/generated/apimodel/catalog.rs +++ b/examples/bookstore/generated/apimodel/catalog.rs @@ -16,31 +16,99 @@ pub struct Book { pub id: String, /// Price in minor currency units (e.g. cents). pub price_cents: i64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Book::validate_tags", + default + )] pub tags: Option>, } +impl Book { + /// The rules the document gives `tags`, checked on the way in. + fn validate_tags<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct NewBook { pub title: String, pub author: String, pub price_cents: i64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "NewBook::validate_tags", + default + )] pub tags: Option>, } +impl NewBook { + /// The rules the document gives `tags`, checked on the way in. + fn validate_tags<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct NewReview { /// Star rating from 1 to 5. pub rating: i64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "NewReview::validate_comment", + default + )] pub comment: Option, } +impl NewReview { + /// The rules the document gives `comment`, checked on the way in. + fn validate_comment<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Review { pub id: String, pub rating: i64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "Review::validate_comment", + default + )] pub comment: Option, } +impl Review { + /// The rules the document gives `comment`, checked on the way in. + fn validate_comment<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/examples/bookstore/generated/restapi/operations/list_books.rs b/examples/bookstore/generated/restapi/operations/list_books.rs index 9cdc278..3a4e746 100644 --- a/examples/bookstore/generated/restapi/operations/list_books.rs +++ b/examples/bookstore/generated/restapi/operations/list_books.rs @@ -12,15 +12,61 @@ #[derive(serde::Deserialize, Debug, Clone, PartialEq)] pub struct ListBooksQuery { /// Restrict the listing to a single author. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_author", + default + )] pub author: Option, /// Only return books carrying every given tag. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_tag", + default + )] pub tag: Option>, /// Maximum number of items to return. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "ListBooksQuery::validate_limit", + default + )] pub limit: Option, } +impl ListBooksQuery { + /// The rules the document gives `author`, checked on the way in. + fn validate_author<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } + /// The rules the document gives `tag`, checked on the way in. + fn validate_tag<'de, D>( + deserializer: D, + ) -> ::core::result::Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some( + as serde::Deserialize>::deserialize(deserializer)?, + ); + return Ok(value); + } + /// The rules the document gives `limit`, checked on the way in. + fn validate_limit<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} /// List books, optionally filtered. #[derive(Debug, Clone, PartialEq)] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b73c15e..6422209 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.98.0" +channel = "1.98.1"