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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Select Rust untagged-union members using retained scalar enum and structural
facts instead of Serde's unconstrained first-success dispatch. Preserve typed
variants and payloads across reqwest, ureq, and aioduct, including nested
containers and recursive members. Add `firstMatch`, `strict`, and explicit
`Raw(serde_json::Value)` policies; propagate selected-member decoding errors
without hiding them behind another member or an opaque fallback.
- Convert Python httpx/requests untagged-union members in models, nested containers,
aliases, JSON/multipart requests, responses, and lazy error bodies. Share enum-
aware selection across both transports with `firstMatch`, `strict`, and dynamic
Expand Down
78 changes: 66 additions & 12 deletions docs/src/rust_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,30 +82,80 @@ struct AppError {
}
```

## Untagged Union Deserialization

Untagged unions keep their existing typed variants and untagged serialization.
Deserialization selects members using the shared IR's JSON shapes, required wire
keys, finite scalar values (including numeric and boolean enums), nested arrays
and maps, and productive recursive references. This prevents an earlier member
with an unconstrained Rust integer field from swallowing a later member's payload.
Repeated recursive checks reuse results within one member-selection pass, without
changing candidate order or sharing cached results across decoded values.

### `untagged_union_strategy`

| Value | Generated API and behavior |
| --- | --- |
| `"firstMatch"` (default) | Keep typed variants; select the first structural match in schema order, preferring concrete members to opaque fallbacks. |
| `"strict"` | Keep the same typed API; reject multiple concrete matches before deserializing any member. Use an opaque fallback only when no concrete member matches. |
| `"raw"` | Explicitly replace each untagged union with an untagged enum containing only `Raw(serde_json::Value)`. Preserve known, ambiguous, unmatched, and null JSON without typed selection. |

Both typed policies reject unmatched values. After selection, the chosen member
is deserialized once; its errors (including nested strict-union errors) propagate
instead of being hidden by another member or an opaque fallback. Structural
matching is not a full JSON Schema validator: formats, bounds, and other validation
keywords are not selection rules, and native Serde decoding can still reject a
selected payload. Serialization does not add schema validation.

The typed enum does not gain synthetic `Raw` or `Null` variants. Explicitly nullable
use-site expressions continue to use `Option<Union>`; decode a standalone nullable
union through `Option<Union>` as well. As before, a named union's root nullable
flag alone does not change its Rust enum or automatically wrap references to it.
Explicit `raw` changes the union's Rust API, so callers
construct and match `Union::Raw(value)` rather than typed variants. It does not
change tagged unions or turn ordinary models into raw JSON.

```toml
[rust-reqwest]
untagged_union_strategy = "strict"
```

The same setting works for `rust-ureq` and `rust-aioduct`, or as a CLI override:

```sh
openapi-nexus generate --input api.yaml --output sdk --generators rust-reqwest \
--generator-config rust-reqwest.untagged_union_strategy=raw
```

Typed decoding retains the selected member's declared payload, not arbitrary
unknown fields. Dictionary/additional-property representation and multi-member
`allOf` behavior are unchanged. Choose `raw` when the full JSON must be retained.

## Full Example

```toml
[generators.rust-reqwest]
[rust-reqwest]
crate_name = "my-api-client"
workspace_mode = true
workspace_deps = "workspace_version"
untagged_union_strategy = "firstMatch"

[generators.rust-reqwest.extra_derives.structs]
[rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]

[generators.rust-reqwest.extra_derives.enums]
[rust-reqwest.extra_derives.enums]
derives = ["Hash"]

[generators.rust-reqwest.extra_derives.unions]
[rust-reqwest.extra_derives.unions]
derives = ["PartialEq"]

[generators.rust-reqwest.extra_derives.response_structs]
[rust-reqwest.extra_derives.response_structs]
derives = ["PartialEq"]

[generators.rust-reqwest.extra_derives.per_type.MySpecialSchema]
[rust-reqwest.extra_derives.per_type.MySpecialSchema]
derives = ["Default"]

[generators.rust-reqwest.utoipa]
[rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'
```
Expand Down Expand Up @@ -148,23 +198,27 @@ Add custom derive macros to generated types. Each category targets a different s

- `structs` — object schemas
- `enums` — string and integer enums
- `unions` — tagged unions (external tagging)
- `unions` — tagged and untagged unions (including explicit raw wrappers)
- `response_structs` — per-operation response type wrappers

```toml
[generators.rust-reqwest.extra_derives.structs]
[rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]
dependencies = { fake = '"2"' }
```

The `dependencies` field adds entries to the generated `Cargo.toml`.
For untagged unions, the emitter owns Serde implementations and, when enabled,
utoipa implementations. Equivalent short, crate-qualified, and absolute derive
paths (such as `Deserialize`, `serde::Deserialize`, and `::serde::Deserialize`)
do not add conflicting implementations.

### `extra_derives.per_type`

Target a specific schema by name:

```toml
[generators.rust-reqwest.extra_derives.per_type.UserProfile]
[rust-reqwest.extra_derives.per_type.UserProfile]
derives = ["Default", "Hash"]
```

Expand All @@ -173,7 +227,7 @@ derives = ["Default", "Hash"]
Native [utoipa](https://github.com/juhaku/utoipa) integration for OpenAPI schema generation at runtime.

```toml
[generators.rust-reqwest.utoipa]
[rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'
```
Expand All @@ -194,7 +248,7 @@ You do NOT need to add `utoipa::ToSchema` to `extra_derives` when using this con
Configure aioduct-specific dependency features for the generated crate. This section only applies to the `rust-aioduct` generator.

```toml
[generators.rust-aioduct.aioduct]
[rust-aioduct.aioduct]
version = "0.2"
runtime = "tokio"
tls = "rustls-ring"
Expand Down
17 changes: 16 additions & 1 deletion scripts/generate-rust-client-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ esac
root="$(cd "$(dirname "$0")/.." && pwd)"
project="$root/tests/generated-clients/rust/$backend"

for fixture in typed-error-responses rust-error-payload-shapes nested-nullable; do
for fixture in typed-error-responses rust-error-payload-shapes nested-nullable union-selection; do
output="$project/generated/$fixture"
# These fixed fixture directories contain only disposable generated output.
# Remove stale source files so a deleted declaration cannot pass unnoticed.
Expand All @@ -21,3 +21,18 @@ for fixture in typed-error-responses rust-error-payload-shapes nested-nullable;
--output "$output" \
--generators "rust-$backend"
done

for strategy in firstMatch strict raw; do
suffix="$strategy"
if [ "$strategy" = firstMatch ]; then suffix=first-match; fi
output="$project/generated/union-$suffix"
# Fixed disposable output, never a consumer source directory.
rm -rf "$output"
cargo run --quiet --manifest-path "$root/Cargo.toml" --bin openapi-nexus -- generate \
--config "$root/tests/generated-clients/rust/union-policies.toml" \
--input "$root/tests/fixtures/valid/rust-union-policies.yaml" \
--output "$output" \
--generators "rust-$backend" \
--generator-config "rust-$backend.crate_name=rust-union-$suffix" \
--generator-config "rust-$backend.untagged_union_strategy=$strategy"
done
1 change: 1 addition & 0 deletions src/generators/python/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ impl<'a> ValuePlan<'a> {
type_expr,
shape,
fallback,
..
} in candidates
{
let check = condition(
Expand Down
53 changes: 51 additions & 2 deletions src/generators/rust/common/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ impl ExtraDerivesConfig {

fn check_derives(derives: &[String], deps: &BTreeMap<String, String>) {
for d in derives {
if let Some(crate_name) = d.split("::").next()
&& crate_name != d
let path = d.strip_prefix("::").unwrap_or(d);
if let Some(crate_name) = path.split("::").next()
&& crate_name != path
{
let normalized = crate_name.replace('-', "_");
if !deps.contains_key(&normalized) {
Expand Down Expand Up @@ -119,6 +120,21 @@ pub struct UtoipaConfig {
pub dependency: Option<String>,
}

/// Deserialization policy for untagged unions; serialization stays untagged.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum UntaggedUnionStrategy {
/// Select the first structural match, preferring concrete members to fallbacks.
#[default]
#[serde(rename = "firstMatch")]
FirstMatch,
/// Reject multiple concrete matches before decoding the selected member.
#[serde(rename = "strict")]
Strict,
/// Emit a raw JSON wrapper instead of selecting typed members.
#[serde(rename = "raw")]
Raw,
}

/// Base configuration for Rust generators.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RustGeneratorConfig {
Expand All @@ -133,6 +149,8 @@ pub struct RustGeneratorConfig {
#[serde(default)]
pub utoipa: Option<UtoipaConfig>,
#[serde(default)]
pub untagged_union_strategy: UntaggedUnionStrategy,
#[serde(default)]
pub aioduct: Option<crate::generators::rust::aioduct::config::AioductFeatureConfig>,
}

Expand Down Expand Up @@ -167,6 +185,26 @@ mod tests {
assert!(config.extra_derives.is_none());
}

#[test]
fn untagged_union_strategy_is_explicit_and_defaults_to_first_match() {
assert_eq!(
RustGeneratorConfig::default().untagged_union_strategy,
UntaggedUnionStrategy::FirstMatch
);
for (value, expected) in [
("firstMatch", UntaggedUnionStrategy::FirstMatch),
("strict", UntaggedUnionStrategy::Strict),
("raw", UntaggedUnionStrategy::Raw),
] {
let config: RustGeneratorConfig =
toml::from_str(&format!("untagged_union_strategy = '{value}'")).unwrap();
assert_eq!(config.untagged_union_strategy, expected);
}
assert!(
toml::from_str::<RustGeneratorConfig>("untagged_union_strategy = 'unknown'").is_err()
);
}

#[test]
fn deserialize_empty_table() {
let config = RustGeneratorConfig::from(toml::value::Table::new());
Expand Down Expand Up @@ -394,4 +432,15 @@ mod tests {
};
extra.warn_missing_dependencies();
}

#[test]
#[tracing_test::traced_test]
fn absolute_derive_paths_check_the_named_crate_dependency() {
let deps = BTreeMap::from([("custom".into(), "\"1\"".into())]);
ExtraDerivesConfig::check_derives(&["::custom::Derive".into()], &deps);
assert!(!logs_contain("no matching entry"));
ExtraDerivesConfig::check_derives(&["::missing::Derive".into()], &deps);
assert!(logs_contain("references crate `missing`"));
assert!(!logs_contain("references crate ``"));
}
}
Loading
Loading