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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Select Java OkHttp untagged-union members using retained scalar enum and
structural facts instead of unconstrained Gson dispatch. Preserve typed member
payloads in models, containers, recursive values, and HTTP response/error bodies.
Add `firstMatch`, `strict`, and explicit `VariantRaw(JsonElement)` policies,
retaining existing typed APIs, Gson codecs, null handling, and wrapper boundaries.
Match scientific notation without Gson's decimal scale limit and keep recursive
helpers independent of model names such as `Integer` and `Boolean`.
- 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
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Rust Generator Configuration](rust_config.md)
- [TypeScript Generator Configuration](ts_config.md)
- [Python Generator Configuration](python_config.md)
- [Java Generator Configuration](java_config.md)
- [Architecture](architecture.md)
- [Adding a Generator](adding_a_generator.md)
- [IR Reference](ir_reference.md)
Expand Down
62 changes: 62 additions & 0 deletions docs/src/java_config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Java Generator Configuration

The `java-okhttp` backend emits Java 17 sealed interfaces and records for unions,
with Gson adapters that retain the selected member's declared payload.

## Untagged union selection

```toml
[java-okhttp]
package_name = "com.example.sdk"
untagged_union_strategy = "firstMatch"
```

| Policy | Behavior |
| --- | --- |
| `firstMatch` (default) | Keep typed variants; choose the first structural match in schema order, with concrete members before opaque fallbacks. |
| `strict` | Keep the same typed API; reject multiple authored concrete matches before decoding. Use an opaque fallback only when no concrete member matches. |
| `raw` | Explicitly emit only `VariantRaw(JsonElement)`, preserving known, ambiguous, unmatched, and null JSON without typed selection. |

Both typed policies reject unmatched non-null values. Matching uses required wire
keys, finite scalar constraints (including numeric and boolean enums), nested
arrays and maps, and productive recursive references. Finite numbers use exact
`BigDecimal` significands and `BigInteger` exponents, retaining
distinctions above double precision without Gson's decimal-accessor scale limit.
Recursive checks share results by JSON node identity and predicate within one
selection, not across decoded values. Private recursive-cache types are qualified
so models named `Integer` or
`Boolean` do not shadow the runtime's boxed types.

After selection, the original member is decoded once through its existing Gson
codec. Its errors, including nested strict-union errors, propagate rather than
being hidden by another member or opaque fallback. This is structural selection,
not full JSON Schema validation: formats, numeric bounds, and ordinary Gson
coercion or overflow behavior are not redesigned. Serialization does not add
schema validation.

### Null and representation compatibility

Typed policies preserve Java's existing root-null API: JSON `null` becomes Java
`null` for a nonnullable union, or the existing `VariantNull.INSTANCE` for a
nullable union. No synthetic raw variant is added to typed unions. Explicit raw
mode instead wraps JSON null in `VariantRaw(JsonNull.INSTANCE)`.
As with typed cases, a case name equal to its enclosing interface is
disambiguated (for example, `VariantRaw.VariantRawVariant`).

Java aliases remain objects with a `value` field, and `allOf` models keep their
existing member-wrapper fields. Matchers follow those wire boundaries; this is
not an alias-codec or multi-member `allOf` redesign. Tagged unions and ordinary
models keep their existing APIs and codecs.

Typed variants retain declared member payloads, not arbitrary unknown fields.
Dictionary/additional-property repairs remain separate. Use raw mode when all
JSON must be retained, including unknown keys, null-valued object entries, and
large numeric literals. Raw writing restores the surrounding Gson writer's null
policy; ordinary sibling model fields keep their normal null omission.

The policy can also be set through the CLI:

```sh
openapi-nexus generate --input api.yaml --output sdk --generators java-okhttp \
--generator-config java-okhttp.untagged_union_strategy=strict
```
35 changes: 23 additions & 12 deletions scripts/generate-jvm-client-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,26 @@ esac
root="$(cd "$(dirname "$0")/.." && pwd)"
project="$root/tests/generated-clients/jvm/$backend"

while read -r name fixture; do
case "$name" in
""|\#*) continue ;;
*[!a-zA-Z0-9_-]*) echo "invalid fixture name: $name" >&2; exit 1 ;;
esac
output="$project/generated/$name"
# Only these fixed, ignored fixture output directories are disposable.
rm -rf "$output"
cargo run --quiet --manifest-path "$root/Cargo.toml" --bin openapi-nexus -- generate \
--config "$root/tests/generated-clients/config.toml" \
--input "$root/tests/fixtures/$fixture" --output "$output" --generators "$backend-okhttp"
done < "$root/tests/generated-clients/jvm/fixtures.txt"
manifests=("$root/tests/generated-clients/jvm/fixtures.txt")
if [ -f "$project/fixtures.txt" ]; then manifests+=("$project/fixtures.txt"); fi
for manifest in "${manifests[@]}"; do
while read -r name fixture strategy; do
case "$name" in
""|\#*) continue ;;
*[!a-zA-Z0-9_-]*) echo "invalid fixture name: $name" >&2; exit 1 ;;
esac
output="$project/generated/$name"
options=(--config "$root/tests/generated-clients/config.toml"
--input "$root/tests/fixtures/$fixture" --output "$output" --generators "$backend-okhttp")
if [ -n "$strategy" ]; then
case "$strategy" in
firstMatch|strict|raw) ;;
*) echo "invalid union strategy: $strategy" >&2; exit 1 ;;
esac
options+=(--generator-config "$backend-okhttp.untagged_union_strategy=$strategy")
fi
# Only these fixed, ignored fixture output directories are disposable.
rm -rf "$output"
cargo run --quiet --manifest-path "$root/Cargo.toml" --bin openapi-nexus -- generate "${options[@]}"
done < "$manifest"
done
1 change: 1 addition & 0 deletions src/generators/java/okhttp/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ impl JavaOkhttpCodeGenerator {
&package_name,
&header,
&request_inputs,
self.config.untagged_union_strategy,
)?);

files.extend(sigil_emit_api::generate_api_files(
Expand Down
37 changes: 37 additions & 0 deletions src/generators/java/okhttp/config.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
use serde::{Deserialize, Serialize};

/// Structural selection policy for generated untagged Gson adapters.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum UntaggedUnionStrategy {
#[default]
#[serde(rename = "firstMatch")]
FirstMatch,
#[serde(rename = "strict")]
Strict,
#[serde(rename = "raw")]
Raw,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JavaOkhttpConfig {
#[serde(default)]
pub package_name: Option<String>,
#[serde(default)]
pub untagged_union_strategy: UntaggedUnionStrategy,
}

impl From<toml::value::Table> for JavaOkhttpConfig {
Expand All @@ -14,3 +28,26 @@ impl From<toml::value::Table> for JavaOkhttpConfig {
})
}
}

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

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