diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d3e3d..0f547cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,52 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Added + +- The 55-spec compile gate now generates deterministic JSON instances for + representable component schemas, validates them against the source JSON + Schema, hydrates and serializes the exact generated Rust models, validates + their output, and requires a stable second round trip. Targeted runs such as + `scripts/spec-compile.sh anthropic` use the same gate and report sample and + skip coverage. + +### Changed + +- **Breaking (generated API).** Structs used as discriminated-union variants + retain their discriminator fields, so constructing one directly now requires + the same tag its standalone component schema requires. Parent unions perform + explicit discriminator-directed Serde dispatch instead of stripping the + field and relying on an internally tagged derive. This keeps direct values, + arrays, and union payloads on one schema-valid wire shape. + +### Fixed + +- Required nullable fields serialize `None` as explicit JSON `null` instead of + omitting a key listed by the schema's `required` array. Nullable component + schemas referenced by a property now propagate that nullability to the field. +- A composition nested as one branch of an outer `anyOf` is retained as a named + Rust union variant instead of being silently dropped. + +- Boolean subschemas (`true` and `false`) parse wherever JSON Schema 2020-12 + allows one — a property, a `$defs` entry, `not`, `if`/`then`/`else`, + `contains`, `propertyNames`, `patternProperties`, `dependentSchemas`, a + `oneOf` branch. `properties: {extra: true}` is how a spec says "this key + exists, any value"; one of those anywhere in a document used to fail the whole + thing with "data did not match any variant of untagged enum Schema" (#63). + + `true` generates `serde_json::Value` and `false` a value that cannot occur — + both reported as faithful by `--report-untyped`. In a union, a `true` branch + makes the union unconstrained and a `false` branch is dropped, so + `oneOf: [A, false]` is `A`. +- Integer keywords written as decimals — `maxItems: 2.0`, which JSON Schema + permits and the 2020-12 suite exercises — are read as the counts they are + rather than rejecting the document. A fractional value like `2.5` is still an + error. + + Together these take the vendored JSON Schema 2020-12 corpus from 38 parse + failures to zero, with no round-trip loss. + + ## [0.14.0] - 2026-08-27 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67070db..3b87951 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,23 @@ The full corpus generates and compile-checks 55 OpenAPI documents and can take several minutes. CI runs a fast generation tier on pull requests and the full compile tier weekly or on manual dispatch. +Compile runs also generate deterministic, schema-valid JSON samples for each +representable component model. Each sample is independently validated against +the source OpenAPI JSON Schema, hydrated into the generated Rust type, +serialized, validated again, and round-tripped a second time to require a stable +wire representation. Start with one production spec while iterating: + +```bash +scripts/spec-compile.sh anthropic +``` + +The full `scripts/spec-compile.sh` command applies the same check across the +55-spec compile suite. Its summary reports component and sample coverage plus +explicit schema skips. Set `SPEC_COMPILE_SCHEMA_ROUNDTRIP=0` only when isolating +an unrelated compile failure; parse-only runs skip model round trips because +they do not compile generated Rust. Failed scratch crates and logs are retained +under `tmp/spec-compile/`. + ## Compatibility expectations Until 1.0, a minor release may correct generated Rust APIs that were incomplete diff --git a/Cargo.lock b/Cargo.lock index aaf2ce4..2e8d659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1145,6 +1145,7 @@ dependencies = [ "toml", "toml_edit", "url", + "uuid", ] [[package]] @@ -2159,6 +2160,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index a0cf18b..0f4e881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ serde_path_to_error = "0.1.20" serde_yaml = "0.9" insta = { version = "1.41", features = ["yaml"] } tempfile = "3.0" +uuid = { version = "1", features = ["serde"] } [features] default = ["cli"] @@ -82,6 +83,11 @@ name = "file-beads" path = "src/bin/file-beads.rs" required-features = ["internal-tools"] +[[bin]] +name = "schema-roundtrip" +path = "src/bin/schema-roundtrip.rs" +required-features = ["internal-tools"] + [lints.clippy] unwrap_used = "deny" expect_used = "warn" diff --git a/README.md b/README.md index 9289032..3808d5d 100644 --- a/README.md +++ b/README.md @@ -727,6 +727,14 @@ cargo insta review # review snapshot diffs scripts/spec-compile.sh # generate + cargo-check every spec in specs/ (full corpus) ``` +`scripts/spec-compile.sh` also synthesizes valid JSON instances from each +source component schema, hydrates the exact generated Rust model, serializes it +back to JSON, validates that output against the same schema, and requires a +stable second round trip. The summary reports tested components, generated +samples, and explicit skips. Pass one or more spec names (for example, +`scripts/spec-compile.sh anthropic`) for a focused trial before running the +full 55-spec compile suite. + The compile tiers are intentionally different: - Every pull request and push to `main` generates all 55 supported OpenAPI diff --git a/examples/anyof_unions.rs b/examples/anyof_unions.rs index 351e8c5..0e56349 100644 --- a/examples/anyof_unions.rs +++ b/examples/anyof_unions.rs @@ -107,12 +107,13 @@ fn main() -> Result<(), Box> { " - {}: {:?}", name, match &schema.schema_type { - openapi_to_rust::analysis::SchemaType::Union { variants } => { + openapi_to_rust::analysis::SchemaType::Union { variants, .. } => { format!("Union(variants: {})", variants.len()) } openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } => { format!( "DiscriminatedUnion(discriminator: {}, variants: {})", diff --git a/examples/debug_beta_input.rs b/examples/debug_beta_input.rs index e8b3f26..80bc1f7 100644 --- a/examples/debug_beta_input.rs +++ b/examples/debug_beta_input.rs @@ -21,6 +21,7 @@ fn main() -> Result<(), Box> { if let openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } = &schema.schema_type { println!("\nDiscriminator field: {discriminator_field}"); diff --git a/examples/discriminated_unions.rs b/examples/discriminated_unions.rs index 2354d02..5d6d04d 100644 --- a/examples/discriminated_unions.rs +++ b/examples/discriminated_unions.rs @@ -143,6 +143,7 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } => { format!( "DiscriminatedUnion(discriminator: {}, variants: {})", diff --git a/examples/discriminator_mappings.rs b/examples/discriminator_mappings.rs index 9dd284b..d56b476 100644 --- a/examples/discriminator_mappings.rs +++ b/examples/discriminator_mappings.rs @@ -139,6 +139,7 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } => { let variant_info: Vec = variants .iter() diff --git a/examples/inline_objects.rs b/examples/inline_objects.rs index c1f642a..91b2203 100644 --- a/examples/inline_objects.rs +++ b/examples/inline_objects.rs @@ -118,6 +118,7 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } => { let variant_info: Vec = variants .iter() @@ -134,7 +135,7 @@ fn main() -> Result<(), Box> { variant_info.join(", ") ) } - openapi_to_rust::analysis::SchemaType::Union { variants } => { + openapi_to_rust::analysis::SchemaType::Union { variants, .. } => { let variant_info: Vec = variants.iter().map(|v| v.target.to_string()).collect(); format!("Union(variants: [{}])", variant_info.join(", ")) diff --git a/examples/openai_patterns.rs b/examples/openai_patterns.rs index e9b8fb8..8cd8afc 100644 --- a/examples/openai_patterns.rs +++ b/examples/openai_patterns.rs @@ -102,7 +102,7 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::Object { properties, .. } => { format!("Object({} properties)", properties.len()) } - openapi_to_rust::analysis::SchemaType::Union { variants } => { + openapi_to_rust::analysis::SchemaType::Union { variants, .. } => { let variant_info: Vec = variants.iter().map(|v| v.target.to_string()).collect(); format!("Union(variants: [{}])", variant_info.join(", ")) diff --git a/examples/server-anthropic-messages/src/main.rs b/examples/server-anthropic-messages/src/main.rs index 2e9490a..56daf19 100644 --- a/examples/server-anthropic-messages/src/main.rs +++ b/examples/server-anthropic-messages/src/main.rs @@ -58,6 +58,7 @@ fn messages_unary() -> MessagesPostResponse { content: vec![gen::ContentBlock::TextBlock(gen::ResponseTextBlock { citations: None, text: "hello (unary)".into(), + r#type: gen::ResponseTextBlockType::Text, })], id: "msg_demo".into(), model: gen::Model::Custom("claude-demo".into()), diff --git a/examples/server-openai-responses/src/main.rs b/examples/server-openai-responses/src/main.rs index 8dba43f..b52bc92 100644 --- a/examples/server-openai-responses/src/main.rs +++ b/examples/server-openai-responses/src/main.rs @@ -1,7 +1,7 @@ //! Example: host a perfect-replica of OpenAI's `POST /v1/responses`. //! //! Exercises both branches of the typed response enum: -//! - `body.stream == Some(true)` → `OkStream(Sse<...>)` +//! - `body.stream.flatten().unwrap_or(false)` → `OkStream(Sse<...>)` //! - otherwise → `Ok(Response)` (single JSON body) //! //! Run: @@ -35,7 +35,7 @@ struct AppState; #[async_trait::async_trait] impl ResponsesApi for AppState { async fn create_response(&self, body: CreateResponse) -> CreateResponseResponse { - if body.stream == Some(true) { + if body.stream.flatten().unwrap_or(false) { create_response_streaming() } else { create_response_unary(body) diff --git a/examples/test_nested_discriminators.rs b/examples/test_nested_discriminators.rs index 037b8f7..29718da 100644 --- a/examples/test_nested_discriminators.rs +++ b/examples/test_nested_discriminators.rs @@ -97,6 +97,7 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::DiscriminatedUnion { discriminator_field, variants, + .. } => { let variant_info: Vec = variants .iter() diff --git a/scripts/spec-compile.sh b/scripts/spec-compile.sh index 99b3cc2..18b6b6f 100755 --- a/scripts/spec-compile.sh +++ b/scripts/spec-compile.sh @@ -17,6 +17,9 @@ # SPEC_COMPILE_LIMIT=N process only the first N alphabetically-sorted specs # SPEC_COMPILE_PARSE_ONLY=1 skip cargo check; only verify the generator # parses+emits without errors. Faster. +# SPEC_COMPILE_SCHEMA_ROUNDTRIP=0 skip synthetic JSON -> generated Rust -> +# JSON Schema round trips. Enabled by default for +# compile runs; parse-only runs always skip it. # SPEC_COMPILE_FORCE_CHECK=1 also cargo check the specs in # GENERATE_ONLY_SPECS (see below), which are # skipped by default because their generated @@ -36,10 +39,20 @@ if [ "${SPEC_COMPILE_OFFLINE:-}" = "1" ]; then OFFLINE="--offline" fi +ROUNDTRIP_ENABLED="${SPEC_COMPILE_SCHEMA_ROUNDTRIP:-1}" +if [ "${SPEC_COMPILE_PARSE_ONLY:-}" = "1" ]; then + ROUNDTRIP_ENABLED=0 +fi + echo "[spec-compile] building openapi-to-rust binary..." -cargo build --bin openapi-to-rust $OFFLINE >/dev/null +if [ "$ROUNDTRIP_ENABLED" = "1" ]; then + cargo build --features internal-tools --bin openapi-to-rust --bin schema-roundtrip $OFFLINE >/dev/null +else + cargo build --bin openapi-to-rust $OFFLINE >/dev/null +fi GEN_BIN="$(pwd)/target/debug/openapi-to-rust" +ROUNDTRIP_BIN="$(pwd)/target/debug/schema-roundtrip" WORKSPACE="$(pwd)" ROOT="$WORKSPACE/tmp/spec-compile" @@ -114,9 +127,20 @@ generate_only_reason() { passed=() failed_gen=() failed_check=() +failed_roundtrip_plan=() +failed_roundtrip=() skipped=() generate_only=() gen_ok=() +roundtrip_planned=() +roundtrip_tested=() +roundtrip_schema_total=0 +roundtrip_schema_tested=0 +roundtrip_schema_skipped=0 +roundtrip_schema_source_invalid=0 +roundtrip_schema_dependent=0 +roundtrip_schema_synthesis_skipped=0 +roundtrip_sample_total=0 for entry in "${SPECS[@]}"; do IFS='|' read -r name spec_path <<<"$entry" @@ -170,6 +194,50 @@ EOF failed_gen+=("$name") continue fi + roundtrip_ready=0 + rt_components=0 + rt_tested=0 + rt_skipped=0 + rt_source_invalid=0 + rt_dependent=0 + rt_synthesis_skipped=0 + rt_samples=0 + if [ "$ROUNDTRIP_ENABLED" = "1" ] && ! is_generate_only "$name"; then + mkdir -p "$dir/tests" + rt_log="$dir/roundtrip-plan.log" + rt_stats="$dir/roundtrip.stats" + if ! "$ROUNDTRIP_BIN" "$spec_path" "$dir/src/schema_roundtrip_test.rs" "$rt_stats" >"$rt_log" 2>&1; then + echo "RT-PLAN-FAIL" + failed_roundtrip_plan+=("$name") + continue + fi + { + echo + echo '#[cfg(test)]' + echo 'mod schema_roundtrip_test;' + } >>"$dir/src/lib.rs" + while IFS='=' read -r key value; do + case "$key" in + component_schemas) rt_components="$value" ;; + tested_schemas) rt_tested="$value" ;; + skipped_schemas) rt_skipped="$value" ;; + source_invalid_schemas) rt_source_invalid="$value" ;; + dependent_schemas) rt_dependent="$value" ;; + synthesis_skipped_schemas) rt_synthesis_skipped="$value" ;; + samples) rt_samples="$value" ;; + esac + done <"$rt_stats" + roundtrip_ready=1 + roundtrip_planned+=("$name") + roundtrip_schema_total=$((roundtrip_schema_total + rt_components)) + roundtrip_schema_tested=$((roundtrip_schema_tested + rt_tested)) + roundtrip_schema_skipped=$((roundtrip_schema_skipped + rt_skipped)) + roundtrip_schema_source_invalid=$((roundtrip_schema_source_invalid + rt_source_invalid)) + roundtrip_schema_dependent=$((roundtrip_schema_dependent + rt_dependent)) + roundtrip_schema_synthesis_skipped=$((roundtrip_schema_synthesis_skipped + rt_synthesis_skipped)) + roundtrip_sample_total=$((roundtrip_sample_total + rt_samples)) + fi + { # Empty [workspace] keeps the scratch crate out of the repo's workspace; # without it cargo walks up, finds the root manifest, and refuses. @@ -182,15 +250,26 @@ EOF echo "publish = false" echo cat "$deps" + if [ "$roundtrip_ready" = "1" ]; then + echo + echo "[dev-dependencies]" + echo 'jsonschema = { version = "0.49", default-features = false }' + fi } >"$dir/Cargo.toml" - echo "GEN-OK" + if [ "$roundtrip_ready" = "1" ]; then + echo "GEN+RT-OK ($rt_tested/$rt_components schemas, $rt_samples samples; skips: $rt_source_invalid source-invalid, $rt_dependent dependent, $rt_synthesis_skipped synthesis)" + else + echo "GEN-OK" + fi gen_ok+=("$name") done if [ "${SPEC_COMPILE_PARSE_ONLY:-}" = "1" ]; then passed=("${gen_ok[@]}") - [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$ROOT" + if [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && [ ${#failed_gen[@]} -eq 0 ]; then + rm -rf "$ROOT" + fi elif [ ${#gen_ok[@]} -gt 0 ]; then # ---- Phase 2: check every exact generated manifest --------------------- echo @@ -202,30 +281,55 @@ elif [ ${#gen_ok[@]} -gt 0 ]; then continue fi log="$ROOT/$name/check.log" - if ( cd "$ROOT/$name" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then - printf "%-30s PASS\n" "$name" - passed+=("$name") - else + if ! ( cd "$ROOT/$name" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then err_count=$(grep -cE "^error" "$log" || true) printf "%-30s CHECK-FAIL (%s errs)\n" "$name" "$err_count" failed_check+=("$name") + continue + fi + if [ -f "$ROOT/$name/src/schema_roundtrip_test.rs" ]; then + rt_log="$ROOT/$name/roundtrip.log" + if ( cd "$ROOT/$name" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo test --lib generated_models_preserve_schema_valid_json $OFFLINE ) >"$rt_log" 2>&1; then + printf "%-30s PASS + ROUNDTRIP\n" "$name" + roundtrip_tested+=("$name") + passed+=("$name") + else + err_count=$(grep -cE "^(error|failures:|thread .* panicked)" "$rt_log" || true) + printf "%-30s ROUNDTRIP-FAIL (%s diagnostics)\n" "$name" "$err_count" + failed_roundtrip+=("$name") + fi + else + printf "%-30s PASS\n" "$name" + passed+=("$name") fi done - [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$ROOT" + if [ "${SPEC_COMPILE_KEEP:-}" != "1" ] \ + && [ ${#failed_gen[@]} -eq 0 ] \ + && [ ${#failed_check[@]} -eq 0 ] \ + && [ ${#failed_roundtrip_plan[@]} -eq 0 ] \ + && [ ${#failed_roundtrip[@]} -eq 0 ]; then + rm -rf "$ROOT" + fi fi echo -echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#generate_only[@]} generate-only, ${#skipped[@]} skipped" +echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#failed_roundtrip_plan[@]} roundtrip-plan-failed, ${#failed_roundtrip[@]} roundtrip-failed, ${#generate_only[@]} generate-only, ${#skipped[@]} skipped" [ ${#failed_gen[@]} -gt 0 ] && echo " gen-fail: ${failed_gen[*]}" [ ${#failed_check[@]} -gt 0 ] && echo " check-fail: ${failed_check[*]}" +[ ${#failed_roundtrip_plan[@]} -gt 0 ] && echo " roundtrip-plan-fail: ${failed_roundtrip_plan[*]}" +[ ${#failed_roundtrip[@]} -gt 0 ] && echo " roundtrip-fail: ${failed_roundtrip[*]}" [ ${#skipped[@]} -gt 0 ] && echo " skipped: ${skipped[*]}" +if [ "$ROUNDTRIP_ENABLED" = "1" ]; then + echo " roundtrip: ${#roundtrip_tested[@]}/${#roundtrip_planned[@]} spec(s), $roundtrip_schema_tested/$roundtrip_schema_total component schema(s), $roundtrip_sample_total sample(s), $roundtrip_schema_skipped schema skip(s) ($roundtrip_schema_source_invalid source-invalid, $roundtrip_schema_dependent dependent, $roundtrip_schema_synthesis_skipped synthesis)" +fi if [ ${#generate_only[@]} -gt 0 ]; then echo " generate-only (NOT compile-verified): ${generate_only[*]}" echo " ^ these generated cleanly but were never compiled. Run them locally" echo " on a machine with enough RAM: scripts/spec-compile.sh ${generate_only[*]}" fi -if [ ${#failed_gen[@]} -gt 0 ] || [ ${#failed_check[@]} -gt 0 ]; then +if [ ${#failed_gen[@]} -gt 0 ] || [ ${#failed_check[@]} -gt 0 ] || [ ${#failed_roundtrip_plan[@]} -gt 0 ] || [ ${#failed_roundtrip[@]} -gt 0 ]; then + echo "[spec-compile] failure artifacts retained under $ROOT" exit 1 fi if [ ${#generate_only[@]} -gt 0 ]; then diff --git a/specs/cloudflare.yaml b/specs/cloudflare.yaml index ae6d0c1..0ae8072 100644 --- a/specs/cloudflare.yaml +++ b/specs/cloudflare.yaml @@ -37956,13 +37956,6 @@ "$ref": "#/components/schemas/hyperdrive_api-response-common" }, "hyperdrive_hyperdrive-caching": { - "discriminator": { - "mapping": { - "false": "#/components/schemas/hyperdrive-caching-enabled", - "true": "#/components/schemas/hyperdrive-caching-disabled" - }, - "propertyName": "disabled" - }, "type": "object", "anyOf": [ { "$ref": "#/components/schemas/hyperdrive_hyperdrive-caching-disabled" }, @@ -92371,8 +92364,7 @@ "code": { "type": "integer" }, "documentation_url": { "type": "string" }, "source": { "type": "object", "properties": { "pointer": { "type": "string" } } } - }, - "required": [] + } }, "pagination_info": { "type": "object", @@ -92397,8 +92389,7 @@ "description": "Total results available without any search parameters", "example": 2000 } - }, - "required": [] + } }, "dns-records_dns-record": { "type": "object", diff --git a/specs/coda.yaml b/specs/coda.yaml index 0da58c2..57524b6 100644 --- a/specs/coda.yaml +++ b/specs/coda.yaml @@ -8790,7 +8790,6 @@ components: - layout - createdAt - updatedAt - - viewId additionalProperties: false properties: id: *ref_29 @@ -10941,12 +10940,12 @@ components: - sessionsDesktop - sessionsOther - totalSessions - - aiCreditsChat, - - aiCreditsBlock, - - aiCreditsColumn, - - aiCreditsAssistant, - - aiCreditsReviewer, - - aiCredits, + - aiCreditsChat + - aiCreditsBlock + - aiCreditsColumn + - aiCreditsAssistant + - aiCreditsReviewer + - aiCredits additionalProperties: false properties: date: @@ -11907,7 +11906,7 @@ components: mapping: user: '#/components/schemas/PackUserPrincipal' workspace: '#/components/schemas/PackWorkspacePrincipal' - global: '#/components/schemas/PackGlobalPrincipal' + worldwide: '#/components/schemas/PackGlobalPrincipal' nomosOrganization: '#/components/schemas/PackNomosOrganizationPrincipal' group: '#/components/schemas/PackGroupPrincipal' grammarlyInstitution: '#/components/schemas/PackGrammarlyInstitutionPrincipal' @@ -14109,8 +14108,8 @@ components: propertyName: type mapping: custom: '#/components/schemas/PackCustomLog' - fetcher: '#/components/schemas/PackInvocationLog' - invocation: '#/components/schemas/PackFetcherLog' + fetcher: '#/components/schemas/PackFetcherLog' + invocation: '#/components/schemas/PackInvocationLog' internal: '#/components/schemas/PackInternalLog' auth: '#/components/schemas/PackAuthLog' ingestionLifecycle: '#/components/schemas/PackIngestionLifecycleLog' @@ -15470,13 +15469,12 @@ components: required: - completionTimestamp - creationTimestamp - - errorMessage - executionType - fullExecutionId - ingestionExecutionId - ingestionId - ingestionName - - ingestionStatuses + - ingestionStatusCounts - startTimestamp additionalProperties: false properties: @@ -15631,7 +15629,6 @@ components: - startTimestamp - completionTimestamp - errorMessage - - message additionalProperties: false properties: csbIngestionExecutionId: @@ -15678,7 +15675,6 @@ components: type: object required: - completionTimestamp - - creationTimestamp - errorMessage - executionType - ingestionExecutionId diff --git a/specs/discord.json b/specs/discord.json index 11d4c39..2d0b9b4 100644 --- a/specs/discord.json +++ b/specs/discord.json @@ -16594,7 +16594,18 @@ }, "ApplicationCommandHandler": { "type": "integer", - "oneOf": [], + "oneOf": [ + { + "title": "APP_HANDLER", + "description": "The app handles the interaction using an interaction token", + "const": 1 + }, + { + "title": "DISCORD_LAUNCH_ACTIVITY", + "description": "Discord handles the interaction by launching an Activity and sending a follow-up message without coordinating with the app", + "const": 2 + } + ], "format": "int32" }, "ApplicationCommandIntegerOption": { @@ -23170,7 +23181,18 @@ }, "EntitlementOwnerTypes": { "type": "integer", - "oneOf": [], + "oneOf": [ + { + "title": "GUILD", + "description": "A guild subscription", + "const": 1 + }, + { + "title": "USER", + "description": "A user subscription", + "const": 2 + } + ], "format": "int32" }, "EntitlementResponse": { @@ -32002,7 +32024,68 @@ }, "NameplatePalette": { "type": "string", - "oneOf": [] + "oneOf": [ + { + "title": "CRIMSON", + "description": "Crimson color palette", + "const": "crimson" + }, + { + "title": "BERRY", + "description": "Berry color palette", + "const": "berry" + }, + { + "title": "SKY", + "description": "Sky color palette", + "const": "sky" + }, + { + "title": "TEAL", + "description": "Teal color palette", + "const": "teal" + }, + { + "title": "FOREST", + "description": "Forest color palette", + "const": "forest" + }, + { + "title": "BUBBLE_GUM", + "description": "Bubble gum color palette", + "const": "bubble_gum" + }, + { + "title": "VIOLET", + "description": "Violet color palette", + "const": "violet" + }, + { + "title": "COBALT", + "description": "Cobalt color palette", + "const": "cobalt" + }, + { + "title": "CLOVER", + "description": "Clover color palette", + "const": "clover" + }, + { + "title": "LEMON", + "description": "Lemon color palette", + "const": "lemon" + }, + { + "title": "WHITE", + "description": "White color palette", + "const": "white" + }, + { + "title": "BLACK", + "description": "Black color palette", + "const": "black" + } + ] }, "NewMemberActionResponse": { "type": "object", @@ -32782,7 +32865,13 @@ }, "PollLayoutTypes": { "type": "integer", - "oneOf": [], + "oneOf": [ + { + "title": "DEFAULT", + "description": "The, uhm, default layout type.", + "const": 1 + } + ], "format": "int32" }, "PollMedia": { @@ -39906,4 +39995,4 @@ } } } -} \ No newline at end of file +} diff --git a/specs/gcore.yaml b/specs/gcore.yaml index ebf1019..f41a8e0 100644 --- a/specs/gcore.yaml +++ b/specs/gcore.yaml @@ -1046,7 +1046,7 @@ paths: schema: description: Server ID examples: - - 024a29e-b4b7-4c91-9a46-505be123d9f8 + - 024a29e9-b4b7-4c91-9a46-505be123d9f8 format: uuid4 title: Server Id type: string @@ -1091,7 +1091,7 @@ paths: schema: description: Server ID examples: - - 024a29e-b4b7-4c91-9a46-505be123d9f8 + - 024a29e9-b4b7-4c91-9a46-505be123d9f8 format: uuid4 title: Server Id type: string @@ -1141,7 +1141,7 @@ paths: schema: description: Server ID examples: - - 024a29e-b4b7-4c91-9a46-505be123d9f8 + - 024a29e9-b4b7-4c91-9a46-505be123d9f8 format: uuid4 title: Server Id type: string @@ -1220,7 +1220,7 @@ paths: schema: description: Server ID examples: - - 024a29e-b4b7-4c91-9a46-505be123d9f8 + - 024a29e9-b4b7-4c91-9a46-505be123d9f8 format: uuid4 title: Server Id type: string @@ -1271,7 +1271,7 @@ paths: schema: description: Server ID examples: - - 024a29e-b4b7-4c91-9a46-505be123d9f8 + - 024a29e9-b4b7-4c91-9a46-505be123d9f8 format: uuid4 title: Server Id type: string @@ -18585,7 +18585,7 @@ paths: schema: description: Optional. Can be used to only show subnets of the specific network examples: - - 123e4567-e89b-12d3-a456-426614174000 + - 123e4567-e89b-42d3-a456-426614174000 format: uuid4 title: Network Id type: string @@ -64761,7 +64761,7 @@ components: existing_floating_id: 57be69f6-6f6a-4f03-a4ad-8eb86c69ec0a source: existing network_id: 53609647-2619-420a-b046-59905c8e3370 - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet - network_id: 783b36b4-3ef4-48ac-879d-5b3ea53180d8 subnet_id: 382a83e5-1b38-49f9-bd83-730353b29ed4 @@ -68019,7 +68019,7 @@ components: existing_floating_id: 57be69f6-6f6a-4f03-a4ad-8eb86c69ec0a source: existing network_id: 59905c8e-2619-420a-b046-536096473370 - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet properties: floating_ip: @@ -72677,7 +72677,7 @@ components: existing_floating_id: description: An existing available floating IP id must be specified if the source is set to `existing` examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Existing Floating Id type: string @@ -78711,7 +78711,7 @@ components: existing_floating_id: 57be69f6-6f6a-4f03-a4ad-8eb86c69ec0a source: existing network_id: 53609647-2619-420a-b046-59905c8e3370 - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet - network_id: 783b36b4-3ef4-48ac-879d-5b3ea53180d8 subnet_id: 382a83e5-1b38-49f9-bd83-730353b29ed4 @@ -81383,11 +81383,6 @@ components: title: K8sClusterSlurmAddonV2Serializer type: object K8sClusterSlurmAddonV2Serializers: - discriminator: - mapping: - 'False': '#/components/schemas/K8sClusterSlurmAddonDisableV2Serializer' - 'True': '#/components/schemas/K8sClusterSlurmAddonEnableV2Serializer' - propertyName: enabled title: K8sClusterSlurmAddonV2Serializers anyOf: - $ref: '#/components/schemas/K8sClusterSlurmAddonEnableV2Serializer' @@ -85998,7 +85993,7 @@ components: existing_floating_id: 57be69f6-6f6a-4f03-a4ad-8eb86c69ec0a source: existing network_id: 59905c8e-2619-420a-b046-536096473370 - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet NetworkSubnetworkCollectionSerializer: properties: @@ -86346,7 +86341,7 @@ components: - network_id description: Instance will be attached to the network subnet with the largest count of available ips example: - network_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + network_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: any_subnet security_groups: - id: 4536dba1-93b1-492e-b3df-270b6b9f3650 @@ -86595,7 +86590,7 @@ components: existing_floating_id: 57be69f6-6f6a-4f03-a4ad-8eb86c69ec0a source: existing network_id: 59905c8e-2619-420a-b046-536096473370 - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet properties: floating_ip: @@ -86679,7 +86674,7 @@ components: - subnet_id description: Instance will be attached to specified subnet example: - subnet_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + subnet_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 type: subnet security_groups: - id: 4536dba1-93b1-492e-b3df-270b6b9f3650 @@ -86706,7 +86701,7 @@ components: network_id: description: Reserved fixed IP will be allocated in a subnet of this network examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Network Id type: string @@ -86762,7 +86757,7 @@ components: network_id: description: Reserved fixed IP will be allocated in a subnet of this network examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Network Id type: string @@ -86808,7 +86803,7 @@ components: subnet_id: description: Reserved fixed IP will be allocated in this subnet examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Subnet Id type: string @@ -93571,7 +93566,7 @@ components: attachments: description: Reserved fixed IP attachment entities examples: - - - resource_id: e3c6ee77-48cb-416b-b204-11b492cc776e3 + - - resource_id: e3c6ee77-48cb-416b-b204-1b492cc776e3 resource_type: instance - resource_id: e73a4dbd-da04-4b6e-8ef9-07e8742001b7 resource_type: ai_cluster @@ -102331,7 +102326,7 @@ components: subnet_id: description: Port is assigned an IP address from this subnet examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Subnet Id type: string @@ -102368,7 +102363,7 @@ components: subnet_id: description: Port is assigned an IP address from this subnet examples: - - e3c6ee77-48cb-416b-b204-11b492cc776e3 + - e3c6ee77-48cb-416b-b204-1b492cc776e3 format: uuid4 title: Subnet Id type: string diff --git a/specs/gitpod.yaml b/specs/gitpod.yaml index 03cb9fc..33972a6 100644 --- a/specs/gitpod.yaml +++ b/specs/gitpod.yaml @@ -16303,7 +16303,7 @@ components: title: status required: - id - - environment_id + - environmentId title: Service type: object gitpod.v1.ServiceAccount: @@ -17379,7 +17379,7 @@ components: title: spec required: - id - - environment_id + - environmentId title: Task type: object gitpod.v1.TaskExecution: diff --git a/specs/imagekit.yaml b/specs/imagekit.yaml index b48f144..f7b705a 100644 --- a/specs/imagekit.yaml +++ b/specs/imagekit.yaml @@ -3936,14 +3936,14 @@ components: extensions: $ref: "#/components/schemas/Extensions" tags: - type: [array, null] + type: [array, "null"] items: type: string description: > An array of tags associated with the file, such as `["tag1", "tag2"]`. Send `null` to unset all tags associated with the file. example: ["tag1", "tag2"] customCoordinates: - type: [string, null] + type: [string, "null"] description: | Define an important area in the image in the format `x,y,width,height` e.g. `10,10,100,100`. Send `null` to unset this value. customMetadata: @@ -5799,13 +5799,13 @@ components: description: | Path of the file. This is the path you would use in the URL to access the file. For example, if the file is at the root of the media library, the path will be `/file.jpg`. If the file is inside a folder named `images`, the path will be `/images/file.jpg`. tags: - type: [array, null] + type: [array, "null"] items: type: string description: | An array of tags assigned to the file. Tags are used to search files in the media library. AITags: - type: [array, null] + type: [array, "null"] items: type: object properties: @@ -5840,7 +5840,7 @@ components: description: | Specifies if the file is published or not. customCoordinates: - type: [string, null] + type: [string, "null"] description: | An string with custom coordinates of the file. url: @@ -6080,12 +6080,12 @@ components: type: string description: The video codec used in the video (only for video). tags: - type: [array, null] + type: [array, "null"] items: type: string description: The array of tags associated with the asset. If no tags are set, it will be `null`. Send `tags` in `responseFields` in API request to get the value of this field. AITags: - type: [array, null] + type: [array, "null"] items: type: object properties: @@ -6120,7 +6120,7 @@ components: description: | Is the file published or in draft state. It can be either `true` or `false`. Send `isPublished` in `responseFields` in API request to get the value of this field. customCoordinates: - type: [string, null] + type: [string, "null"] description: | Value of custom coordinates associated with the image in the format `x,y,width,height`. If `customCoordinates` are not defined, then it is `null`. Send `customCoordinates` in `responseFields` in API request to get the value of this field. fileType: diff --git a/specs/letta.yaml b/specs/letta.yaml index ac20d91..cd24b48 100644 --- a/specs/letta.yaml +++ b/specs/letta.yaml @@ -20509,6 +20509,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "AnthropicModelSettings" }, "AnthropicThinking": { @@ -20637,7 +20638,7 @@ } }, "type": "object", - "required": ["id", "date", "tool_call"], + "required": ["id", "date", "tool_call", "message_type"], "title": "ApprovalRequestMessage", "description": "A message representing a request for approval to call a tool (generated by the LLM to trigger tool execution).\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n tool_call (ToolCall): The tool call" }, @@ -20706,7 +20707,7 @@ } }, "type": "object", - "required": ["id", "date"], + "required": ["id", "date", "message_type"], "title": "ApprovalResponseMessage", "description": "A message representing a response form the user indicating whether a tool has been approved to run.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n approve: (bool) Whether the tool has been approved\n approval_request_id: The ID of the approval request\n reason: (Optional[str]) An optional explanation for the provided approval status" }, @@ -20736,7 +20737,7 @@ } }, "type": "object", - "required": ["tool_call_id", "approve"], + "required": ["tool_call_id", "approve", "type"], "title": "ApprovalReturn" }, "ArchivalMemorySearchResponse": { @@ -20834,7 +20835,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["created_at", "name", "organization_id", "id"], + "required": ["created_at", "name", "id"], "title": "Archive", "description": "Representation of an archive - a collection of archival passages that can be shared between agents." }, @@ -20902,7 +20903,7 @@ } }, "type": "object", - "required": ["id", "date", "content"], + "required": ["id", "date", "content", "message_type"], "title": "AssistantMessage", "description": "A message sent by the LLM in response to user input. Used in the LLM context.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (Union[str, List[LettaAssistantMessageContentUnion]]): The message content sent by the agent (can be a string or an array of content parts)" }, @@ -20948,7 +20949,7 @@ } }, "type": "object", - "required": ["content", "message_id", "created_at"], + "required": ["content", "message_id", "created_at", "message_type"], "title": "AssistantMessageListResult", "description": "Assistant message list result with agent context.\n\nShape is identical to UpdateAssistantMessage but includes the owning agent_id and message id." }, @@ -21034,6 +21035,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "AzureModelSettings", "description": "Azure OpenAI model configuration (OpenAI-compatible)." }, @@ -21059,7 +21061,7 @@ } }, "type": "object", - "required": ["media_type", "data"], + "required": ["media_type", "data", "type"], "title": "Base64Image" }, "BaseToolRuleSchema": { @@ -21100,6 +21102,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "BasetenModelSettings", "description": "Baseten model configuration (OpenAI-compatible)." }, @@ -21250,6 +21253,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "BedrockModelSettings", "description": "AWS Bedrock model configuration." }, @@ -22207,6 +22211,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "ChatGPTOAuthModelSettings", "description": "ChatGPT OAuth model configuration (uses ChatGPT backend API)." }, @@ -22259,7 +22264,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name", "children"], + "required": ["tool_name", "children", "type"], "title": "ChildToolRule", "description": "A ToolRule represents a tool that can be invoked by the agent." }, @@ -22650,7 +22655,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name", "child_output_mapping"], + "required": ["tool_name", "child_output_mapping", "type"], "title": "ConditionalToolRule", "description": "A ToolRule that conditionally maps to different child tools based on the output." }, @@ -22833,7 +22838,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name"], + "required": ["tool_name", "type"], "title": "ContinueToolRule", "description": "Represents a tool rule configuration where if this tool gets called, it must continue the agent loop." }, @@ -23826,7 +23831,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["server_url"], + "required": ["server_url", "mcp_server_type"], "title": "CreateSSEMCPServer", "description": "Create a new SSE MCP server" }, @@ -23857,7 +23862,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["command", "args"], + "required": ["command", "args", "mcp_server_type"], "title": "CreateStdioMCPServer", "description": "Create a new Stdio MCP server" }, @@ -23888,7 +23893,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["server_url"], + "required": ["server_url", "mcp_server_type"], "title": "CreateStreamableHTTPMCPServer", "description": "Create a new Streamable HTTP MCP server" }, @@ -23964,6 +23969,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "DeepseekModelSettings", "description": "Deepseek model configuration (OpenAI-compatible)." }, @@ -24045,7 +24051,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "DynamicManager" }, "DynamicManagerSchema": { @@ -24071,7 +24077,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "DynamicManagerSchema" }, "DynamicManagerUpdate": { @@ -24110,6 +24116,7 @@ } }, "type": "object", + "required": ["manager_type"], "title": "DynamicManagerUpdate" }, "E2BSandboxConfig": { @@ -24362,7 +24369,7 @@ "event_data": { "additionalProperties": true, "type": "object", "title": "Event Data" } }, "type": "object", - "required": ["id", "date", "event_type", "event_data"], + "required": ["id", "date", "event_type", "event_data", "message_type"], "title": "EventMessage", "description": "A message for notifying the developer that an event that has occured (e.g. a compaction). Events are NOT part of the context window." }, @@ -25065,6 +25072,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "GoogleAIModelSettings" }, "GoogleVertexModelSettings": { @@ -25123,6 +25131,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "GoogleVertexModelSettings" }, "GroqModelSettings": { @@ -25176,6 +25185,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "GroqModelSettings", "description": "Groq model configuration (OpenAI-compatible)." }, @@ -25529,7 +25539,7 @@ } }, "type": "object", - "required": ["id", "date", "state"], + "required": ["id", "date", "state", "message_type"], "title": "HiddenReasoningMessage", "description": "Representation of an agent's internal reasoning where reasoning content\nhas been hidden from the response.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n state (Literal[\"redacted\", \"omitted\"]): Whether the reasoning\n content was redacted by the provider or simply omitted by the API\n hidden_reasoning (Optional[str]): The internal reasoning of the agent" }, @@ -25853,7 +25863,7 @@ } }, "type": "object", - "required": ["source"], + "required": ["source", "type"], "title": "ImageContent" }, "ImageURL": { @@ -25900,7 +25910,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name"], + "required": ["tool_name", "type"], "title": "InitToolRule", "description": "Represents the initial tool rule configuration." }, @@ -26628,6 +26638,7 @@ } }, "type": "object", + "required": ["type"], "title": "JsonObjectResponseFormat", "description": "Response format for JSON object responses." }, @@ -26648,7 +26659,7 @@ } }, "type": "object", - "required": ["json_schema"], + "required": ["json_schema", "type"], "title": "JsonSchemaResponseFormat", "description": "Response format for JSON schema-based responses." }, @@ -27231,7 +27242,7 @@ } }, "type": "object", - "required": ["file_id"], + "required": ["file_id", "type"], "title": "LettaImage" }, "LettaPing": { @@ -27258,7 +27269,7 @@ "run_id": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Run Id" } }, "type": "object", - "required": ["id", "date"], + "required": ["id", "date", "message_type"], "title": "LettaPing", "description": "A ping message used as a keepalive to prevent SSE streams from timing out during long running requests.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format" }, @@ -27495,7 +27506,7 @@ } }, "type": "object", - "required": ["stop_reason"], + "required": ["stop_reason", "message_type"], "title": "LettaStopReason", "description": "The stop reason from Letta indicating why agent loop stopped execution." }, @@ -27769,6 +27780,7 @@ } }, "type": "object", + "required": ["message_type"], "title": "LettaUsageStatistics", "description": "Usage statistics for the agent interaction.\n\nAttributes:\n completion_tokens (int): The number of tokens generated by the agent.\n prompt_tokens (int): The number of tokens in the prompt.\n total_tokens (int): The total number of tokens processed by the agent.\n step_count (int): The number of steps taken by the agent.\n cached_input_tokens (Optional[int]): The number of input tokens served from cache. None if not reported.\n cache_write_tokens (Optional[int]): The number of input tokens written to cache. None if not reported.\n reasoning_tokens (Optional[int]): The number of reasoning/thinking tokens generated. None if not reported." }, @@ -27921,7 +27933,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name", "max_count_limit"], + "required": ["tool_name", "max_count_limit", "type"], "title": "MaxCountPerStepToolRule", "description": "Represents a tool rule configuration which constrains the total number of times this tool can be invoked in a single step." }, @@ -28671,6 +28683,7 @@ } }, "type": "object", + "required": ["type"], "title": "OmittedReasoningContent", "description": "A placeholder for reasoning content we know is present, but isn't returned by the provider (e.g. OpenAI GPT-5 on ChatCompletions)" }, @@ -28736,6 +28749,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "OpenAIModelSettings" }, "OpenAIReasoning": { @@ -28802,6 +28816,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "OpenRouterModelSettings", "description": "OpenRouter model configuration (OpenAI-compatible)." }, @@ -28983,7 +28998,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name", "children"], + "required": ["tool_name", "children", "type"], "title": "ParentToolRule", "description": "A ToolRule that only allows a child tool to be called if the parent has been called." }, @@ -29551,7 +29566,7 @@ } }, "type": "object", - "required": ["is_native", "reasoning"], + "required": ["is_native", "reasoning", "type"], "title": "ReasoningContent", "description": "Sent via the Anthropic Messages API" }, @@ -29587,7 +29602,7 @@ "signature": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Signature" } }, "type": "object", - "required": ["id", "date", "reasoning"], + "required": ["id", "date", "reasoning", "message_type"], "title": "ReasoningMessage", "description": "Representation of an agent's internal reasoning.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n source (Literal[\"reasoner_model\", \"non_reasoner_model\"]): Whether the reasoning\n content was generated natively by a reasoner model or derived via prompting\n reasoning (str): The internal reasoning of the agent\n signature (Optional[str]): The model-generated signature of the reasoning step" }, @@ -29623,7 +29638,7 @@ } }, "type": "object", - "required": ["reasoning", "message_id", "created_at"], + "required": ["reasoning", "message_id", "created_at", "message_type"], "title": "ReasoningMessageListResult", "description": "Reasoning message list result with agent context.\n\nShape is identical to UpdateReasoningMessage but includes the owning agent_id and message id." }, @@ -29643,7 +29658,7 @@ } }, "type": "object", - "required": ["data"], + "required": ["data", "type"], "title": "RedactedReasoningContent", "description": "Sent via the Anthropic Messages API" }, @@ -29668,7 +29683,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name"], + "required": ["tool_name", "type"], "title": "RequiredBeforeExitToolRule", "description": "Represents a tool rule configuration where this tool must be called before the agent loop can exit." }, @@ -29693,7 +29708,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name"], + "required": ["tool_name", "type"], "title": "RequiresApprovalToolRule", "description": "Represents a tool rule configuration which requires approval before the tool can be invoked." }, @@ -29771,6 +29786,7 @@ } }, "type": "object", + "required": ["manager_type"], "title": "RoundRobinManager" }, "RoundRobinManagerUpdate": { @@ -29789,6 +29805,7 @@ } }, "type": "object", + "required": ["manager_type"], "title": "RoundRobinManagerUpdate" }, "Run": { @@ -30012,6 +30029,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "SGLangModelSettings", "description": "SGLang model configuration (OpenAI-compatible runtime with SGLang-specific parsing)." }, @@ -30389,7 +30407,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "SleeptimeManager" }, "SleeptimeManagerSchema": { @@ -30409,7 +30427,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "SleeptimeManagerSchema" }, "SleeptimeManagerUpdate": { @@ -30443,6 +30461,7 @@ } }, "type": "object", + "required": ["manager_type"], "title": "SleeptimeManagerUpdate" }, "Source": { @@ -31087,7 +31106,7 @@ } }, "type": "object", - "required": ["id", "summary"], + "required": ["id", "summary", "type"], "title": "SummarizedReasoningContent", "description": "The style of reasoning content returned by the OpenAI Responses API" }, @@ -31127,7 +31146,7 @@ } }, "type": "object", - "required": ["id", "date", "summary"], + "required": ["id", "date", "summary", "message_type"], "title": "SummaryMessage", "description": "A message representing a summary of the conversation. Sent to the LLM as a user or system message depending on the provider." }, @@ -31151,7 +31170,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "SupervisorManager" }, "SupervisorManagerSchema": { @@ -31166,7 +31185,7 @@ "manager_agent_id": { "type": "string", "title": "Manager Agent Id", "description": "" } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "SupervisorManagerSchema" }, "SupervisorManagerUpdate": { @@ -31195,7 +31214,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "SupervisorManagerUpdate" }, "SystemMessage": { @@ -31227,7 +31246,7 @@ } }, "type": "object", - "required": ["id", "date", "content"], + "required": ["id", "date", "content", "message_type"], "title": "SystemMessage", "description": "A message generated by the system. Never streamed back on a response, only used for cursor pagination.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (str): The message content sent by the system" }, @@ -31267,7 +31286,7 @@ } }, "type": "object", - "required": ["content", "message_id", "created_at"], + "required": ["content", "message_id", "created_at", "message_type"], "title": "SystemMessageListResult", "description": "System message list result with agent context.\n\nShape is identical to UpdateSystemMessage but includes the owning agent_id and message id." }, @@ -31293,7 +31312,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["tool_name"], + "required": ["tool_name", "type"], "title": "TerminalToolRule", "description": "Represents a terminal tool rule configuration where if this tool gets called, it must end the agent loop." }, @@ -31314,7 +31333,7 @@ } }, "type": "object", - "required": ["text"], + "required": ["text", "type"], "title": "TextContent" }, "TextResponseFormat": { @@ -31328,6 +31347,7 @@ } }, "type": "object", + "required": ["type"], "title": "TextResponseFormat", "description": "Response format for plain text responses." }, @@ -31382,6 +31402,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "TogetherModelSettings", "description": "Together AI model configuration (OpenAI-compatible)." }, @@ -31555,7 +31576,7 @@ } }, "type": "object", - "required": ["id", "name", "input"], + "required": ["id", "name", "input", "type"], "title": "ToolCallContent" }, "ToolCallDelta": { @@ -31607,7 +31628,7 @@ } }, "type": "object", - "required": ["id", "date", "tool_call"], + "required": ["id", "date", "tool_call", "message_type"], "title": "ToolCallMessage", "description": "A message representing a request to call a tool (generated by the LLM to trigger tool execution).\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n tool_call (Union[ToolCall, ToolCallDelta]): The tool call" }, @@ -31794,7 +31815,7 @@ } }, "type": "object", - "required": ["tool_call_id", "content", "is_error"], + "required": ["tool_call_id", "content", "is_error", "type"], "title": "ToolReturnContent" }, "ToolReturnCreate": { @@ -31875,7 +31896,7 @@ } }, "type": "object", - "required": ["id", "date", "tool_return", "status", "tool_call_id"], + "required": ["id", "date", "tool_return", "status", "tool_call_id", "message_type"], "title": "ToolReturnMessage", "description": "A message representing the return value of a tool call (generated by Letta executing the requested tool).\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n tool_return (str): The return value of the tool (deprecated, use tool_returns)\n status (Literal[\"success\", \"error\"]): The status of the tool call (deprecated, use tool_returns)\n tool_call_id (str): A unique identifier for the tool call that generated this message (deprecated, use tool_returns)\n stdout (Optional[List(str)]): Captured stdout (e.g. prints, logs) from the tool invocation (deprecated, use tool_returns)\n stderr (Optional[List(str)]): Captured stderr from the tool invocation (deprecated, use tool_returns)\n tool_returns (Optional[List[ToolReturn]]): List of tool returns for multi-tool support" }, @@ -32681,7 +32702,7 @@ "url": { "type": "string", "title": "Url", "description": "The URL of the image." } }, "type": "object", - "required": ["url"], + "required": ["url", "type"], "title": "UrlImage" }, "UsageStatistics": { @@ -32769,7 +32790,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["name", "organization_id"], + "required": ["name"], "title": "UserCreate" }, "UserMessage": { @@ -32804,7 +32825,7 @@ } }, "type": "object", - "required": ["id", "date", "content"], + "required": ["id", "date", "content", "message_type"], "title": "UserMessage", "description": "A message sent by the user. Never streamed back on a response, only used for cursor pagination.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (Union[str, List[LettaUserMessageContentUnion]]): The message content sent by the user (can be a string or an array of multi-modal content parts)" }, @@ -32847,7 +32868,7 @@ } }, "type": "object", - "required": ["content", "message_id", "created_at"], + "required": ["content", "message_id", "created_at", "message_type"], "title": "UserMessageListResult", "description": "User message list result with agent context.\n\nShape is identical to UpdateUserMessage but includes the owning agent_id and message id." }, @@ -32923,7 +32944,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "VoiceSleeptimeManager" }, "VoiceSleeptimeManagerSchema": { @@ -32948,7 +32969,7 @@ } }, "type": "object", - "required": ["manager_agent_id"], + "required": ["manager_agent_id", "manager_type"], "title": "VoiceSleeptimeManagerSchema" }, "VoiceSleeptimeManagerUpdate": { @@ -32987,6 +33008,7 @@ } }, "type": "object", + "required": ["manager_type"], "title": "VoiceSleeptimeManagerUpdate" }, "XAIModelSettings": { @@ -33040,6 +33062,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "XAIModelSettings", "description": "xAI model configuration (OpenAI-compatible)." }, @@ -33099,6 +33122,7 @@ } }, "type": "object", + "required": ["provider_type"], "title": "ZAIModelSettings", "description": "Z.ai (ZhipuAI) model configuration (OpenAI-compatible)." }, @@ -33780,7 +33804,7 @@ } }, "type": "object", - "required": ["tool_return", "status", "tool_call_id"], + "required": ["tool_return", "status", "tool_call_id", "type"], "title": "ToolReturn" }, "letta__schemas__mcp__UpdateSSEMCPServer": { @@ -33906,7 +33930,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["server_url"], + "required": ["server_url", "mcp_server_type"], "title": "UpdateSSEMCPServer", "description": "Update schema for SSE MCP server - all fields optional" }, @@ -33936,7 +33960,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["command", "args"], + "required": ["command", "args", "mcp_server_type"], "title": "UpdateStdioMCPServer", "description": "Update schema for Stdio MCP server - all fields optional" }, @@ -33971,7 +33995,7 @@ }, "additionalProperties": false, "type": "object", - "required": ["server_url"], + "required": ["server_url", "mcp_server_type"], "title": "UpdateStreamableHTTPMCPServer", "description": "Update schema for Streamable HTTP MCP server - all fields optional" }, @@ -34463,7 +34487,7 @@ "text": "#/components/schemas/TextContent", "image": "#/components/schemas/ImageContent", "tool_call": "#/components/schemas/ToolCallContent", - "tool_return": "#/components/schemas/ToolCallContent", + "tool_return": "#/components/schemas/ToolReturnContent", "reasoning": "#/components/schemas/ReasoningContent", "redacted_reasoning": "#/components/schemas/RedactedReasoningContent", "omitted_reasoning": "#/components/schemas/OmittedReasoningContent" diff --git a/specs/meta-llama.yaml b/specs/meta-llama.yaml index 46dbc82..a194fad 100644 --- a/specs/meta-llama.yaml +++ b/specs/meta-llama.yaml @@ -670,7 +670,7 @@ components: discriminator: propertyName: type mapping: - image: '#/components/schemas/MessageImageContentItem' + image_url: '#/components/schemas/MessageImageContentItem' text: '#/components/schemas/MessageTextContentItem' AssistantMessageContentItem: oneOf: diff --git a/specs/storyden.yaml b/specs/storyden.yaml index e666097..a5ad0da 100644 --- a/specs/storyden.yaml +++ b/specs/storyden.yaml @@ -7364,35 +7364,35 @@ components: type: object required: [type, thread_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [thread_deleted] } thread_id: { $ref: "#/components/schemas/Identifier" } AuditEventThreadReplyDeleted: type: object required: [type, reply_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [thread_reply_deleted] } reply_id: { $ref: "#/components/schemas/Identifier" } AuditEventAccountSuspended: type: object required: [type, account_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_suspended] } account_id: { $ref: "#/components/schemas/Identifier" } AuditEventAccountUnsuspended: type: object required: [type, account_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_unsuspended] } account_id: { $ref: "#/components/schemas/Identifier" } AuditEventAccountContentPurged: type: object required: [type, account_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_content_purged] } account_id: { $ref: "#/components/schemas/Identifier" } included: $ref: "#/components/schemas/ModerationActionPurgeAccountContentTypeList" @@ -7401,7 +7401,7 @@ components: type: object required: [type, account_id, note_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [moderation_note_created] } account_id: { $ref: "#/components/schemas/Identifier" } note_id: { $ref: "#/components/schemas/Identifier" } @@ -7409,7 +7409,7 @@ components: type: object required: [type, account_id, note_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [moderation_note_deleted] } account_id: { $ref: "#/components/schemas/Identifier" } note_id: { $ref: "#/components/schemas/Identifier" } @@ -7417,7 +7417,7 @@ components: type: object required: [type, account_id, warning_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_warned] } account_id: { $ref: "#/components/schemas/Identifier" } warning_id: { $ref: "#/components/schemas/Identifier" } @@ -7425,7 +7425,7 @@ components: type: object required: [type, account_id, warning_id, previous_reason, reason] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_warning_updated] } account_id: { $ref: "#/components/schemas/Identifier" } warning_id: { $ref: "#/components/schemas/Identifier" } previous_reason: { $ref: "#/components/schemas/WarningReason" } @@ -7435,7 +7435,7 @@ components: type: object required: [type, account_id, warning_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_warning_deleted] } account_id: { $ref: "#/components/schemas/Identifier" } warning_id: { $ref: "#/components/schemas/Identifier" } @@ -7443,7 +7443,7 @@ components: type: object required: [type, account_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_password_reset_token_issued] } account_id: description: Target account ID that received password reset access. $ref: "#/components/schemas/Identifier" @@ -7452,7 +7452,7 @@ components: type: object required: [type, account_id, email_address_id] properties: - type: { $ref: "#/components/schemas/AuditEventType" } + type: { type: string, enum: [account_password_reset_email_sent] } account_id: description: Target account ID that received the password reset email. $ref: "#/components/schemas/Identifier" @@ -7640,13 +7640,13 @@ components: type: object required: [mode] properties: - mode: { $ref: "#/components/schemas/PluginMode" } + mode: { type: string, enum: [supervised] } PluginExternalProps: type: object required: [mode, token] properties: - mode: { $ref: "#/components/schemas/PluginMode" } + mode: { type: string, enum: [external] } token: type: string description: | @@ -7683,7 +7683,7 @@ components: type: object required: [mode, url] properties: - mode: { $ref: "#/components/schemas/PluginMode" } + mode: { type: string, enum: [supervised] } url: type: string format: uri @@ -7697,7 +7697,7 @@ components: type: object required: [mode, manifest] properties: - mode: { $ref: "#/components/schemas/PluginMode" } + mode: { type: string, enum: [external] } manifest: $ref: "#/components/schemas/PluginManifest" @@ -11401,35 +11401,35 @@ components: type: object required: [kind, ref] properties: - kind: { $ref: "#/components/schemas/DatagraphItemKind" } + kind: { type: string, enum: [post] } ref: { $ref: "#/components/schemas/Post" } DatagraphItemThread: type: object required: [kind, ref] properties: - kind: { $ref: "#/components/schemas/DatagraphItemKind" } + kind: { type: string, enum: [thread] } ref: { $ref: "#/components/schemas/Thread" } DatagraphItemReply: type: object required: [kind, ref] properties: - kind: { $ref: "#/components/schemas/DatagraphItemKind" } + kind: { type: string, enum: [reply] } ref: { $ref: "#/components/schemas/Reply" } DatagraphItemNode: type: object required: [kind, ref] properties: - kind: { $ref: "#/components/schemas/DatagraphItemKind" } + kind: { type: string, enum: [node] } ref: { $ref: "#/components/schemas/Node" } DatagraphItemProfile: type: object required: [kind, ref] properties: - kind: { $ref: "#/components/schemas/DatagraphItemKind" } + kind: { type: string, enum: [profile] } ref: { $ref: "#/components/schemas/PublicProfile" } DatagraphMatch: diff --git a/specs/vercel.json b/specs/vercel.json index bb1a77c..df36976 100644 --- a/specs/vercel.json +++ b/specs/vercel.json @@ -23911,6 +23911,11 @@ "srv" ], "properties": { + "name": { + "description": "A subdomain name or an empty string for the root domain.", + "type": "string", + "example": "subdomain" + }, "type": { "description": "Must be of type `SRV`.", "type": "string", @@ -23991,6 +23996,11 @@ "name" ], "properties": { + "name": { + "description": "A subdomain name or an empty string for the root domain.", + "type": "string", + "example": "subdomain" + }, "type": { "description": "Must be of type `TXT`.", "type": "string", @@ -24067,6 +24077,11 @@ "https" ], "properties": { + "name": { + "description": "A subdomain name or an empty string for the root domain.", + "type": "string", + "example": "subdomain" + }, "type": { "description": "Must be of type `HTTPS`.", "type": "string", @@ -158301,4 +158316,4 @@ } }, "security": [] -} \ No newline at end of file +} diff --git a/src/analysis.rs b/src/analysis.rs index fd207e0..25617d3 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -120,6 +120,7 @@ impl SchemaType { | Self::Reference { .. } | Self::Array { .. } | Self::Tuple { .. } + | Self::Nullable { .. } | Self::Untyped { .. } => true, Self::Object { .. } | Self::StringEnum { .. } @@ -177,6 +178,9 @@ fn collect_type_dependencies( targets.insert(target.clone()); } SchemaType::Array { item_type } => collect_type_dependencies(item_type, targets, depth + 1), + SchemaType::Nullable { inner_type } => { + collect_type_dependencies(inner_type, targets, depth + 1) + } SchemaType::Tuple { element_types } => { for element_type in element_types { collect_type_dependencies(element_type, targets, depth + 1); @@ -194,7 +198,7 @@ fn collect_type_dependencies( collect_type_dependencies(value_type, targets, depth + 1); } } - SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + SchemaType::Union { variants, .. } | SchemaType::Composition { schemas: variants } => { for variant in variants { targets.insert(variant.target.clone()); } @@ -251,6 +255,7 @@ fn normalize_untyped(schema_type: &mut SchemaType, depth: usize) { } } SchemaType::Array { item_type } => normalize_untyped(item_type, depth + 1), + SchemaType::Nullable { inner_type } => normalize_untyped(inner_type, depth + 1), SchemaType::Tuple { element_types } => { for element_type in element_types { normalize_untyped(element_type, depth + 1); @@ -355,6 +360,9 @@ fn collect_untyped( collect_untyped(item_type, &element_context, findings, depth + 1); } } + SchemaType::Nullable { inner_type } => { + collect_untyped(inner_type, context, findings, depth + 1) + } SchemaType::Tuple { element_types } => { for (index, element_type) in element_types.iter().enumerate() { collect_untyped( @@ -367,7 +375,7 @@ fn collect_untyped( } // A union branch that mapped to an untyped Rust type is carried as a // variant target string, so it is recognized by name here. - SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + SchemaType::Union { variants, .. } | SchemaType::Composition { schemas: variants } => { for (index, variant) in variants.iter().enumerate() { if let Some(shape) = untyped_shape_of(&variant.target) { findings.push(UntypedFinding { @@ -467,6 +475,10 @@ pub enum UntypedReason { /// generated union carries a `serde_json::Value` variant. Whether that is /// faithful depends on the branch, which the analyzed type no longer says. UntypedUnionBranch, + /// A `false` schema: nothing validates against it, so there is no value to + /// give a type. Legal anywhere a schema is, and written to forbid a + /// property or close a tuple. + NeverMatches, /// Reached a fallback that has not been classified yet. Every one of these /// is a gap in this taxonomy, not in the generator. Unclassified, @@ -481,6 +493,8 @@ impl UntypedReason { Self::AnySchema | Self::OpaqueObject | Self::UntypedAdditionalProperties => { UntypedVerdict::Faithful } + // Nothing validates against `false`, so nothing is being lost. + Self::NeverMatches => UntypedVerdict::Faithful, // An array with no `items` says nothing about elements, and open // positional items permit extras of any type: both are the spec's // choice, not a dropped constraint. @@ -607,15 +621,29 @@ pub enum SchemaType { /// halves survive; `None` for a plain object. variant: Option, }, - /// Discriminated union (oneOf + discriminator) + /// Discriminated union (`oneOf`/`anyOf` + discriminator). DiscriminatedUnion { discriminator_field: String, variants: Vec, + /// `oneOf` requires a unique structural match; `anyOf` permits a + /// deterministic first match when the discriminator is absent or its + /// preferred branch does not fit. + exclusive: bool, + }, + /// Simple union. Exclusive unions originate from `oneOf` and require + /// exactly one branch to preserve the complete input shape; non-exclusive + /// unions retain `anyOf`/multi-type first-match semantics. + Union { + variants: Vec, + exclusive: bool, }, - /// Simple union (anyOf without discriminator) - Union { variants: Vec }, /// Array type Array { item_type: Box }, + /// A nullable value in a container position. Object properties carry + /// nullability separately because their `Option` also participates in + /// required-vs-missing serde behavior; array items, tuple positions, and + /// typed additional-property values need an inline wrapper instead. + Nullable { inner_type: Box }, /// Fixed-arity array — one schema per position, no extras — rendered as a /// Rust tuple. Only emitted when the spec proves the length (see /// `SchemaDetails::positional_items_are_exact`); an open `prefixItems` @@ -648,8 +676,10 @@ pub enum SchemaType { /// value-type schema instead of degrading to `serde_json::Value`. #[derive(Debug, Clone)] pub enum ObjectAdditionalProperties { - /// `additionalProperties: false` or absent — extra keys are - /// rejected and no extra field is emitted. + /// No catch-all field is emitted. This is exact for + /// `additionalProperties: false`; for an omitted keyword it is the + /// generator's historical closed-model projection and is used only while + /// no required unknown member forces an open carrier. Forbidden, /// `additionalProperties: true` — extra keys captured as /// `BTreeMap`. @@ -674,6 +704,11 @@ pub struct PropertyInfo { pub description: Option, pub default: Option, pub serde_attrs: Vec, + /// True when this field was synthesized from a `required` name that the + /// schema did not also declare in `properties`. Keeping that provenance + /// lets allOf merging prefer a real sibling declaration regardless of + /// branch order. + pub synthesized_required: bool, /// Q2.4: OpenAPI constraint annotations captured from the /// property schema. Surfaced by the generator as `/// Constraint: /// …` doc lines and/or `#[validate(...)]` attributes depending on @@ -730,8 +765,14 @@ impl PropertyConstraints { _ => None, }; Self { - minimum: details.minimum, - maximum: details.maximum, + minimum: details + .minimum + .as_ref() + .and_then(serde_json::Number::as_f64), + maximum: details + .maximum + .as_ref() + .and_then(serde_json::Number::as_f64), exclusive_minimum, exclusive_maximum, multiple_of: details.multiple_of, @@ -749,7 +790,27 @@ impl PropertyConstraints { pub struct UnionVariant { pub rust_name: String, pub type_name: String, + /// Canonical discriminator value used when the payload does not already + /// carry one. This is always the first member of + /// `discriminator_values`. pub discriminator_value: String, + /// Every wire discriminator value accepted by this branch. JSON Schema + /// permits a discriminator property to use a multi-value enum, so a + /// branch is not necessarily identified by exactly one string. + pub discriminator_values: Vec, + /// Values for which this branch is the preferred first dispatch target. + /// Overlapping branch constraints remain in `discriminator_values` so + /// deserialization can fall back structurally when the preferred branch + /// does not fit the rest of the payload. + pub preferred_discriminator_values: Vec, + /// Whether the branch schema declares the discriminator property at all. + /// A mapped/tagless branch may legitimately omit it, in which case the + /// serializer must not invent a schema-name-derived wire field. + pub discriminator_field_declared: bool, + /// Whether the branch schema requires the discriminator property. + /// Missing-tag structural fallback is limited to branches where this is + /// false. + pub discriminator_field_required: bool, pub schema_ref: String, } @@ -1219,31 +1280,9 @@ fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi:: let (Some(first), None) = (references.next(), references.next()) else { return schema; }; - let others_annotation_only = all_of.iter().all(|member| { - if member.reference().is_some() { - return true; - } - serde_json::to_value(member) - .ok() - .and_then(|value| value.as_object().cloned()) - .is_some_and(|object| { - object.keys().all(|key| { - matches!( - key.as_str(), - "title" - | "description" - | "deprecated" - | "readOnly" - | "writeOnly" - | "examples" - | "example" - | "externalDocs" - | "xml" - | "$comment" - ) || key.starts_with("x-") - }) - }) - }); + let others_annotation_only = all_of + .iter() + .all(|member| member.reference().is_some() || schema_is_annotation_only(member)); if others_annotation_only { first } else { @@ -1251,6 +1290,35 @@ fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi:: } } +/// Whether a schema contributes annotations but no assertion to an +/// intersection. OpenAPI's `nullable` only modifies an adjacent `type`, so a +/// type-less nullable flag is neutral here. `default` and examples are JSON +/// Schema annotations as well. +fn schema_is_annotation_only(schema: &crate::openapi::Schema) -> bool { + serde_json::to_value(schema) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|object| { + object.keys().all(|key| { + matches!( + key.as_str(), + "title" + | "description" + | "deprecated" + | "readOnly" + | "writeOnly" + | "examples" + | "example" + | "default" + | "externalDocs" + | "xml" + | "$comment" + | "nullable" + ) || key.starts_with("x-") + }) + }) +} + /// Load an extension file and parse it into the JSON representation used by /// the analyzer. YAML extensions follow the same conversion policy as YAML /// OpenAPI documents; every other extension is parsed as JSON. @@ -1491,9 +1559,170 @@ fn extract_schema_variants(obj: &Value) -> Option> { None } +/// The source identity of a generated schema is distinct from the Rust-facing +/// name eventually allocated to it. Component names are reserved before any +/// traversal, while inline and deep-pointer identities retain enough +/// provenance to reuse their own allocation without impersonating a component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum InlineUnionKind { + OneOf, + AnyOf, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum SchemaIdentity { + Component(String), + Pointer(String), + InlineUnionBranch { + owner_context: String, + union_kind: InlineUnionKind, + original_index: usize, + discriminator: Option, + fingerprint: String, + }, + Inline { + context: String, + kind: &'static str, + preferred_name: String, + fingerprint: String, + }, +} + +#[derive(Debug, Default)] +struct SchemaNameRegistry { + names_by_identity: BTreeMap, + identities_by_name: BTreeMap, +} + +impl SchemaNameRegistry { + fn with_components(component_names: impl IntoIterator) -> Self { + let mut registry = Self::default(); + for name in component_names { + let identity = SchemaIdentity::Component(name.clone()); + registry + .names_by_identity + .insert(identity.clone(), name.clone()); + registry.identities_by_name.insert(name, identity); + } + registry + } + + fn component_name(&self, source_name: &str) -> Option<&str> { + self.names_by_identity + .get(&SchemaIdentity::Component(source_name.to_string())) + .map(String::as_str) + } + + fn allocate( + &mut self, + identity: SchemaIdentity, + preferred_name: &str, + collision_name: &str, + ) -> String { + if let Some(existing) = self.names_by_identity.get(&identity) { + return existing.clone(); + } + + let allocated = if !self.identities_by_name.contains_key(preferred_name) { + preferred_name.to_string() + } else if !self.identities_by_name.contains_key(collision_name) { + collision_name.to_string() + } else { + let hash = stable_schema_identity_hash(&identity); + let hashed = format!("{collision_name}{hash:016X}"); + if !self.identities_by_name.contains_key(&hashed) { + hashed + } else { + let mut suffix = 2; + loop { + let candidate = format!("{hashed}{suffix}"); + if !self.identities_by_name.contains_key(&candidate) { + break candidate; + } + suffix += 1; + } + } + }; + + self.names_by_identity + .insert(identity.clone(), allocated.clone()); + self.identities_by_name.insert(allocated.clone(), identity); + allocated + } +} + +/// Stable FNV-1a rather than `DefaultHasher`, whose output is deliberately not +/// a cross-version contract. This suffix is only a final fallback after both a +/// preferred and human-readable collision name are occupied. +fn stable_schema_identity_hash(identity: &SchemaIdentity) -> u64 { + let bytes = format!("{identity:?}"); + bytes + .as_bytes() + .iter() + .fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) +} + +#[cfg(test)] +mod schema_name_registry_tests { + use super::{SchemaIdentity, SchemaNameRegistry}; + + #[test] + fn component_reservations_are_independent_of_input_traversal_order() { + let component_names = ["ModelApi".to_string(), "OutputFormatContainer".to_string()]; + let mut forward = SchemaNameRegistry::with_components(component_names.clone()); + let mut reverse = SchemaNameRegistry::with_components(component_names.into_iter().rev()); + let inline = SchemaIdentity::Inline { + context: "Model".to_string(), + kind: "property-object", + preferred_name: "ModelApi".to_string(), + fingerprint: r#"{"type":"object"}"#.to_string(), + }; + + assert_eq!( + forward.allocate(inline.clone(), "ModelApi", "ModelApiInline"), + "ModelApiInline" + ); + assert_eq!( + reverse.allocate(inline, "ModelApi", "ModelApiInline"), + "ModelApiInline" + ); + assert_eq!(forward.component_name("ModelApi"), Some("ModelApi")); + assert_eq!(reverse.component_name("ModelApi"), Some("ModelApi")); + assert_eq!( + forward.component_name("OutputFormatContainer"), + Some("OutputFormatContainer") + ); + assert_eq!( + reverse.component_name("OutputFormatContainer"), + Some("OutputFormatContainer") + ); + } + + #[test] + fn an_identity_reuses_its_exact_allocated_name() { + let mut registry = SchemaNameRegistry::with_components(["ModelApi".to_string()]); + let inline = SchemaIdentity::Inline { + context: "Model".to_string(), + kind: "property-object", + preferred_name: "ModelApi".to_string(), + fingerprint: r#"{"type":"object"}"#.to_string(), + }; + + let first = registry.allocate(inline.clone(), "ModelApi", "ModelApiInline"); + let repeated = registry.allocate(inline, "Ignored", "IgnoredInline"); + + assert_eq!(first, "ModelApiInline"); + assert_eq!(repeated, first); + assert_eq!(registry.component_name("ModelApi"), Some("ModelApi")); + } +} + pub struct SchemaAnalyzer { schemas: BTreeMap, resolved_cache: BTreeMap, + schema_names: SchemaNameRegistry, openapi_spec: Value, current_schema_name: Option, component_parameters: BTreeMap, @@ -1540,6 +1769,116 @@ impl SchemaAnalyzer { } } + fn allocate_inline_schema_name( + &mut self, + preferred_name: &str, + collision_name: &str, + kind: &'static str, + schema: &Schema, + ) -> String { + let identity = SchemaIdentity::Inline { + context: self + .current_schema_name + .clone() + .unwrap_or_else(|| "".to_string()), + kind, + preferred_name: preferred_name.to_string(), + fingerprint: serde_json::to_string(schema).unwrap_or_else(|_| format!("{schema:?}")), + }; + self.schema_names + .allocate(identity, preferred_name, collision_name) + } + + /// Run nested analysis under the name of the schema that will own the + /// generated Rust item. Restoring the previous context even on error keeps + /// sibling paths independent and makes names encode the complete owning + /// path (`RootWrapperUser`, not a second `RootUser`). + fn with_schema_context( + &mut self, + schema_name: &str, + analyze: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let previous = self.current_schema_name.replace(schema_name.to_string()); + let result = analyze(self); + self.current_schema_name = previous; + result + } + + fn add_allocated_object_schema( + &mut self, + object_type_name: String, + schema: &Schema, + dependencies: &mut HashSet, + ) -> Result { + let object_type = self.with_schema_context(&object_type_name, |analyzer| { + analyzer.analyze_object_schema(schema, dependencies) + })?; + self.resolved_cache.insert( + object_type_name.clone(), + AnalyzedSchema { + name: object_type_name.clone(), + original: serde_json::to_value(schema).unwrap_or(Value::Null), + schema_type: object_type, + dependencies: dependencies.clone(), + nullable: false, + description: schema.details().description.clone(), + default: None, + }, + ); + dependencies.insert(object_type_name.clone()); + Ok(SchemaType::Reference { + target: object_type_name, + }) + } + + fn allocate_inline_union_branch_name( + &mut self, + preferred_name: &str, + owner_context: &str, + union_kind: InlineUnionKind, + original_index: usize, + discriminator: Option<&str>, + schema: &Schema, + ) -> String { + let identity = SchemaIdentity::InlineUnionBranch { + owner_context: owner_context.to_string(), + union_kind, + original_index, + discriminator: discriminator.map(str::to_string), + fingerprint: serde_json::to_string(schema).unwrap_or_else(|_| format!("{schema:?}")), + }; + self.schema_names + .allocate(identity, preferred_name, &format!("{preferred_name}Inline")) + } + + fn allocate_pointer_schema_name(&mut self, pointer: &str, preferred_name: &str) -> String { + self.schema_names.allocate( + SchemaIdentity::Pointer(pointer.to_string()), + preferred_name, + &format!("{preferred_name}Pointer"), + ) + } + + fn allocate_synthetic_schema_name( + &mut self, + preferred_name: &str, + collision_name: &str, + kind: &'static str, + fingerprint: String, + ) -> String { + let identity = SchemaIdentity::Inline { + context: self + .current_schema_name + .clone() + .unwrap_or_else(|| "".to_string()), + kind, + preferred_name: preferred_name.to_string(), + fingerprint, + }; + self.schema_names + .allocate(identity, preferred_name, collision_name) + } + fn uses_aws_query_conventions(&self) -> bool { self.openapi_spec .pointer("/info/x-providerName") @@ -1568,9 +1907,11 @@ impl SchemaAnalyzer { .and_then(|c| c.parameters.as_ref()) .cloned() .unwrap_or_default(); + let schema_names = SchemaNameRegistry::with_components(schemas.keys().cloned()); Ok(Self { schemas, resolved_cache: BTreeMap::new(), + schema_names, openapi_spec, current_schema_name: None, component_parameters, @@ -1903,23 +2244,50 @@ impl SchemaAnalyzer { /// openapi-generator-dpd) so we can downgrade those unions to /// `#[serde(untagged)]`. fn branch_resolves_to_object(&self, schema: &Schema) -> bool { + self.branch_resolves_to_object_inner(schema, &mut HashSet::new()) + } + + fn branch_resolves_to_object_inner( + &self, + schema: &Schema, + visited_refs: &mut HashSet, + ) -> bool { // Follow $ref one hop, then ask the same question of the target. if let Some(ref_str) = schema.reference() { - return match self + if !visited_refs.insert(ref_str.to_string()) { + return false; + } + let result = match self .extract_schema_name(ref_str) .and_then(|n| self.schemas.get(n)) { - Some(target) => self.branch_resolves_to_object(target), + Some(target) => self.branch_resolves_to_object_inner(target, visited_refs), None => false, }; + visited_refs.remove(ref_str); + return result; } - // allOf compositions are object-shaped; same for anyOf/oneOf - // wrappers (those will reduce to objects or to further unions). - if matches!( - schema, - Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. } - ) { - return true; + // An allOf wrapper around one scalar/array carrier and neutral + // annotation siblings remains that non-object carrier. Other allOf + // shapes are object-like only when at least one meaningful member is. + if let Schema::AllOf { all_of, .. } = schema { + if Self::single_non_object_allof_carrier(all_of).is_some() { + return false; + } + return all_of + .iter() + .filter(|member| !schema_is_annotation_only(member)) + .any(|member| self.branch_resolves_to_object_inner(member, visited_refs)); + } + // A nested anyOf/oneOf can carry an object discriminator only when + // every possible branch is itself object-shaped. Treating the wrapper + // as unconditionally object-like made scalar carrier unions (such as + // string-or-number aliases) enter object-only discriminator codegen. + if let Some(variants) = schema.union_variants() { + return !variants.is_empty() + && variants + .iter() + .all(|variant| self.branch_resolves_to_object_inner(variant, visited_refs)); } if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) { return true; @@ -2002,6 +2370,54 @@ impl SchemaAnalyzer { false } + /// Resolve local component-reference chains when deciding field + /// nullability. A `$ref` node has no nullable details of its own, but its + /// target may be `anyOf: [T, null]`, `type: [T, null]`, or OpenAPI 3.0 + /// `nullable: true`. + fn schema_or_reference_is_nullable(&self, schema: &Schema) -> bool { + let mut current = schema.clone(); + let mut visited = HashSet::new(); + loop { + if current.is_nullable_any() { + return true; + } + if let Schema::AllOf { all_of, .. } = ¤t + && let Some(carrier) = Self::single_non_object_allof_carrier(all_of) + { + current = carrier.clone(); + continue; + } + let Some(reference) = current.reference() else { + return false; + }; + if !visited.insert(reference.to_string()) { + return false; + } + let Some(target) = self.reference_target_schema(reference) else { + return false; + }; + current = target; + } + } + + /// Carry schema nullability into positions that do not have + /// [`PropertyInfo::nullable`] metadata of their own. This is deliberately + /// applied only by container analyzers: wrapping an ordinary object + /// property here would conflate a missing field with an explicit JSON + /// `null` and would double-wrap the generator's field-level `Option`. + fn nullable_container_value(&self, schema: &Schema, schema_type: SchemaType) -> SchemaType { + if !self.schema_or_reference_is_nullable(schema) + || matches!(schema_type, SchemaType::Nullable { .. }) + || matches!(schema.schema_type(), Some(OpenApiSchemaType::Null)) + { + schema_type + } else { + SchemaType::Nullable { + inner_type: Box::new(schema_type), + } + } + } + fn extract_type_mappings(&self, schema: &Schema) -> Result>> { let variants = schema.union_variants().ok_or_else(|| { GeneratorError::InvalidSchema("No variants found for discriminated union".to_string()) @@ -2047,96 +2463,423 @@ impl SchemaAnalyzer { self.extract_discriminator_value_for_field(schema, "type") } - fn extract_discriminator_value_for_field( + fn extract_discriminator_values_for_field( &self, schema: &Schema, field_name: &str, - ) -> Option { - if let Some(properties) = &schema.details().properties { - if let Some(type_field) = properties.get(field_name) { - // Check for const value first (highest priority) - if let Some(const_value) = &type_field.details().const_value { - if let Some(value) = const_value.as_str() { - return Some(value.to_string()); - } - } - // Check for enum with single value - if let Some(enum_values) = &type_field.details().enum_values { - if enum_values.len() == 1 { - return enum_values[0].as_str().map(|s| s.to_string()); - } - } - // Check for const value in extra fields - if let Some(const_value) = type_field.details().extra.get("const") { - return const_value.as_str().map(|s| s.to_string()); - } - // Check for x-stainless-const with default value - if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") { - if stainless_const.as_bool() == Some(true) { - if let Some(default_value) = &type_field.details().default { - if let Some(value) = default_value.as_str() { - return Some(value.to_string()); - } - } - } - } + ) -> Vec { + self.extract_discriminator_value_domain_for_field(schema, field_name) + .unwrap_or_default() + } + + fn extract_discriminator_value_domain_for_field( + &self, + schema: &Schema, + field_name: &str, + ) -> Option> { + let mut visited_refs = HashSet::new(); + self.discriminator_value_domain(schema, field_name, &mut visited_refs) + } + + /// Returns the string values admitted for `field_name`, or `None` when + /// the schema does not constrain that field. An empty domain means the + /// constraints are contradictory. JSON Schema composition matters here: + /// `allOf` intersects constraints, while `anyOf`/`oneOf` combine the + /// alternatives. Flattening all of them into one union allowed explicit + /// discriminator mappings to manufacture tags rejected by their payload. + fn discriminator_value_domain( + &self, + schema: &Schema, + field_name: &str, + visited_refs: &mut HashSet, + ) -> Option> { + if let Some(reference) = schema.reference() { + if !visited_refs.insert(reference.to_string()) { + return None; } + let result = self + .extract_schema_name(reference) + .and_then(|name| self.schemas.get(name)) + .and_then(|target| { + self.discriminator_value_domain(target, field_name, visited_refs) + }); + visited_refs.remove(reference); + return result; } - None - } - fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> { - schema.reference().or_else(|| schema.recursive_reference()) + let own_domain = schema + .details() + .properties + .as_ref() + .and_then(|properties| properties.get(field_name)) + .and_then(|property| self.string_constraint_domain(property, visited_refs)); + + let composition_domain = match schema { + Schema::AllOf { all_of, .. } => { + let mut domain = None; + for member in all_of { + domain = Self::intersect_optional_domains( + domain, + self.discriminator_value_domain(member, field_name, visited_refs), + ); + } + domain + } + Schema::AnyOf { any_of, .. } => { + self.union_discriminator_domains(any_of, field_name, visited_refs) + } + Schema::OneOf { one_of, .. } => { + self.union_discriminator_domains(one_of, field_name, visited_refs) + } + Schema::Bool(false) => Some(Vec::new()), + _ => None, + }; + + Self::intersect_optional_domains(own_domain, composition_domain) } - fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> { - if ref_str == "#" { - return None; // Special case for self-reference + fn union_discriminator_domains( + &self, + schemas: &[Schema], + field_name: &str, + visited_refs: &mut HashSet, + ) -> Option> { + if schemas.is_empty() { + return Some(Vec::new()); + } + let mut domain = Vec::new(); + for schema in schemas { + let values = self.discriminator_value_domain(schema, field_name, visited_refs)?; + for value in values { + Self::push_unique_string(&mut domain, &value); + } } + Some(domain) + } - let parts: Vec<&str> = ref_str.split('/').collect(); + fn string_constraint_domain( + &self, + schema: &Schema, + visited_refs: &mut HashSet, + ) -> Option> { + let allow_vendor_default = + !self.has_standard_string_constraint(schema, &mut HashSet::new()); + self.string_constraint_domain_inner(schema, visited_refs, allow_vendor_default) + } - // Standard 3.x pattern: #/components/schemas/{SchemaName}[/deeper/path] - if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" { - return Some(parts[3]); + fn string_constraint_domain_inner( + &self, + schema: &Schema, + visited_refs: &mut HashSet, + allow_vendor_default: bool, + ) -> Option> { + if let Some(reference) = schema.reference() { + if !visited_refs.insert(reference.to_string()) { + return None; + } + let result = self + .extract_schema_name(reference) + .and_then(|name| self.schemas.get(name)) + .and_then(|target| { + self.string_constraint_domain_inner(target, visited_refs, allow_vendor_default) + }); + visited_refs.remove(reference); + return result; } - // Swagger 2.0 carry-over: some 3.x specs (Google) still use - // `#/definitions/{SchemaName}`. Treat it as an alias. - if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" { - return Some(parts[2]); + let details = schema.details(); + let mut own_domain = None; + if let Some(value) = details.const_value.as_ref() { + own_domain = Self::intersect_optional_domains( + own_domain, + Some(value.as_str().into_iter().map(str::to_string).collect()), + ); } - - // Last-segment fallback for other ref shapes — but only if the - // segment plausibly names a top-level schema (PascalCase, no digits- - // only, not a JSON-schema keyword like `schema`/`properties`/`items`). - // pagerduty has `#/components/parameters/foo/schema`, where the last - // segment "schema" is a sub-path indicator, not a schema name. - let last = parts.last()?; - if last.is_empty() - || last.chars().all(|c| c.is_ascii_digit()) - || matches!( - *last, - "schema" | "properties" | "items" | "additionalProperties" - ) - { - return None; + if let Some(enum_values) = &details.enum_values { + own_domain = Self::intersect_optional_domains( + own_domain, + Some( + enum_values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + ), + ); } - let first = last.chars().next().unwrap_or(' '); - if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() { - return None; + if allow_vendor_default + && details + .extra + .get("x-stainless-const") + .and_then(Value::as_bool) + == Some(true) + && let Some(default) = details.default.as_ref().and_then(Value::as_str) + { + own_domain = + Self::intersect_optional_domains(own_domain, Some(vec![default.to_string()])); } - Some(last) - } - fn analyze_schema(&mut self, schema_name: &str) -> Result { - // Check cache first - if let Some(cached) = self.resolved_cache.get(schema_name) { - return Ok(cached.clone()); - } + let composition_domain = match schema { + Schema::AllOf { all_of, .. } => { + let mut domain = None; + for member in all_of { + domain = Self::intersect_optional_domains( + domain, + self.string_constraint_domain_inner( + member, + visited_refs, + allow_vendor_default, + ), + ); + } + domain + } + Schema::AnyOf { any_of, .. } => { + self.union_string_constraint_domains(any_of, visited_refs, allow_vendor_default) + } + Schema::OneOf { one_of, .. } => { + self.union_string_constraint_domains(one_of, visited_refs, allow_vendor_default) + } + Schema::Bool(false) => Some(Vec::new()), + _ => None, + }; - // Set current schema name for context - self.current_schema_name = Some(schema_name.to_string()); + Self::intersect_optional_domains(own_domain, composition_domain) + } + + fn union_string_constraint_domains( + &self, + schemas: &[Schema], + visited_refs: &mut HashSet, + allow_vendor_default: bool, + ) -> Option> { + if schemas.is_empty() { + return Some(Vec::new()); + } + let mut domain = Vec::new(); + for schema in schemas { + let values = + self.string_constraint_domain_inner(schema, visited_refs, allow_vendor_default)?; + for value in values { + Self::push_unique_string(&mut domain, &value); + } + } + Some(domain) + } + + fn has_standard_string_constraint( + &self, + schema: &Schema, + visited_refs: &mut HashSet, + ) -> bool { + if let Some(reference) = schema.reference() { + if !visited_refs.insert(reference.to_string()) { + return false; + } + let result = self + .extract_schema_name(reference) + .and_then(|name| self.schemas.get(name)) + .is_some_and(|target| self.has_standard_string_constraint(target, visited_refs)); + visited_refs.remove(reference); + return result; + } + + let details = schema.details(); + if details.const_value.is_some() || details.enum_values.is_some() { + return true; + } + + match schema { + Schema::AllOf { all_of, .. } => all_of + .iter() + .any(|member| self.has_standard_string_constraint(member, visited_refs)), + Schema::AnyOf { any_of, .. } => any_of + .iter() + .any(|member| self.has_standard_string_constraint(member, visited_refs)), + Schema::OneOf { one_of, .. } => one_of + .iter() + .any(|member| self.has_standard_string_constraint(member, visited_refs)), + _ => false, + } + } + + fn intersect_optional_domains( + left: Option>, + right: Option>, + ) -> Option> { + match (left, right) { + (None, other) | (other, None) => other, + (Some(left), Some(right)) => Some( + left.into_iter() + .filter(|value| right.contains(value)) + .collect(), + ), + } + } + + fn push_unique_string(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } + } + + fn discriminator_property_presence(&self, schema: &Schema, field_name: &str) -> (bool, bool) { + self.discriminator_property_presence_inner(schema, field_name, &mut HashSet::new(), 0) + } + + fn discriminator_property_presence_inner( + &self, + schema: &Schema, + field_name: &str, + visited_refs: &mut HashSet, + depth: usize, + ) -> (bool, bool) { + if depth > 64 { + return (false, false); + } + if let Some(reference) = schema.reference() { + if !visited_refs.insert(reference.to_string()) { + return (false, false); + } + let result = self + .extract_schema_name(reference) + .and_then(|name| self.schemas.get(name)) + .map(|target| { + self.discriminator_property_presence_inner( + target, + field_name, + visited_refs, + depth + 1, + ) + }) + .unwrap_or((false, false)); + visited_refs.remove(reference); + return result; + } + + let details = schema.details(); + let mut declared = details + .properties + .as_ref() + .is_some_and(|properties| properties.contains_key(field_name)); + let mut required = details + .required + .as_ref() + .is_some_and(|names| names.iter().any(|name| name == field_name)); + + let members = match schema { + Schema::AllOf { all_of, .. } => Some(all_of.as_slice()), + Schema::AnyOf { any_of, .. } => Some(any_of.as_slice()), + Schema::OneOf { one_of, .. } => Some(one_of.as_slice()), + _ => None, + }; + if let Some(members) = members { + for member in members { + let (member_declared, member_required) = self + .discriminator_property_presence_inner( + member, + field_name, + visited_refs, + depth + 1, + ); + declared |= member_declared; + required |= member_required; + } + } + (declared, required) + } + + fn extract_discriminator_value_for_field( + &self, + schema: &Schema, + field_name: &str, + ) -> Option { + self.extract_discriminator_values_for_field(schema, field_name) + .into_iter() + .next() + } + + fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> { + schema.reference().or_else(|| schema.recursive_reference()) + } + + fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> { + if ref_str == "#" { + return None; // Special case for self-reference + } + + let parts: Vec<&str> = ref_str.split('/').collect(); + + // Standard 3.x pattern: #/components/schemas/{SchemaName}. A longer + // pointer names a node *inside* the component and must be resolved at + // that exact JSON Pointer rather than being truncated to the root. + if parts.len() == 4 && parts[0] == "#" && parts[2] == "schemas" { + return Some(parts[3]); + } + + // Swagger 2.0 carry-over: some 3.x specs (Google) still use + // `#/definitions/{SchemaName}`. Treat it as an alias. + if parts.len() == 3 && parts[0] == "#" && parts[1] == "definitions" { + return Some(parts[2]); + } + + // Other local fragments are JSON Pointers, not component names. Let + // the exact-pointer resolver handle them before applying the legacy + // last-segment fallback used by non-pointer reference shapes. + if ref_str.starts_with("#/") { + return None; + } + + // Last-segment fallback for other ref shapes — but only if the + // segment plausibly names a top-level schema (PascalCase, no digits- + // only, not a JSON-schema keyword like `schema`/`properties`/`items`). + // pagerduty has `#/components/parameters/foo/schema`, where the last + // segment "schema" is a sub-path indicator, not a schema name. + let last = parts.last()?; + if last.is_empty() + || last.chars().all(|c| c.is_ascii_digit()) + || matches!( + *last, + "schema" | "properties" | "items" | "additionalProperties" + ) + { + return None; + } + let first = last.chars().next().unwrap_or(' '); + if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() { + return None; + } + Some(last) + } + + /// Return the exact local schema named by a reference, whether the + /// reference targets a component root or a node deeper in the document. + fn reference_target_schema(&self, reference: &str) -> Option { + if let Some(name) = self.extract_schema_name(reference) { + return self.schemas.get(name).cloned(); + } + let pointer = reference.strip_prefix('#')?; + if !pointer.starts_with('/') { + return None; + } + Schema::deserialize(self.openapi_spec.pointer(pointer)?).ok() + } + + fn analyze_schema(&mut self, schema_name: &str) -> Result { + // Component lookup is provenance-typed: an inline schema can never + // satisfy this cache request merely because it preferred the same + // emitted name. + let emitted_name = self + .schema_names + .component_name(schema_name) + .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))? + .to_string(); + if let Some(cached) = self.resolved_cache.get(&emitted_name) { + return Ok(cached.clone()); + } + + // Set current schema name for context + self.current_schema_name = Some(emitted_name.clone()); let schema = self .schemas @@ -2146,9 +2889,9 @@ impl SchemaAnalyzer { // Prevent infinite recursion with placeholder self.resolved_cache.insert( - schema_name.to_string(), + emitted_name.clone(), AnalyzedSchema { - name: schema_name.to_string(), + name: emitted_name.clone(), original: serde_json::to_value(&schema).unwrap_or(Value::Null), schema_type: SchemaType::Reference { target: "placeholder".to_string(), @@ -2160,11 +2903,10 @@ impl SchemaAnalyzer { }, ); - let analyzed = self.analyze_schema_value(&schema, schema_name)?; + let analyzed = self.analyze_schema_value(&schema, &emitted_name)?; // Update cache with real result - self.resolved_cache - .insert(schema_name.to_string(), analyzed.clone()); + self.resolved_cache.insert(emitted_name, analyzed.clone()); Ok(analyzed) } @@ -2176,11 +2918,23 @@ impl SchemaAnalyzer { ) -> Result { let details = schema.details(); let description = details.description.clone(); - // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`. - let nullable = details.is_nullable() || schema.type_array_contains_null(); + // Retain every OpenAPI nullability spelling on named schemas. Named + // Rust models are the non-null carrier; reference sites consult this + // bit to wrap the carrier in Option when the target also admits null. + let nullable = schema.is_nullable_any(); let mut dependencies = HashSet::new(); let schema_type = match schema { + // `true` admits every value; `false` admits none. Neither leaves + // anything to generate a type from. + Schema::Bool(accepts_anything) => self.untyped_value( + self.untyped_context(""), + if *accepts_anything { + UntypedReason::AnySchema + } else { + UntypedReason::NeverMatches + }, + ), Schema::Reference { reference, .. } => { // A ref that names no component schema may still address a node // in this document — a parameter's schema, a response body, one @@ -2246,7 +3000,10 @@ impl SchemaAnalyzer { &mut dependencies, )?); } - SchemaType::Union { variants } + SchemaType::Union { + variants, + exclusive: false, + } } else { self.analyze_single_typed_schema( schema, @@ -2317,12 +3074,14 @@ impl SchemaAnalyzer { discriminator.as_ref(), schema_name, &mut dependencies, + InlineUnionKind::OneOf, + None, )? } } Schema::AllOf { all_of, .. } => { // Handle allOf composition (schema inheritance) - self.analyze_allof_composition(all_of, &mut dependencies)? + self.analyze_allof_composition(schema, all_of, &mut dependencies)? } Schema::Untyped { .. } => { // Try to infer type from structure @@ -2393,14 +3152,15 @@ impl SchemaAnalyzer { if let Some(values) = details.string_enum_values() { SchemaType::StringEnum { values } } else { + let mapped = self.type_mapper.string_format(format); SchemaType::Primitive { - rust_type: self.type_mapper.string_format(format).rust_type, - serde_with: None, + rust_type: mapped.rust_type, + serde_with: mapped.serde_with, } } } OpenApiSchemaType::Integer => SchemaType::Primitive { - rust_type: self.type_mapper.integer_format(format).rust_type, + rust_type: self.integer_rust_type(details), serde_with: None, }, OpenApiSchemaType::Number => SchemaType::Primitive { @@ -2461,12 +3221,18 @@ impl SchemaAnalyzer { // `{properties: {...}, anyOf: [{required: [a]}, {required: [b]}]}`. if Self::union_only_constrains_requiredness(any_of) { self.analyze_empty_union(prop_schema, dependencies)? - } else if let Some(with_variants) = self.analyze_object_with_variants( - prop_schema, - any_of, - &format!("{owner_name}{}", self.to_pascal_case(prop_name)), - dependencies, - )? { + } else if let Some(with_variants) = { + let variant_owner = + format!("{owner_name}{}", self.to_pascal_case(prop_name)); + self.with_schema_context(&variant_owner, |analyzer| { + analyzer.analyze_object_with_variants( + prop_schema, + any_of, + &variant_owner, + dependencies, + ) + })? + } { with_variants } else if self.should_use_dynamic_json(prop_schema) { // This is a dynamic JSON pattern, use serde_json::Value directly @@ -2499,28 +3265,13 @@ impl SchemaAnalyzer { // Generate a name based on both the schema and property name let prop_pascal = self.to_pascal_case(prop_name); - let mut union_type_name = format!("{context_name}{prop_pascal}"); - - // Avoid colliding with an existing component schema or - // an inline name that's already in resolved_cache. - if self.schemas.contains_key(&union_type_name) - || self.resolved_cache.contains_key(&union_type_name) - { - let mut suffix = 2; - loop { - let candidate = format!("{union_type_name}Union{suffix}"); - if !self.schemas.contains_key(&candidate) - && !self.resolved_cache.contains_key(&candidate) - { - union_type_name = candidate; - break; - } - suffix += 1; - if suffix > 1000 { - break; - } - } - } + let preferred_union_name = format!("{context_name}{prop_pascal}"); + let union_type_name = self.allocate_inline_schema_name( + &preferred_union_name, + &format!("{preferred_union_name}Union2"), + "property-anyof", + prop_schema, + ); // Analyze the union let union_schema_type = self.analyze_anyof_union( @@ -2592,6 +3343,7 @@ impl SchemaAnalyzer { description: prop_description, default: prop_default, serde_attrs: Vec::new(), + synthesized_required: false, constraints: PropertyConstraints::from_schema_details(prop_details), }, ); @@ -2604,26 +3356,13 @@ impl SchemaAnalyzer { .clone() .unwrap_or_else(|| "Unknown".to_string()); let prop_pascal = self.to_pascal_case(prop_name); - let mut union_type_name = format!("{context_name}{prop_pascal}"); - // Same collision-suffix dance as the anyOf branch above. - if self.schemas.contains_key(&union_type_name) - || self.resolved_cache.contains_key(&union_type_name) - { - let mut suffix = 2; - loop { - let candidate = format!("{union_type_name}Union{suffix}"); - if !self.schemas.contains_key(&candidate) - && !self.resolved_cache.contains_key(&candidate) - { - union_type_name = candidate; - break; - } - suffix += 1; - if suffix > 1000 { - break; - } - } - } + let preferred_union_name = format!("{context_name}{prop_pascal}"); + let union_type_name = self.allocate_inline_schema_name( + &preferred_union_name, + &format!("{preferred_union_name}Union2"), + "property-oneof", + prop_schema, + ); // Analyze the discriminated union let union_schema_type = self.analyze_oneof_union( @@ -2631,6 +3370,8 @@ impl SchemaAnalyzer { discriminator.as_ref(), &union_type_name, dependencies, + InlineUnionKind::OneOf, + None, )?; // Store the union as a named schema @@ -2670,7 +3411,7 @@ impl SchemaAnalyzer { let prop_details = prop_schema.details(); // Every nullability form, via one helper — see is_nullable_any. - let prop_nullable = prop_schema.is_nullable_any(); + let prop_nullable = self.schema_or_reference_is_nullable(prop_schema); let prop_description = prop_details.description.clone(); let prop_default = prop_details.default.clone(); @@ -2682,6 +3423,7 @@ impl SchemaAnalyzer { description: prop_description, default: prop_default, serde_attrs: Vec::new(), + synthesized_required: false, constraints: PropertyConstraints::from_schema_details(prop_details), }, ); @@ -2703,26 +3445,88 @@ impl SchemaAnalyzer { .and_then(|s| s.additional_properties_typed) .unwrap_or(true); - let additional_properties = match &details.additional_properties { - Some(crate::openapi::AdditionalProperties::Boolean(true)) => { - ObjectAdditionalProperties::Untyped - } - Some(crate::openapi::AdditionalProperties::Boolean(false)) => { - ObjectAdditionalProperties::Forbidden - } - Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => { - let analyzed = - self.analyze_property_schema_with_context(value_schema, None, dependencies)?; - ObjectAdditionalProperties::Typed { - value_type: Box::new(analyzed), - } - } - Some(crate::openapi::AdditionalProperties::Schema(_)) => { - // typed_enabled = false: degrade to the pre-Q2.3 behavior. - ObjectAdditionalProperties::Untyped - } - None => ObjectAdditionalProperties::Forbidden, + let untyped_required_property = || PropertyInfo { + schema_type: SchemaType::Untyped { + shape: UntypedShape::Value, + reason: UntypedReason::AnySchema, + }, + nullable: false, + description: None, + default: None, + serde_attrs: Vec::new(), + synthesized_required: true, + constraints: PropertyConstraints::default(), }; + let (mut additional_properties, required_additional_property, explicitly_forbidden) = + match &details.additional_properties { + Some(crate::openapi::AdditionalProperties::Boolean(true)) => ( + ObjectAdditionalProperties::Untyped, + Some(untyped_required_property()), + false, + ), + Some(crate::openapi::AdditionalProperties::Boolean(false)) => { + (ObjectAdditionalProperties::Forbidden, None, true) + } + Some(crate::openapi::AdditionalProperties::Schema(value_schema)) + if typed_enabled => + { + let analyzed = self.analyze_property_schema_with_context( + value_schema, + Some("AdditionalProperty"), + dependencies, + )?; + let nullable_value = + self.nullable_container_value(value_schema, analyzed.clone()); + let value_details = value_schema.details(); + let required_property = PropertyInfo { + schema_type: analyzed.clone(), + nullable: self.schema_or_reference_is_nullable(value_schema), + description: value_details.description.clone(), + // A JSON Schema `default` is an annotation, not permission + // to omit a name that `required` says must be present. + default: None, + serde_attrs: Vec::new(), + synthesized_required: true, + constraints: PropertyConstraints::from_schema_details(value_details), + }; + ( + ObjectAdditionalProperties::Typed { + value_type: Box::new(nullable_value), + }, + Some(required_property), + false, + ) + } + Some(crate::openapi::AdditionalProperties::Schema(_)) => ( + // typed_enabled = false: degrade both the catch-all map and + // any required unknown member to serde_json::Value. + ObjectAdditionalProperties::Untyped, + Some(untyped_required_property()), + false, + ), + // JSON Schema and OpenAPI 3.0 define an omitted + // additionalProperties keyword as accepting any extra value. + // We retain the historical closed-model shape unless an + // undeclared required name proves that an open carrier is needed. + None if Self::object_shape_needs_additional_property_carrier(details) => ( + ObjectAdditionalProperties::Untyped, + Some(untyped_required_property()), + false, + ), + None => ( + ObjectAdditionalProperties::Forbidden, + Some(untyped_required_property()), + false, + ), + }; + + self.finalize_required_object_members( + &mut property_info, + &required, + &mut additional_properties, + required_additional_property, + explicitly_forbidden, + )?; Ok(SchemaType::Object { properties: property_info, @@ -2732,6 +3536,85 @@ impl SchemaAnalyzer { }) } + /// Decide when an omitted `additionalProperties` keyword still needs an + /// emitted catch-all map. JSON Schema leaves such objects open, but the + /// generator historically projected them as closed structs. Preserve the + /// open portion when dropping it can invalidate object-count constraints + /// or discard keys that the schema itself demonstrates in examples. + fn object_shape_needs_additional_property_carrier( + details: &crate::openapi::SchemaDetails, + ) -> bool { + if details.min_properties.is_some() || details.max_properties.is_some() { + return true; + } + + let declared = details.properties.as_ref(); + let has_undeclared_key = |value: &Value| { + value.as_object().is_some_and(|object| { + object + .keys() + .any(|key| declared.is_none_or(|properties| !properties.contains_key(key))) + }) + }; + details.example.as_ref().is_some_and(has_undeclared_key) + || details + .examples + .as_ref() + .is_some_and(|examples| examples.iter().any(has_undeclared_key)) + } + + /// Materialize names asserted by `required` but omitted from `properties`. + /// A flattened map preserves arbitrary extras, but it cannot express that a + /// particular wire key must exist, so each such name also needs a normal + /// required field in the generated struct. + fn finalize_required_object_members( + &self, + properties: &mut BTreeMap, + required: &HashSet, + additional_properties: &mut ObjectAdditionalProperties, + required_additional_property: Option, + explicitly_forbidden: bool, + ) -> Result<()> { + let mut missing = required + .iter() + .filter(|name| !properties.contains_key(*name)) + .cloned() + .collect::>(); + missing.sort(); + if missing.is_empty() { + return Ok(()); + } + + if explicitly_forbidden { + let owner = self + .current_schema_name + .as_deref() + .unwrap_or(""); + return Err(GeneratorError::InvalidSchema(format!( + "object schema `{owner}` is unsatisfiable: required member(s) {} are not declared in properties while additionalProperties: false", + missing + .iter() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", ") + ))); + } + + let required_additional_property = required_additional_property.ok_or_else(|| { + GeneratorError::InvalidSchema( + "undeclared required members have no additional-properties carrier".to_string(), + ) + })?; + for name in missing { + properties.insert(name, required_additional_property.clone()); + } + + if matches!(additional_properties, ObjectAdditionalProperties::Forbidden) { + *additional_properties = ObjectAdditionalProperties::Untyped; + } + Ok(()) + } + /// Build one union variant for a genuine `type: [X, Y, ...]` member. /// All members of a `TypedMulti` share a single `SchemaDetails`, so /// `array`/`object` members carry the *same* `items`/`properties` as @@ -2750,7 +3633,13 @@ impl SchemaAnalyzer { ) -> Result { match member_type { OpenApiSchemaType::Array => { - let array_type_name = format!("{union_type_name}Array"); + let preferred_array_type_name = format!("{union_type_name}Array"); + let array_type_name = self.allocate_inline_schema_name( + &preferred_array_type_name, + &format!("{preferred_array_type_name}Inline"), + "typed-multi-array", + schema, + ); let array_type = self.analyze_array_schema(schema, &array_type_name, dependencies)?; self.resolved_cache.insert( @@ -2772,31 +3661,28 @@ impl SchemaAnalyzer { }) } OpenApiSchemaType::Object => { - let object_type_name = format!("{union_type_name}Object"); - let object_type = self.analyze_object_schema(schema, dependencies)?; - self.resolved_cache.insert( - object_type_name.clone(), - AnalyzedSchema { - name: object_type_name.clone(), - original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: object_type, - dependencies: dependencies.clone(), - nullable: false, - description: schema.details().description.clone(), - default: None, - }, + let preferred_object_type_name = format!("{union_type_name}Object"); + let object_type_name = self.allocate_inline_schema_name( + &preferred_object_type_name, + &format!("{preferred_object_type_name}Inline"), + "typed-multi-object", + schema, ); - dependencies.insert(object_type_name.clone()); + let object_type = self.add_allocated_object_schema( + object_type_name.clone(), + schema, + dependencies, + )?; + let SchemaType::Reference { target } = object_type else { + unreachable!("allocated object schemas always return a reference"); + }; Ok(SchemaRef { - target: object_type_name, + target, nullable: false, }) } _ => Ok(SchemaRef { - target: self - .type_mapper - .map(member_type, schema.details()) - .rust_type, + target: self.openapi_type_to_rust_type(member_type, schema.details()), nullable: false, }), } @@ -2808,6 +3694,19 @@ impl SchemaAnalyzer { property_name: Option<&str>, dependencies: &mut HashSet, ) -> Result { + // `true` admits every value; `false` admits none. + if let Schema::Bool(accepts_anything) = schema { + let reason = if *accepts_anything { + UntypedReason::AnySchema + } else { + UntypedReason::NeverMatches + }; + return Ok(self.untyped_value( + self.untyped_context(property_name.unwrap_or_default()), + reason, + )); + } + if let Some(ref_str) = self.get_any_reference(schema) { let target_opt = if ref_str == "#" { Some( @@ -2853,25 +3752,13 @@ impl SchemaAnalyzer { let prop_pascal = property_name .map(|name| self.to_pascal_case(name)) .unwrap_or_default(); - let mut union_type_name = format!("{context_name}{prop_pascal}"); - if self.schemas.contains_key(&union_type_name) - || self.resolved_cache.contains_key(&union_type_name) - { - let mut suffix = 2; - loop { - let candidate = format!("{union_type_name}Union{suffix}"); - if !self.schemas.contains_key(&candidate) - && !self.resolved_cache.contains_key(&candidate) - { - union_type_name = candidate; - break; - } - suffix += 1; - if suffix > 1000 { - break; - } - } - } + let preferred_union_name = format!("{context_name}{prop_pascal}"); + let union_type_name = self.allocate_inline_schema_name( + &preferred_union_name, + &format!("{preferred_union_name}Union2"), + "property-typed-multi", + schema, + ); let details = schema.details(); let mut variants = Vec::with_capacity(non_null_types.len()); @@ -2889,7 +3776,10 @@ impl SchemaAnalyzer { AnalyzedSchema { name: union_type_name.clone(), original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: SchemaType::Union { variants }, + schema_type: SchemaType::Union { + variants, + exclusive: false, + }, dependencies: HashSet::new(), nullable: false, description: details.description.clone(), @@ -2990,7 +3880,7 @@ impl SchemaAnalyzer { .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)); } // Inline object in property - create a named schema for it - let object_type_name = if let Some(prop_name) = property_name { + let preferred_object_type_name = if let Some(prop_name) = property_name { // Use property name for context let prop_pascal = self.to_pascal_case(prop_name); format!( @@ -3005,30 +3895,18 @@ impl SchemaAnalyzer { self.current_schema_name.as_deref().unwrap_or("Unknown") ) }; + let object_type_name = self.allocate_inline_schema_name( + &preferred_object_type_name, + &format!("{preferred_object_type_name}Inline"), + "property-object", + schema, + ); - // Analyze the object schema - let object_type = self.analyze_object_schema(schema, dependencies)?; - - // Create an analyzed schema for the inline object - let inline_schema = AnalyzedSchema { - name: object_type_name.clone(), - original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: object_type, - dependencies: dependencies.clone(), - nullable: false, - description: schema.details().description.clone(), - default: None, - }; - - // Add the inline object as a named schema - self.resolved_cache - .insert(object_type_name.clone(), inline_schema); - dependencies.insert(object_type_name.clone()); - - // Return a reference to the named schema - return Ok(SchemaType::Reference { - target: object_type_name, - }); + return self.add_allocated_object_schema( + object_type_name, + schema, + dependencies, + ); } // `type: null` admits exactly one value; Rust spells that // `()`, which serde reads from and writes as null. @@ -3059,7 +3937,17 @@ impl SchemaAnalyzer { // Handle allOf composition patterns if let Schema::AllOf { all_of, .. } = schema { - return self.analyze_allof_composition(all_of, dependencies); + if let Some(property_name) = property_name { + let owner = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Inline".to_string()); + let composition_name = format!("{owner}{}", self.to_pascal_case(property_name)); + return self.with_schema_context(&composition_name, |analyzer| { + analyzer.analyze_allof_composition(schema, all_of, dependencies) + }); + } + return self.analyze_allof_composition(schema, all_of, dependencies); } // Handle union patterns (anyOf/oneOf) that weren't caught earlier @@ -3095,19 +3983,24 @@ impl SchemaAnalyzer { .. } = schema { + let union_name = self.allocate_inline_schema_name( + &union_name, + &format!("{union_name}Union2"), + "fallback-property-oneof", + schema, + ); // This is a oneOf - analyze it properly with potential discriminator let oneof_result = self.analyze_oneof_union( one_of, discriminator.as_ref(), &union_name, dependencies, + InlineUnionKind::OneOf, + None, )?; // If we got a union type (not discriminated), we need to store it as a named type - if let SchemaType::Union { - variants: _union_variants, - } = &oneof_result - { + if let SchemaType::Union { .. } = &oneof_result { // Store the union as a named type in resolved_cache self.resolved_cache.insert( union_name.clone(), @@ -3159,6 +4052,7 @@ impl SchemaAnalyzer { } return Ok(SchemaType::Union { variants: union_variants, + exclusive: false, }); } } @@ -3175,7 +4069,25 @@ impl SchemaAnalyzer { return Ok(self .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)); } - return self.analyze_object_schema(schema, dependencies); + let owner = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Unknown".to_string()); + let preferred_name = property_name.map_or_else( + || format!("{owner}Object"), + |property_name| format!("{owner}{}", self.to_pascal_case(property_name)), + ); + let object_type_name = self.allocate_inline_schema_name( + &preferred_name, + &format!("{preferred_name}Inline"), + "property-object", + schema, + ); + return self.add_allocated_object_schema( + object_type_name, + schema, + dependencies, + ); } OpenApiSchemaType::Array => { let context_name = if let Some(prop_name) = property_name { @@ -3220,9 +4132,18 @@ impl SchemaAnalyzer { fn analyze_allof_composition( &mut self, + owner_schema: &Schema, all_of_schemas: &[Schema], dependencies: &mut HashSet, ) -> Result { + // A scalar/array carrier intersected only with annotation-only + // siblings keeps its wire type. This is deliberately narrower than + // "pick the first non-object": multiple carriers or assertion-bearing + // siblings are true intersections and must not be guessed at. + if let Some(carrier) = Self::single_non_object_allof_carrier(all_of_schemas) { + return self.analyze_property_schema_with_context(carrier, None, dependencies); + } + // A reference plus annotation-only siblings is still a direct type // alias. AWS-style specs frequently encode property descriptions as // `allOf: [$ref, { description: ... }]`; recursively expanding a @@ -3232,31 +4153,9 @@ impl SchemaAnalyzer { .filter_map(|schema| schema.reference()) .filter_map(|reference| self.extract_schema_name(reference)) .collect::>(); - let only_reference_and_annotations = all_of_schemas.iter().all(|schema| { - if schema.reference().is_some() { - return true; - } - serde_json::to_value(schema) - .ok() - .and_then(|value| value.as_object().cloned()) - .is_some_and(|object| { - object.keys().all(|key| { - matches!( - key.as_str(), - "title" - | "description" - | "deprecated" - | "readOnly" - | "writeOnly" - | "examples" - | "example" - | "externalDocs" - | "xml" - | "$comment" - ) || key.starts_with("x-") - }) - }) - }); + let only_reference_and_annotations = all_of_schemas + .iter() + .all(|schema| schema.reference().is_some() || schema_is_annotation_only(schema)); if referenced_targets.len() == 1 && only_reference_and_annotations { let target = referenced_targets[0]; dependencies.insert(target.to_string()); @@ -3281,49 +4180,94 @@ impl SchemaAnalyzer { // AllOf represents schema composition - merge all schemas into one let mut merged_properties = BTreeMap::new(); let mut merged_required = HashSet::new(); + let mut merged_variant = None; let mut descriptions = Vec::new(); // Save the current schema context to restore it when analyzing properties let current_context = self.current_schema_name.clone(); + let owner_name = current_context.as_deref().unwrap_or("InlineComposition"); + + // `properties` and `required` can be siblings of `allOf` on the owner + // itself. Seed them before walking members so a real declaration from + // either side wins over any synthesized required placeholder. + self.merge_schema_into_properties( + owner_schema, + &mut merged_properties, + &mut merged_required, + dependencies, + )?; - for schema in all_of_schemas { + for (member_index, schema) in all_of_schemas.iter().enumerate() { match schema { Schema::Reference { reference, .. } => { - // Add dependency on referenced schema - if let Some(target) = self.extract_schema_name(reference) { - dependencies.insert(target.to_string()); - - // First ensure the referenced schema is analyzed - let analyzed_ref = self.analyze_schema(target)?; + let (analyzed_type, analyzed_name, raw_target) = + if let Some(target) = self.extract_schema_name(reference) { + dependencies.insert(target.to_string()); + let analyzed_ref = self.analyze_schema(target)?; + ( + Some(analyzed_ref.schema_type), + Some(target.to_string()), + self.schemas.get(target).cloned(), + ) + } else { + ( + self.resolve_pointer_schema(reference, dependencies)?, + None, + self.reference_target_schema(reference), + ) + }; - // Now merge the analyzed schema's properties - match &analyzed_ref.schema_type { - SchemaType::Object { - properties, - required, - .. - } => { - // Merge properties from the analyzed schema - for (prop_name, prop_info) in properties { - merged_properties.insert(prop_name.clone(), prop_info.clone()); - } - // Merge required fields - for req in required { - merged_required.insert(req.clone()); - } - } - _ => { - // If the referenced schema is not an object, fall back to raw merge - if let Some(ref_schema) = self.schemas.get(target).cloned() { - self.merge_schema_into_properties( - &ref_schema, - &mut merged_properties, - &mut merged_required, - dependencies, - )?; - } - } - } + let merged = if let Some(analyzed_type) = analyzed_type { + self.merge_analyzed_object_properties( + &analyzed_type, + analyzed_name.as_deref(), + &mut merged_properties, + &mut merged_required, + &mut merged_variant, + owner_name, + )? + } else { + false + }; + if !merged && let Some(raw_target) = raw_target { + self.merge_schema_into_properties( + &raw_target, + &mut merged_properties, + &mut merged_required, + dependencies, + )?; + } + } + Schema::AnyOf { any_of, .. } + if Self::union_only_constrains_requiredness(any_of) => {} + Schema::OneOf { one_of, .. } + if Self::union_only_constrains_requiredness(one_of) => {} + Schema::AnyOf { .. } | Schema::OneOf { .. } => { + let preferred_name = format!("{owner_name}AllOfVariant{}", member_index + 1); + let variant_name = + self.add_inline_schema(&preferred_name, schema, dependencies)?; + dependencies.insert(variant_name.clone()); + let analyzed_type = self + .resolved_cache + .get(&variant_name) + .map(|analyzed| analyzed.schema_type.clone()) + .ok_or_else(|| { + GeneratorError::InvalidSchema(format!( + "allOf union member `{variant_name}` was not analyzed" + )) + })?; + if !self.merge_analyzed_object_properties( + &analyzed_type, + Some(&variant_name), + &mut merged_properties, + &mut merged_required, + &mut merged_variant, + owner_name, + )? { + return Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableComposition, + )); } } Schema::Typed { @@ -3364,36 +4308,208 @@ impl SchemaAnalyzer { } } - // If we successfully merged properties, return an object - if !merged_properties.is_empty() { + // If we successfully merged properties, reconcile required names only + // after every allOf sibling has contributed its declarations. Doing it + // per branch would turn a sibling-declared typed field into an opaque + // placeholder depending on branch order. + if !merged_properties.is_empty() || !merged_required.is_empty() || merged_variant.is_some() + { + let mut additional_properties = if merged_properties + .values() + .any(|property| property.synthesized_required) + { + ObjectAdditionalProperties::Untyped + } else { + ObjectAdditionalProperties::Forbidden + }; + self.finalize_required_object_members( + &mut merged_properties, + &merged_required, + &mut additional_properties, + Some(PropertyInfo { + schema_type: SchemaType::Untyped { + shape: UntypedShape::Value, + reason: UntypedReason::AnySchema, + }, + nullable: false, + description: None, + default: None, + serde_attrs: Vec::new(), + synthesized_required: true, + constraints: PropertyConstraints::default(), + }), + false, + )?; Ok(SchemaType::Object { properties: merged_properties, required: merged_required, - additional_properties: ObjectAdditionalProperties::Forbidden, - variant: None, + additional_properties, + variant: merged_variant, }) } else { - // Fall back to composition if we couldn't merge - Ok(SchemaType::Composition { - schemas: all_of_schemas + let schemas = all_of_schemas + .iter() + .filter_map(|schema| { + let reference = schema.reference()?; + let target = self.extract_schema_name(reference)?; + dependencies.insert(target.to_string()); + Some(SchemaRef { + target: target.to_string(), + nullable: false, + }) + }) + .collect::>(); + // An empty composition generated an empty struct, silently + // narrowing scalar/array intersections to `{}`. References to + // non-object carriers have the same problem. Keep representable + // object inheritance as Composition; otherwise retain the wire + // value opaquely instead of inventing an object shape. + let references_are_objects = all_of_schemas + .iter() + .filter(|schema| schema.reference().is_some()) + .all(|schema| self.branch_resolves_to_object(schema)); + if schemas.is_empty() || !references_are_objects { + Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableComposition, + )) + } else { + Ok(SchemaType::Composition { schemas }) + } + } + } + + /// Return the one direct scalar/array carrier in an allOf whose remaining + /// members are annotation-only. References, objects, unions, boolean + /// schemas, and multiple assertion-bearing members are intentionally not + /// collapsed by this narrow recovery path. + fn single_non_object_allof_carrier(all_of_schemas: &[Schema]) -> Option<&Schema> { + let mut meaningful = all_of_schemas + .iter() + .filter(|schema| !schema_is_annotation_only(schema)); + let carrier = meaningful.next()?; + if meaningful.next().is_some() { + return None; + } + + match carrier { + Schema::Typed { + schema_type: + OpenApiSchemaType::String + | OpenApiSchemaType::Integer + | OpenApiSchemaType::Number + | OpenApiSchemaType::Boolean + | OpenApiSchemaType::Array, + .. + } => Some(carrier), + Schema::TypedMulti { schema_types, .. } => { + let mut non_null = schema_types .iter() - .filter_map(|s| { - if let Some(ref_str) = s.reference() { - if let Some(target) = self.extract_schema_name(ref_str) { - dependencies.insert(target.to_string()); - Some(SchemaRef { - target: target.to_string(), - nullable: false, - }) - } else { - None - } - } else { - None + .filter(|schema_type| **schema_type != OpenApiSchemaType::Null); + let only = non_null.next()?; + (non_null.next().is_none() + && matches!( + only, + OpenApiSchemaType::String + | OpenApiSchemaType::Integer + | OpenApiSchemaType::Number + | OpenApiSchemaType::Boolean + | OpenApiSchemaType::Array + )) + .then_some(carrier) + } + _ => None, + } + } + + /// Merge the object reached by an analyzed type, following aliases through + /// the analysis cache. Deep-pointer resolution hoists inline objects and + /// returns a reference to that cache entry, so allOf composition needs the + /// same alias-following behavior as a direct component reference. + fn merge_analyzed_object_properties( + &mut self, + schema_type: &SchemaType, + named_target: Option<&str>, + merged_properties: &mut BTreeMap, + merged_required: &mut HashSet, + merged_variant: &mut Option, + owner_name: &str, + ) -> Result { + let mut current = schema_type.clone(); + let mut visited = HashSet::new(); + let mut variant_target = named_target.map(str::to_string); + loop { + match current { + SchemaType::Object { + properties, + required, + variant, + .. + } => { + for (name, property) in properties { + let keep_declared_sibling = + merged_properties.get(&name).is_some_and(|existing| { + !existing.synthesized_required && property.synthesized_required + }); + if !keep_declared_sibling { + merged_properties.insert(name, property); } - }) - .collect(), - }) + } + merged_required.extend(required); + if let Some(variant) = variant { + Self::merge_allof_variant(merged_variant, variant, owner_name)?; + } + return Ok(true); + } + SchemaType::Reference { target } => { + if !visited.insert(target.clone()) { + return Ok(false); + } + if variant_target.is_none() { + variant_target = Some(target.clone()); + } + current = if let Some(analyzed) = self.resolved_cache.get(&target) { + analyzed.schema_type.clone() + } else if self.schemas.contains_key(&target) { + self.analyze_schema(&target)?.schema_type + } else { + return Ok(false); + }; + } + SchemaType::Union { .. } | SchemaType::DiscriminatedUnion { .. } => { + let Some(target) = variant_target else { + return Ok(false); + }; + Self::merge_allof_variant( + merged_variant, + SchemaRef { + target, + nullable: false, + }, + owner_name, + )?; + return Ok(true); + } + _ => return Ok(false), + } + } + } + + fn merge_allof_variant( + merged_variant: &mut Option, + candidate: SchemaRef, + owner_name: &str, + ) -> Result<()> { + match merged_variant { + Some(existing) if existing.target == candidate.target => Ok(()), + Some(existing) => Err(GeneratorError::InvalidSchema(format!( + "allOf object `{owner_name}` intersects multiple union members (`{}` and `{}`), which cannot be represented by one flattened variant", + existing.target, candidate.target + ))), + None => { + *merged_variant = Some(candidate); + Ok(()) + } } } @@ -3432,7 +4548,7 @@ impl SchemaAnalyzer { // openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template // (3.1 type-array, openapi-generator-dsu) — the latter arrive // as `null` from the live API for any pod that hasn't started. - let nullable = prop_schema.is_nullable_any(); + let nullable = self.schema_or_reference_is_nullable(prop_schema); merged_properties.insert( prop_name.clone(), PropertyInfo { @@ -3441,6 +4557,7 @@ impl SchemaAnalyzer { description: prop_details.description.clone(), default: prop_details.default.clone(), serde_attrs: Vec::new(), + synthesized_required: false, constraints: PropertyConstraints::from_schema_details(prop_details), }, ); @@ -3463,7 +4580,14 @@ impl SchemaAnalyzer { discriminator: Option<&crate::openapi::Discriminator>, parent_name: &str, dependencies: &mut HashSet, + union_kind: InlineUnionKind, + source_indices: Option<&[usize]>, ) -> Result { + let default_indices = (0..one_of_schemas.len()).collect::>(); + let source_indices = source_indices + .filter(|indices| indices.len() == one_of_schemas.len()) + .unwrap_or(&default_indices); + // Branches may be pointers into other parts of the document. let expanded_branches; let one_of_schemas = match self.expand_pointer_branches(one_of_schemas) { @@ -3474,6 +4598,30 @@ impl SchemaAnalyzer { None => one_of_schemas, }; + // A boolean branch either opens the union up or can never be taken. + let boolean_resolved; + let boolean_resolved_indices; + let one_of_schemas = match Self::resolve_boolean_branches(one_of_schemas) { + Ok(resolved) => { + boolean_resolved_indices = one_of_schemas + .iter() + .zip(source_indices.iter().copied()) + .filter_map(|(branch, index)| { + (!matches!(branch, Schema::Bool(false))).then_some(index) + }) + .collect::>(); + boolean_resolved = resolved; + boolean_resolved.as_slice() + } + Err(()) => { + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)); + } + }; + let source_indices = boolean_resolved_indices.as_slice(); + if one_of_schemas.is_empty() { + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::NeverMatches)); + } + // A union of one is that one, and branches that differ only in // constraints share one Rust type. Both are checked before the shape // patterns below, which would otherwise synthesize a union type and @@ -3506,10 +4654,23 @@ impl SchemaAnalyzer { } } - // If there's no discriminator, we should create an untagged union - if discriminator.is_none() { + // If there's no discriminator, create an untagged union. A nullable + // referenced branch must also be structural: JSON null has no object + // discriminator field for an internally tagged enum to inspect. + if discriminator.is_none() + || one_of_schemas + .iter() + .any(|schema| self.schema_or_reference_is_nullable(schema)) + { // Handle untagged unions (oneOf without discriminator) - return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies); + return self.analyze_untagged_oneof_union( + one_of_schemas, + parent_name, + dependencies, + union_kind, + source_indices, + None, + ); } // Bug openapi-generator-dpd: if any branch resolves to a non-object @@ -3521,7 +4682,14 @@ impl SchemaAnalyzer { .iter() .any(|s| !self.branch_resolves_to_object(s)) { - return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies); + return self.analyze_untagged_oneof_union( + one_of_schemas, + parent_name, + dependencies, + union_kind, + source_indices, + discriminator, + ); } // This is a discriminated union @@ -3534,10 +4702,32 @@ impl SchemaAnalyzer { .property_name .clone(); + // A contradictory tag domain has no schema-valid discriminator value. + // Keep the payload structural instead of inventing and serializing a + // tag that the branch's own JSON Schema rejects. + if one_of_schemas.iter().any(|branch| { + self.extract_discriminator_value_domain_for_field(branch, &discriminator_field) + .is_some_and(|values| values.is_empty()) + }) { + eprintln!( + "⚠️ discriminated union `{parent_name}` has a branch with contradictory `{discriminator_field}` constraints; using structural union fallback" + ); + return self.analyze_untagged_oneof_union( + one_of_schemas, + parent_name, + dependencies, + union_kind, + source_indices, + discriminator, + ); + } + let mut variants = Vec::new(); let mut used_variant_names = std::collections::HashSet::new(); - for variant_schema in one_of_schemas { + for (variant_schema, original_index) in + one_of_schemas.iter().zip(source_indices.iter().copied()) + { // Check if this is a direct reference, recursive reference, or an allOf wrapper with a reference let ref_info = if let Some(ref_str) = variant_schema.reference() { Some((ref_str, false)) @@ -3575,43 +4765,56 @@ impl SchemaAnalyzer { if !schema_name.is_empty() { dependencies.insert(schema_name.clone()); - // Determine discriminator value with priority order: - // 1. Explicit mapping in discriminator - // 2. Extract from referenced schema - // 3. Generate from schema name - let discriminator_value = if let Some(disc) = discriminator { - if let Some(mappings) = &disc.mapping { - // Find the mapping key that points to this schema reference - // Mapping format is: "discriminator_value" -> "#/components/schemas/SchemaName" - mappings - .iter() - .find(|(_, target_ref)| { - // Check if this mapping target matches our reference - target_ref.as_str() == ref_str - || self - .extract_schema_name(target_ref) - .map(|s| s.to_string()) - == Some(schema_name.clone()) - }) - .map(|(key, _)| key.clone()) - .unwrap_or_else(|| { - self.fallback_discriminator_value_for_field( - &schema_name, - &discriminator_field, - ) - }) - } else { - self.fallback_discriminator_value_for_field( - &schema_name, - &discriminator_field, - ) - } - } else { - self.fallback_discriminator_value_for_field( - &schema_name, + // Mapping keys are dispatch hints, not schema constraints. + // When the target branch constrains the discriminator, + // retain only mapping keys admitted by that target. + let mut discriminator_values = Vec::new(); + let allowed_domain = self.schemas.get(&schema_name).and_then(|ref_schema| { + self.extract_discriminator_value_domain_for_field( + ref_schema, &discriminator_field, ) - }; + }); + if let Some(mappings) = discriminator.and_then(|disc| disc.mapping.as_ref()) { + for (key, target_ref) in mappings { + if target_ref == ref_str + || self.extract_schema_name(target_ref) + == Some(schema_name.as_str()) + { + if allowed_domain + .as_ref() + .is_some_and(|allowed| !allowed.contains(key)) + { + let allowed = allowed_domain + .as_ref() + .map(|values| values.join("`, `")) + .unwrap_or_default(); + eprintln!( + "⚠️ discriminator mapping conflict in union `{parent_name}`: key `{key}` targets `{schema_name}` but branch allows `{allowed}`; ignoring mapping key" + ); + } else { + Self::push_unique_string(&mut discriminator_values, key); + } + } + } + } + if let Some(allowed_domain) = allowed_domain { + for value in allowed_domain { + Self::push_unique_string(&mut discriminator_values, &value); + } + } + if discriminator_values.is_empty() { + discriminator_values + .push(self.generate_discriminator_value_from_name(&schema_name)); + } + let discriminator_value = discriminator_values[0].clone(); + let (discriminator_field_declared, discriminator_field_required) = self + .schemas + .get(&schema_name) + .map(|schema| { + self.discriminator_property_presence(schema, &discriminator_field) + }) + .unwrap_or((false, false)); // Generate Rust-friendly variant name and ensure uniqueness let base_name = self.to_rust_variant_name(&schema_name); @@ -3625,46 +4828,57 @@ impl SchemaAnalyzer { rust_name, type_name: schema_name, discriminator_value: final_discriminator_value, + preferred_discriminator_values: discriminator_values.clone(), + discriminator_values, + discriminator_field_declared, + discriminator_field_required, schema_ref: ref_str.to_string(), }); } } else { // Handle inline schemas in oneOf - let variant_index = variants.len(); + let variant_index = original_index; let inline_type_name = self.generate_inline_type_name(variant_schema, variant_index); - // Try to extract discriminator value from inline schema - let discriminator_value = if let Some(disc) = discriminator { - if let Some(mappings) = &disc.mapping { - // Look for mapping that points to this inline variant by index - mappings - .iter() - .find(|(_, target_ref)| { - target_ref.contains(&format!("variant_{variant_index}")) - }) - .map(|(key, _)| key.clone()) - .unwrap_or_else(|| { - self.extract_inline_discriminator_value( - variant_schema, - &discriminator_field, - variant_index, - ) - }) - } else { - self.extract_inline_discriminator_value( - variant_schema, - &discriminator_field, - variant_index, - ) + // Inline branches follow the same multi-value rules as + // referenced branches. + let mut discriminator_values = Vec::new(); + let allowed_domain = self.extract_discriminator_value_domain_for_field( + variant_schema, + &discriminator_field, + ); + if let Some(mappings) = discriminator.and_then(|disc| disc.mapping.as_ref()) { + for (key, target_ref) in mappings { + if target_ref.contains(&format!("variant_{variant_index}")) { + if allowed_domain + .as_ref() + .is_some_and(|allowed| !allowed.contains(key)) + { + let allowed = allowed_domain + .as_ref() + .map(|values| values.join("`, `")) + .unwrap_or_default(); + eprintln!( + "⚠️ discriminator mapping conflict in union `{parent_name}`: key `{key}` targets `{inline_type_name}` but branch allows `{allowed}`; ignoring mapping key" + ); + } else { + Self::push_unique_string(&mut discriminator_values, key); + } + } } - } else { - self.extract_inline_discriminator_value( - variant_schema, - &discriminator_field, - variant_index, - ) - }; + } + if let Some(allowed_domain) = allowed_domain { + for value in allowed_domain { + Self::push_unique_string(&mut discriminator_values, &value); + } + } + if discriminator_values.is_empty() { + discriminator_values.push(format!("variant_{variant_index}")); + } + let discriminator_value = discriminator_values[0].clone(); + let (discriminator_field_declared, discriminator_field_required) = + self.discriminator_property_presence(variant_schema, &discriminator_field); // Generate Rust-friendly variant name based on discriminator or fallback to generic let base_name = if discriminator_value.starts_with("variant_") { @@ -3679,24 +4893,41 @@ impl SchemaAnalyzer { // Use the discriminator value as-is from the schema let final_discriminator_value = discriminator_value; + // Store inline schema before recording the variant so a + // reserved component collision can return the actual name. + let inline_type_name = self.add_inline_union_branch_schema( + &inline_type_name, + variant_schema, + dependencies, + parent_name, + union_kind, + original_index, + Some(&final_discriminator_value), + )?; + variants.push(UnionVariant { rust_name, type_name: inline_type_name.clone(), discriminator_value: final_discriminator_value, + preferred_discriminator_values: discriminator_values.clone(), + discriminator_values, + discriminator_field_declared, + discriminator_field_required, schema_ref: format!("inline_{variant_index}"), }); - - // Store inline schema for later analysis and generation - self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?; } } + self.disambiguate_shared_discriminator_values(&mut variants, discriminator); + if variants.is_empty() { // If we couldn't create a discriminated union, fall back to an untagged union // This handles cases where oneOf contains references or inline schemas without proper discriminators let mut union_variants = Vec::new(); - for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() { + for (variant_schema, original_index) in + one_of_schemas.iter().zip(source_indices.iter().copied()) + { // First check if it's a reference or recursive reference if let Some(ref_str) = variant_schema.reference() { if let Some(schema_name) = self.extract_schema_name(ref_str) { @@ -3723,11 +4954,16 @@ impl SchemaAnalyzer { nullable: false, }); } else { + let branch_discriminator = self.inline_union_branch_discriminator_value( + variant_schema, + discriminator, + original_index, + ); // Handle inline schemas by creating type aliases or using primitive types directly let inline_name = self.generate_context_aware_name( parent_name, "InlineVariant", - variant_index, + original_index, Some(variant_schema), ); let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?; @@ -3768,13 +5004,17 @@ impl SchemaAnalyzer { let inline_type_name = self.generate_context_aware_name( parent_name, "Variant", - variant_index, + original_index, None, ); - self.add_inline_schema( + let inline_type_name = self.add_inline_union_branch_schema( &inline_type_name, variant_schema, dependencies, + parent_name, + union_kind, + original_index, + branch_discriminator.as_deref(), )?; union_variants.push(SchemaRef { target: inline_type_name, @@ -3793,11 +5033,15 @@ impl SchemaAnalyzer { // For other complex types, create an inline type _ => { let inline_type_name = - format!("{}Variant{}", parent_name, variant_index + 1); - self.add_inline_schema( + format!("{}Variant{}", parent_name, original_index + 1); + let inline_type_name = self.add_inline_union_branch_schema( &inline_type_name, variant_schema, dependencies, + parent_name, + union_kind, + original_index, + branch_discriminator.as_deref(), )?; union_variants.push(SchemaRef { target: inline_type_name, @@ -3811,6 +5055,7 @@ impl SchemaAnalyzer { if !union_variants.is_empty() { return Ok(SchemaType::Union { variants: union_variants, + exclusive: matches!(union_kind, InlineUnionKind::OneOf), }); } @@ -3824,6 +5069,7 @@ impl SchemaAnalyzer { Ok(SchemaType::DiscriminatedUnion { discriminator_field, variants, + exclusive: matches!(union_kind, InlineUnionKind::OneOf), }) } @@ -3832,32 +5078,49 @@ impl SchemaAnalyzer { one_of_schemas: &[Schema], parent_name: &str, dependencies: &mut HashSet, + union_kind: InlineUnionKind, + source_indices: &[usize], + discriminator: Option<&Discriminator>, ) -> Result { - // Drop {"type": "null"} variants. They mean "may be null" and are surfaced - // as Option at the property level — including them here produces a junk - // `SerdeJsonValue(serde_json::Value)` variant. - let filtered: Vec<&Schema> = one_of_schemas + // Drop null-only variants. They mean "may be null" and are surfaced as + // Option at the property level — including them here produces a junk + // `SerdeJsonValue(serde_json::Value)` variant. Recognize the equivalent + // `type`, `const`, and `enum` spellings. + let filtered: Vec<(usize, &Schema)> = one_of_schemas .iter() - .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null))) + .zip(source_indices.iter().copied()) + .filter_map(|(schema, original_index)| { + (!schema.is_explicit_null_only()).then_some((original_index, schema)) + }) .collect(); // If filtering leaves a single variant, return its analyzed type directly. if filtered.len() == 1 { return self - .analyze_schema_value(filtered[0], parent_name) + .analyze_schema_value(filtered[0].1, parent_name) .map(|a| a.schema_type); } + // Exact, unique branch selection is appropriate for object-only + // oneOf unions. Mixed scalar/object and unconstrained branches need + // normal untagged Serde semantics: a `serde_json::Value` alternative, + // for example, intentionally accepts shapes that a narrower branch + // can also hydrate. + let exclusive_object_union = matches!(union_kind, InlineUnionKind::OneOf) + && filtered + .iter() + .all(|(_, schema)| self.branch_resolves_to_object(schema)); + let mut union_variants = Vec::new(); - for (variant_index, variant_schema) in filtered.iter().copied().enumerate() { + for (original_index, variant_schema) in filtered { // First check if it's a reference or recursive reference if let Some(ref_str) = variant_schema.reference() { if let Some(schema_name) = self.extract_schema_name(ref_str) { dependencies.insert(schema_name.to_string()); union_variants.push(SchemaRef { target: schema_name.to_string(), - nullable: false, + nullable: self.schema_or_reference_is_nullable(variant_schema), }); } } else if let Some(recursive_ref) = variant_schema.recursive_reference() { @@ -3877,11 +5140,16 @@ impl SchemaAnalyzer { nullable: false, }); } else { + let branch_discriminator = self.inline_union_branch_discriminator_value( + variant_schema, + discriminator, + original_index, + ); // Handle inline schemas by creating type aliases or using primitive types directly let inline_name = self.generate_context_aware_name( parent_name, "InlineVariant", - variant_index, + original_index, Some(variant_schema), ); let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?; @@ -3941,14 +5209,19 @@ impl SchemaAnalyzer { let inline_type_name = self.generate_context_aware_name( parent_name, "Variant", - variant_index, + original_index, None, ); - self.add_inline_schema( - &inline_type_name, - variant_schema, - dependencies, - )?; + let inline_type_name = self + .add_inline_union_branch_schema( + &inline_type_name, + variant_schema, + dependencies, + parent_name, + union_kind, + original_index, + branch_discriminator.as_deref(), + )?; union_variants.push(SchemaRef { target: inline_type_name, nullable: false, @@ -3961,13 +5234,17 @@ impl SchemaAnalyzer { let inline_type_name = self.generate_context_aware_name( parent_name, "Variant", - variant_index, + original_index, None, ); - self.add_inline_schema( + let inline_type_name = self.add_inline_union_branch_schema( &inline_type_name, variant_schema, dependencies, + parent_name, + union_kind, + original_index, + branch_discriminator.as_deref(), )?; union_variants.push(SchemaRef { target: inline_type_name, @@ -3988,10 +5265,18 @@ impl SchemaAnalyzer { let inline_type_name = self.generate_context_aware_name( parent_name, "Variant", - variant_index, + original_index, None, ); - self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?; + let inline_type_name = self.add_inline_union_branch_schema( + &inline_type_name, + variant_schema, + dependencies, + parent_name, + union_kind, + original_index, + branch_discriminator.as_deref(), + )?; union_variants.push(SchemaRef { target: inline_type_name, nullable: false, @@ -4004,6 +5289,7 @@ impl SchemaAnalyzer { if !union_variants.is_empty() { return Ok(SchemaType::Union { variants: union_variants, + exclusive: exclusive_object_union, }); } @@ -4019,7 +5305,44 @@ impl SchemaAnalyzer { type_name: &str, schema: &Schema, dependencies: &mut HashSet, - ) -> Result<()> { + ) -> Result { + let allocated_name = self.allocate_inline_schema_name( + type_name, + &format!("{type_name}Inline"), + "inline-schema", + schema, + ); + self.add_allocated_inline_schema(allocated_name, schema, dependencies) + } + + #[allow(clippy::too_many_arguments)] + fn add_inline_union_branch_schema( + &mut self, + type_name: &str, + schema: &Schema, + dependencies: &mut HashSet, + owner_context: &str, + union_kind: InlineUnionKind, + original_index: usize, + discriminator: Option<&str>, + ) -> Result { + let allocated_name = self.allocate_inline_union_branch_name( + type_name, + owner_context, + union_kind, + original_index, + discriminator, + schema, + ); + self.add_allocated_inline_schema(allocated_name, schema, dependencies) + } + + fn add_allocated_inline_schema( + &mut self, + allocated_name: String, + schema: &Schema, + dependencies: &mut HashSet, + ) -> Result { // For primitive types, we need to ensure they are stored as type aliases if let Some(schema_type) = schema.schema_type() { match schema_type { @@ -4032,9 +5355,9 @@ impl SchemaAnalyzer { // Store as a type alias self.resolved_cache.insert( - type_name.to_string(), + allocated_name.clone(), AnalyzedSchema { - name: type_name.to_string(), + name: allocated_name.clone(), original: serde_json::to_value(schema).unwrap_or(Value::Null), schema_type: SchemaType::Primitive { rust_type, @@ -4046,7 +5369,7 @@ impl SchemaAnalyzer { default: None, }, ); - return Ok(()); + return Ok(allocated_name); } _ => {} } @@ -4055,22 +5378,48 @@ impl SchemaAnalyzer { // For non-primitive types, analyze the inline schema and add it to our collection // Set current_schema_name so nested inline properties (enums, unions, objects) // get named with the correct parent context instead of inheriting a stale name - let previous_schema_name = self.current_schema_name.take(); - self.current_schema_name = Some(type_name.to_string()); - let analyzed = self.analyze_schema_value(schema, type_name)?; - self.current_schema_name = previous_schema_name; + let analyzed = self.with_schema_context(&allocated_name, |analyzer| { + analyzer.analyze_schema_value(schema, &allocated_name) + })?; // Add to resolved cache so it can be generated - self.resolved_cache.insert(type_name.to_string(), analyzed); + self.resolved_cache.insert(allocated_name.clone(), analyzed); // Add dependencies - if let Some(cached) = self.resolved_cache.get(type_name) { + if let Some(cached) = self.resolved_cache.get(&allocated_name) { for dep in &cached.dependencies { dependencies.insert(dep.clone()); } } - Ok(()) + Ok(allocated_name) + } + + fn inline_union_branch_discriminator_value( + &self, + schema: &Schema, + discriminator: Option<&Discriminator>, + original_index: usize, + ) -> Option { + let discriminator = discriminator?; + discriminator + .mapping + .as_ref() + .and_then(|mappings| { + mappings + .iter() + .find(|(_, target_ref)| { + target_ref.contains(&format!("variant_{original_index}")) + }) + .map(|(key, _)| key.clone()) + }) + .or_else(|| { + Some(self.extract_inline_discriminator_value( + schema, + &discriminator.property_name, + original_index, + )) + }) } fn extract_inline_discriminator_value( @@ -4363,30 +5712,140 @@ impl SchemaAnalyzer { // config this produces bit-identical output to the pre-refactor // match; later Q2.* issues add format-aware branches inside // TypeMapper without touching this function. - self.type_mapper.map(openapi_type, details).rust_type + if openapi_type == OpenApiSchemaType::Integer { + self.integer_rust_type(details) + } else { + self.type_mapper.map(openapi_type, details).rust_type + } + } + + #[allow(dead_code)] + fn fallback_discriminator_value(&self, schema_name: &str) -> String { + self.fallback_discriminator_value_for_field(schema_name, "type") + } + + fn fallback_discriminator_value_for_field( + &self, + schema_name: &str, + field_name: &str, + ) -> String { + // Try to extract from referenced schema first + if let Some(ref_schema) = self.schemas.get(schema_name) { + if let Some(extracted) = + self.extract_discriminator_value_for_field(ref_schema, field_name) + { + return extracted; + } + } + + // Fall back to generating from name + self.generate_discriminator_value_from_name(schema_name) } - #[allow(dead_code)] - fn fallback_discriminator_value(&self, schema_name: &str) -> String { - self.fallback_discriminator_value_for_field(schema_name, "type") - } + fn disambiguate_shared_discriminator_values( + &self, + variants: &mut [UnionVariant], + discriminator: Option<&Discriminator>, + ) { + let mut owners_by_value: BTreeMap> = BTreeMap::new(); + for (index, variant) in variants.iter().enumerate() { + for value in &variant.discriminator_values { + owners_by_value + .entry(value.clone()) + .or_default() + .push(index); + } + } + + let mut removals: Vec<(usize, String)> = Vec::new(); + for (value, candidate_owners) in owners_by_value { + if candidate_owners.len() < 2 { + continue; + } + + let explicitly_mapped_owners: Vec = discriminator + .and_then(|disc| disc.mapping.as_ref()) + .and_then(|mappings| mappings.get(&value)) + .map(|target_ref| { + candidate_owners + .iter() + .copied() + .filter(|index| { + let variant = &variants[*index]; + target_ref == &variant.schema_ref + || self.extract_schema_name(target_ref) + == Some(variant.type_name.as_str()) + || (variant.schema_ref.starts_with("inline_") + && target_ref.contains(&variant.schema_ref)) + }) + .collect() + }) + .unwrap_or_default(); + + let winner = if explicitly_mapped_owners.len() == 1 { + explicitly_mapped_owners.first().copied() + } else { + let mut scored: Vec<(usize, usize)> = candidate_owners + .iter() + .map(|index| { + ( + *index, + Self::discriminator_name_affinity(&variants[*index].type_name, &value), + ) + }) + .collect(); + scored.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0))); + match scored.as_slice() { + [(index, score), rest @ ..] + if *score > 0 && rest.first().is_none_or(|(_, next)| score > next) => + { + Some(*index) + } + _ => None, + } + }; + + if let Some(winner) = winner { + for index in candidate_owners { + if index != winner && variants[index].preferred_discriminator_values.len() > 1 { + removals.push((index, value.clone())); + } + } + } + } - fn fallback_discriminator_value_for_field( - &self, - schema_name: &str, - field_name: &str, - ) -> String { - // Try to extract from referenced schema first - if let Some(ref_schema) = self.schemas.get(schema_name) { - if let Some(extracted) = - self.extract_discriminator_value_for_field(ref_schema, field_name) - { - return extracted; + for (index, value) in removals { + variants[index] + .preferred_discriminator_values + .retain(|candidate| candidate != &value); + } + for variant in variants { + if let Some(canonical) = variant.preferred_discriminator_values.first() { + variant.discriminator_value.clone_from(canonical); } } + } - // Fall back to generating from name - self.generate_discriminator_value_from_name(schema_name) + fn discriminator_name_affinity(schema_name: &str, value: &str) -> usize { + let schema_compact: String = schema_name + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + let value_compact: String = value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + let exact_bonus = usize::from( + !value_compact.is_empty() && schema_compact.contains(value_compact.as_str()), + ) * 10; + let token_matches = value + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .filter(|token| schema_compact.contains(&token.to_ascii_lowercase())) + .count(); + exact_bonus + token_matches } fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String { @@ -4478,59 +5937,14 @@ impl SchemaAnalyzer { primary_name: String, dependencies: &mut HashSet, ) -> SchemaType { - fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool { - matches!( - &existing.schema_type, - SchemaType::StringEnum { values: existing_values } - if existing_values == values - ) - } - - let mut enum_type_name = primary_name.clone(); - let should_insert = match self.resolved_cache.get(&enum_type_name) { - None => true, - Some(existing) if matches_values(existing, &enum_values) => false, - Some(_) => { - // Collision with different values — try a - // value-suffixed name first. - let suffix = enum_values - .first() - .map(|v| self.to_pascal_case(v)) - .unwrap_or_else(|| "Variant".to_string()); - let candidate = format!("{primary_name}{suffix}"); - - let resolved = match self.resolved_cache.get(&candidate) { - None => Some((candidate.clone(), true)), - Some(existing) if matches_values(existing, &enum_values) => { - Some((candidate.clone(), false)) - } - Some(_) => { - // Walk a numeric suffix until we find - // a slot that's free or matches. - let mut found = None; - for n in 2..1000 { - let numbered = format!("{candidate}_{n}"); - match self.resolved_cache.get(&numbered) { - None => { - found = Some((numbered, true)); - break; - } - Some(existing) if matches_values(existing, &enum_values) => { - found = Some((numbered, false)); - break; - } - Some(_) => continue, - } - } - found - } - }; - - let (resolved_name, insert) = resolved.unwrap_or((candidate, true)); - enum_type_name = resolved_name; - insert - } - }; + let suffix = enum_values + .first() + .map(|value| self.to_pascal_case(value)) + .unwrap_or_else(|| "Variant".to_string()); + let collision_name = format!("{primary_name}{suffix}"); + let enum_type_name = + self.allocate_inline_schema_name(&primary_name, &collision_name, "string-enum", schema); + let should_insert = !self.resolved_cache.contains_key(&enum_type_name); // Store the enum as a named schema if this is the // first time we've seen this exact (name, values) pair. @@ -4744,7 +6158,13 @@ impl SchemaAnalyzer { return Ok(None); }; - let variant_name = self.unique_hoisted_name(schema_name, "Variant"); + let preferred_variant_name = format!("{schema_name}Variant"); + let variant_name = self.allocate_inline_schema_name( + &preferred_variant_name, + &format!("{preferred_variant_name}Inline"), + "object-variant", + schema, + ); let variant_type = self.analyze_anyof_union( branches, schema.discriminator(), @@ -4787,6 +6207,28 @@ impl SchemaAnalyzer { })) } + /// Resolve boolean branches in a union. + /// + /// `true` accepts every value, so a union containing it admits everything + /// and has no narrower type. `false` accepts none, so such a branch can + /// never be taken and is dropped — `oneOf: [A, false]` is `A`. + /// + /// Returns `Err(())` when the union is unconstrained. + #[allow(clippy::result_unit_err)] + fn resolve_boolean_branches(branches: &[Schema]) -> std::result::Result, ()> { + if branches + .iter() + .any(|branch| matches!(branch, Schema::Bool(true))) + { + return Err(()); + } + Ok(branches + .iter() + .filter(|branch| !matches!(branch, Schema::Bool(false))) + .cloned() + .collect()) + } + /// Whether a union's branches constrain only which properties are /// required. /// @@ -4797,18 +6239,63 @@ impl SchemaAnalyzer { /// with both fields optional — rather than an unrepresentable union. fn union_only_constrains_requiredness(branches: &[Schema]) -> bool { !branches.is_empty() - && branches.iter().all(|branch| { - let details = branch.details(); - branch.schema_type().is_none() - && branch.reference().is_none() - && branch.union_variants().is_none() - && details.properties.is_none() - && details.enum_values.is_none() - && details.const_value.is_none() - && details.items.is_none() - && details.additional_properties.is_none() - && details.required.is_some() - }) + && branches + .iter() + .all(Self::schema_only_constrains_requiredness) + } + + /// Requiredness formulas can nest through `anyOf`/`oneOf` and `not`, as + /// in protobuf-generated "at most one field" schemas. They constrain + /// presence but add no payload shape for a Rust field to carry. + fn schema_only_constrains_requiredness(schema: &Schema) -> bool { + let keys_are_requiredness_or_annotations = serde_json::to_value(schema) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|object| { + object.keys().all(|key| { + matches!( + key.as_str(), + "required" + | "not" + | "anyOf" + | "oneOf" + | "title" + | "description" + | "deprecated" + | "readOnly" + | "writeOnly" + | "examples" + | "example" + | "default" + | "externalDocs" + | "xml" + | "$comment" + ) || key.starts_with("x-") + }) + }); + if !keys_are_requiredness_or_annotations { + return false; + } + + match schema { + Schema::AnyOf { any_of, .. } => { + !any_of.is_empty() && any_of.iter().all(Self::schema_only_constrains_requiredness) + } + Schema::OneOf { one_of, .. } => { + !one_of.is_empty() && one_of.iter().all(Self::schema_only_constrains_requiredness) + } + other => { + let details = other.details(); + details + .required + .as_ref() + .is_some_and(|required| !required.is_empty()) + || details + .not + .as_deref() + .is_some_and(Self::schema_only_constrains_requiredness) + } + } } /// Analyze a union whose branch list is empty. @@ -4881,10 +6368,11 @@ impl SchemaAnalyzer { if pointer.is_empty() || !pointer.starts_with('/') { return Ok(None); } - let name = pointer_type_name(pointer); - if name.is_empty() { + let preferred_name = pointer_type_name(pointer); + if preferred_name.is_empty() { return Ok(None); } + let name = self.allocate_pointer_schema_name(pointer, &preferred_name); // Already resolved once, or currently being resolved further up the // stack: reference the name rather than expanding it again. if self.resolved_cache.contains_key(&name) || !self.resolving_pointers.insert(name.clone()) @@ -4902,15 +6390,24 @@ impl SchemaAnalyzer { return Ok(None); }; - let analyzed = self.analyze_property_schema_with_context(&schema, None, dependencies); + // Analyze the target as the named schema represented by the pointer. + // Property analysis invents names from the caller's current context + // (`ActionObject`, `HolderItem`), which makes two uses of the same + // pointer diverge and can overwrite recursive targets. + let saved_context = self.current_schema_name.clone(); + self.current_schema_name = Some(name.clone()); + let analyzed = self.analyze_schema_value(&schema, &name); + self.current_schema_name = saved_context; self.resolving_pointers.remove(&name); let analyzed = analyzed?; - Ok(Some(self.hoist_inline_property_type( - &name, - "", - analyzed, - dependencies, - ))) + dependencies.extend(analyzed.dependencies.iter().cloned()); + if analyzed.schema_type.renders_inline() { + return Ok(Some(analyzed.schema_type)); + } + + self.resolved_cache.insert(name.clone(), analyzed); + dependencies.insert(name.clone()); + Ok(Some(SchemaType::Reference { target: name })) } /// Give a property type a name when it needs one. @@ -4932,7 +6429,15 @@ impl SchemaAnalyzer { return schema_type; } - let hoisted_name = self.unique_hoisted_name(schema_name, property_name); + use heck::ToPascalCase; + + let preferred_name = format!("{schema_name}{}", property_name.to_pascal_case()); + let hoisted_name = self.allocate_synthetic_schema_name( + &preferred_name, + &format!("{preferred_name}Inline"), + "hoisted-property", + format!("{schema_type:?}"), + ); let hoisted_dependencies = schema_type_dependencies(&schema_type); self.resolved_cache.insert( hoisted_name.clone(), @@ -4952,28 +6457,6 @@ impl SchemaAnalyzer { } } - /// A generated name for a hoisted property type that no other schema has - /// claimed. Collisions are resolved by suffix rather than by overwriting, - /// which would silently retype an unrelated schema. - fn unique_hoisted_name(&self, schema_name: &str, property_name: &str) -> String { - use heck::ToPascalCase; - - let base = format!("{schema_name}{}", property_name.to_pascal_case()); - if !self.schemas.contains_key(&base) && !self.resolved_cache.contains_key(&base) { - return base; - } - let mut suffix = 2; - loop { - let candidate = format!("{base}{suffix}"); - if !self.schemas.contains_key(&candidate) - && !self.resolved_cache.contains_key(&candidate) - { - return candidate; - } - suffix += 1; - } - } - /// Analyze positional element schemas — 2020-12 `prefixItems` or the /// draft-04 `items: [A, B]` tuple form — into the tightest type the spec /// justifies. @@ -5046,14 +6529,24 @@ impl SchemaAnalyzer { dependencies: &mut HashSet, ) -> Result { let item_type = match items_schema { + Schema::Bool(accepts_anything) => self.untyped_value( + self.untyped_context(""), + if *accepts_anything { + UntypedReason::AnySchema + } else { + UntypedReason::NeverMatches + }, + ), Schema::Reference { reference, .. } => { // Array of referenced types - let target = self - .extract_schema_name(reference) - .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))? - .to_string(); - dependencies.insert(target.clone()); - SchemaType::Reference { target } + if let Some(target) = self.extract_schema_name(reference) { + let target = target.to_string(); + dependencies.insert(target.clone()); + SchemaType::Reference { target } + } else { + self.resolve_pointer_schema(reference, dependencies)? + .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))? + } } Schema::RecursiveRef { recursive_ref, .. } => { // Array of recursive references @@ -5111,31 +6604,19 @@ impl SchemaAnalyzer { }, OpenApiSchemaType::Object => { // Inline object in array - create a named schema for it - let object_type_name = inline_name.to_string(); - - // Analyze the object schema - let object_type = self.analyze_object_schema(items_schema, dependencies)?; - - // Create an analyzed schema for the inline object - let inline_schema = AnalyzedSchema { - name: object_type_name.clone(), - original: serde_json::to_value(items_schema).unwrap_or(Value::Null), - schema_type: object_type, - dependencies: dependencies.clone(), - nullable: false, - description: items_schema.details().description.clone(), - default: None, - }; - - // Add the inline object as a named schema - self.resolved_cache - .insert(object_type_name.clone(), inline_schema); - dependencies.insert(object_type_name.clone()); + let preferred_object_type_name = inline_name.to_string(); + let object_type_name = self.allocate_inline_schema_name( + &preferred_object_type_name, + &format!("{preferred_object_type_name}Inline"), + "array-item-object", + items_schema, + ); - // Return a reference to the named schema - SchemaType::Reference { - target: object_type_name, - } + self.add_allocated_object_schema( + object_type_name, + items_schema, + dependencies, + )? } OpenApiSchemaType::Array => { // Array of arrays - recursively analyze @@ -5156,7 +6637,13 @@ impl SchemaAnalyzer { SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => { // Generate a unique name for the union schema based on the parent context // Use the parent context directly to maintain consistent naming - let union_name = format!("{inline_name}Union"); + let preferred_union_name = format!("{inline_name}Union"); + let union_name = self.allocate_inline_schema_name( + &preferred_union_name, + &format!("{preferred_union_name}Inline"), + "array-item-union", + items_schema, + ); // Create a new analyzed schema with the correct name let mut union_schema = analyzed; @@ -5180,32 +6667,19 @@ impl SchemaAnalyzer { match inferred { OpenApiSchemaType::Object => { // Inline object in array - create a named schema for it - let object_type_name = inline_name.to_string(); - - // Analyze the object schema - let object_type = - self.analyze_object_schema(items_schema, dependencies)?; - - // Create an analyzed schema for the inline object - let inline_schema = AnalyzedSchema { - name: object_type_name.clone(), - original: serde_json::to_value(items_schema).unwrap_or(Value::Null), - schema_type: object_type, - dependencies: dependencies.clone(), - nullable: false, - description: items_schema.details().description.clone(), - default: None, - }; - - // Add the inline object as a named schema - self.resolved_cache - .insert(object_type_name.clone(), inline_schema); - dependencies.insert(object_type_name.clone()); + let preferred_object_type_name = inline_name.to_string(); + let object_type_name = self.allocate_inline_schema_name( + &preferred_object_type_name, + &format!("{preferred_object_type_name}Inline"), + "array-item-object", + items_schema, + ); - // Return a reference to the named schema - SchemaType::Reference { - target: object_type_name, - } + self.add_allocated_object_schema( + object_type_name, + items_schema, + dependencies, + )? } OpenApiSchemaType::String => { // Typeless (OpenAPI 3.1) enum in array items — @@ -5261,7 +6735,7 @@ impl SchemaAnalyzer { _ => self.analyze_property_schema_with_context(items_schema, None, dependencies)?, }; - Ok(item_type) + Ok(self.nullable_container_value(items_schema, item_type)) } fn get_number_rust_type( @@ -5274,12 +6748,157 @@ impl SchemaAnalyzer { // (callers in 2025-era code path `Integer | Number` here). let format = details.format.as_deref(); match schema_type { - OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type, + OpenApiSchemaType::Integer => self.integer_rust_type(details), OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type, _ => self.type_mapper.dynamic_json().rust_type, } } + /// Select the narrow configured integer carrier unless the schema itself + /// proves that a wider, still exactly serializable value is valid. JSON + /// Schema's `format` is an annotation, so a contradictory `format: int64` + /// plus a maximum above i64 must follow the numeric bounds rather than + /// rejecting source-valid wire values at hydration time. + fn integer_rust_type(&self, details: &crate::openapi::SchemaDetails) -> String { + fn integer_value(number: &serde_json::Number) -> Option { + number + .as_i64() + .map(i128::from) + .or_else(|| number.as_u64().map(i128::from)) + .or_else(|| { + number.as_f64().and_then(|value| { + (value.fract() == 0.0 + && value >= i128::MIN as f64 + && value <= i128::MAX as f64) + .then_some(value as i128) + }) + }) + } + + fn below(number: &serde_json::Number, boundary: i128) -> bool { + integer_value(number).is_some_and(|value| value < boundary) + } + + fn above(number: &serde_json::Number, boundary: i128) -> bool { + integer_value(number).is_some_and(|value| value > boundary) + } + + let explicitly_nonnegative = details + .minimum + .as_ref() + .and_then(integer_value) + .is_some_and(|value| value >= 0) + || matches!( + details.exclusive_minimum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value >= 0.0 + ); + + let annotated_numbers = details + .const_value + .iter() + .chain(details.default.iter()) + .chain(details.example.iter()) + .chain(details.enum_values.iter().flatten()) + .chain(details.examples.iter().flatten()) + .filter_map(Value::as_number) + .collect::>(); + let below_i32 = details + .minimum + .as_ref() + .is_some_and(|number| below(number, i128::from(i32::MIN))) + || annotated_numbers + .iter() + .any(|number| below(number, i128::from(i32::MIN))) + || matches!( + details.exclusive_minimum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value < i32::MIN as f64 + ); + let above_i32 = details + .maximum + .as_ref() + .is_some_and(|number| above(number, i128::from(i32::MAX))) + || annotated_numbers + .iter() + .any(|number| above(number, i128::from(i32::MAX))) + || matches!( + details.exclusive_maximum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value > i32::MAX as f64 + ); + let below_i64 = details + .minimum + .as_ref() + .is_some_and(|number| below(number, i128::from(i64::MIN))) + || annotated_numbers + .iter() + .any(|number| below(number, i128::from(i64::MIN))) + || matches!( + details.exclusive_minimum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value < i64::MIN as f64 + ); + let above_i64 = details + .maximum + .as_ref() + .is_some_and(|number| above(number, i128::from(i64::MAX))) + || annotated_numbers + .iter() + .any(|number| above(number, i128::from(i64::MAX))) + || matches!( + details.exclusive_maximum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value >= 9_223_372_036_854_775_808.0 + ); + let below_zero = details + .minimum + .as_ref() + .and_then(integer_value) + .is_some_and(|value| value < 0) + || annotated_numbers + .iter() + .filter_map(|number| integer_value(number)) + .any(|value| value < 0) + || matches!( + details.exclusive_minimum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value < 0.0 + ); + let above_u32 = details + .maximum + .as_ref() + .is_some_and(|number| above(number, i128::from(u32::MAX))) + || annotated_numbers + .iter() + .any(|number| above(number, i128::from(u32::MAX))) + || matches!( + details.exclusive_maximum, + Some(crate::openapi::ExclusiveBound::Number(value)) if value > u32::MAX as f64 + ); + + let configured = self + .type_mapper + .integer_format(details.format.as_deref()) + .rust_type; + match configured.as_str() { + "i32" if below_i32 || above_i32 => { + if below_i64 || above_i64 { + "i128".to_string() + } else { + "i64".to_string() + } + } + "i64" if below_i64 => "i128".to_string(), + "i64" if above_i64 && explicitly_nonnegative => "u64".to_string(), + "i64" if above_i64 => "i128".to_string(), + "u32" if below_zero => { + if below_i64 || above_i64 { + "i128".to_string() + } else { + "i64".to_string() + } + } + "u32" if above_u32 => "u64".to_string(), + "u64" if below_zero => "i128".to_string(), + configured => configured.to_string(), + } + } + fn analyze_anyof_union( &mut self, any_of_schemas: &[Schema], @@ -5287,6 +6906,8 @@ impl SchemaAnalyzer { dependencies: &mut HashSet, context_name: &str, ) -> Result { + let original_indices = (0..any_of_schemas.len()).collect::>(); + // Branches may be pointers into other parts of the document. let expanded_branches; let any_of_schemas = match self.expand_pointer_branches(any_of_schemas) { @@ -5297,20 +6918,51 @@ impl SchemaAnalyzer { None => any_of_schemas, }; - // Drop {"type": "null"} variants. Nullability is surfaced as Option - // at the property level via is_nullable_pattern(); leaving the null - // variant in here would produce a phantom `()` or `serde_json::Value` - // type alias that the generator can't render. + // A boolean branch either opens the union up or can never be taken. + let boolean_resolved; + let boolean_resolved_indices; + let any_of_schemas = match Self::resolve_boolean_branches(any_of_schemas) { + Ok(resolved) => { + boolean_resolved_indices = any_of_schemas + .iter() + .zip(original_indices.iter().copied()) + .filter_map(|(branch, index)| { + (!matches!(branch, Schema::Bool(false))).then_some(index) + }) + .collect::>(); + boolean_resolved = resolved; + boolean_resolved.as_slice() + } + Err(()) => { + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)); + } + }; + let source_indices = boolean_resolved_indices.as_slice(); + if any_of_schemas.is_empty() { + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::NeverMatches)); + } + + // Drop null-only variants. Nullability is surfaced as Option at the + // property level via is_nullable_any(); leaving the null variant in + // here would produce a phantom `()` or `serde_json::Value` type alias + // that the generator can't render. Recognize the equivalent `type`, + // `const`, and `enum` spellings. let filtered_owned: Vec; - let any_of_schemas: &[Schema] = if any_of_schemas + let filtered_indices: Vec; + let (any_of_schemas, source_indices): (&[Schema], &[usize]) = if any_of_schemas .iter() - .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null))) + .any(Schema::is_explicit_null_only) { filtered_owned = any_of_schemas .iter() - .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null))) + .filter(|s| !s.is_explicit_null_only()) .cloned() .collect(); + filtered_indices = any_of_schemas + .iter() + .zip(source_indices.iter().copied()) + .filter_map(|(schema, index)| (!schema.is_explicit_null_only()).then_some(index)) + .collect(); if filtered_owned.is_empty() { return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)); } @@ -5319,9 +6971,9 @@ impl SchemaAnalyzer { .analyze_schema_value(&filtered_owned[0], context_name) .map(|a| a.schema_type); } - &filtered_owned + (&filtered_owned, &filtered_indices) } else { - any_of_schemas + (any_of_schemas, source_indices) }; // A union of one is that one: gcore writes `anyOf: [{allOf: [...]}]` @@ -5365,6 +7017,8 @@ impl SchemaAnalyzer { Some(disc), context_name, dependencies, + InlineUnionKind::AnyOf, + Some(source_indices), ); } @@ -5380,52 +7034,67 @@ impl SchemaAnalyzer { }), context_name, dependencies, + InlineUnionKind::AnyOf, + Some(source_indices), ); } // Create an untagged union for flexible matching let mut variants = Vec::new(); - for schema in any_of_schemas { + for (schema, original_index) in + any_of_schemas.iter().zip(source_indices.iter().copied()) + { if let Some(ref_str) = schema.reference() { if let Some(target) = self.extract_schema_name(ref_str) { dependencies.insert(target.to_string()); variants.push(SchemaRef { target: target.to_string(), - nullable: false, + nullable: self.schema_or_reference_is_nullable(schema), }); } } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) || schema.inferred_type() == Some(OpenApiSchemaType::Object) { // Generate inline object type for anyOf union - let inline_index = variants.len(); - let inline_type_name = self.generate_inline_type_name(schema, inline_index); + let inline_type_name = self.generate_inline_type_name(schema, original_index); // Store inline schema for later analysis and generation - self.add_inline_schema(&inline_type_name, schema, dependencies)?; + let inline_type_name = self.add_inline_union_branch_schema( + &inline_type_name, + schema, + dependencies, + context_name, + InlineUnionKind::AnyOf, + original_index, + None, + )?; variants.push(SchemaRef { target: inline_type_name, nullable: false, }); } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) { - // Handle array types in unions by creating a type alias - let array_type = - self.analyze_array_schema(schema, context_name, dependencies)?; - // Create a unique name for this array type in the union - let array_type_name = if let Some(items_schema) = schema.details().item_schema() - { - if let Some(ref_str) = items_schema.reference() { - if let Some(item_type_name) = self.extract_schema_name(ref_str) { - dependencies.insert(item_type_name.to_string()); - format!("{item_type_name}Array") + let preferred_array_type_name = + if let Some(items_schema) = schema.details().item_schema() { + if let Some(ref_str) = items_schema.reference() { + if let Some(item_type_name) = self.extract_schema_name(ref_str) { + dependencies.insert(item_type_name.to_string()); + format!("{item_type_name}Array") + } else { + self.generate_context_aware_name( + context_name, + "Array", + original_index, + Some(schema), + ) + } } else { self.generate_context_aware_name( context_name, "Array", - variants.len(), + original_index, Some(schema), ) } @@ -5433,18 +7102,21 @@ impl SchemaAnalyzer { self.generate_context_aware_name( context_name, "Array", - variants.len(), + original_index, Some(schema), ) - } - } else { - self.generate_context_aware_name( - context_name, - "Array", - variants.len(), - Some(schema), - ) - }; + }; + let array_type_name = self.allocate_inline_union_branch_name( + &preferred_array_type_name, + context_name, + InlineUnionKind::AnyOf, + original_index, + None, + schema, + ); + // Handle array types in unions by creating a type alias. + let array_type = + self.analyze_array_schema(schema, context_name, dependencies)?; // Store the array as a type alias self.resolved_cache.insert( @@ -5483,44 +7155,51 @@ impl SchemaAnalyzer { .unwrap_or(true); if primitive_unions { - let mapped = self.type_mapper.map(schema_type.clone(), schema.details()); variants.push(SchemaRef { - target: mapped.rust_type, + target: self + .openapi_type_to_rust_type(schema_type.clone(), schema.details()), nullable: false, }); } else { - let inline_index = variants.len(); - let inline_type_name = match schema_type { + let preferred_inline_type_name = match schema_type { OpenApiSchemaType::String => { - if inline_index == 0 { + if original_index == 0 { format!("{context_name}String") } else { - format!("{context_name}StringVariant{inline_index}") + format!("{context_name}StringVariant{original_index}") } } OpenApiSchemaType::Number => { - if inline_index == 0 { + if original_index == 0 { format!("{context_name}Number") } else { - format!("{context_name}NumberVariant{inline_index}") + format!("{context_name}NumberVariant{original_index}") } } OpenApiSchemaType::Integer => { - if inline_index == 0 { + if original_index == 0 { format!("{context_name}Integer") } else { - format!("{context_name}IntegerVariant{inline_index}") + format!("{context_name}IntegerVariant{original_index}") } } OpenApiSchemaType::Boolean => { - if inline_index == 0 { + if original_index == 0 { format!("{context_name}Boolean") } else { - format!("{context_name}BooleanVariant{inline_index}") + format!("{context_name}BooleanVariant{original_index}") } } - _ => format!("{context_name}Variant{inline_index}"), + _ => format!("{context_name}Variant{original_index}"), }; + let inline_type_name = self.allocate_inline_union_branch_name( + &preferred_inline_type_name, + context_name, + InlineUnionKind::AnyOf, + original_index, + None, + schema, + ); let rust_type = self.openapi_type_to_rust_type(schema_type.clone(), schema.details()); @@ -5548,11 +7227,42 @@ impl SchemaAnalyzer { nullable: false, }); } + } else { + // A composition can itself be one branch of an outer + // union, for example `anyOf: [{ oneOf: [...], + // discriminator: ... }, { type: string }]`. It has no + // direct `type`, so the arms above used to silently drop + // it and leave the generated Rust union unable to hydrate + // schema-valid object input. Hoist the branch and let the + // normal analyzer preserve its nested composition. + let inline_type_name = self.generate_context_aware_name( + context_name, + "InlineVariant", + original_index, + Some(schema), + ); + let inline_type_name = self.add_inline_union_branch_schema( + &inline_type_name, + schema, + dependencies, + context_name, + InlineUnionKind::AnyOf, + original_index, + None, + )?; + dependencies.insert(inline_type_name.clone()); + variants.push(SchemaRef { + target: inline_type_name, + nullable: false, + }); } } if !variants.is_empty() { - return Ok(SchemaType::Union { variants }); + return Ok(SchemaType::Union { + variants, + exclusive: false, + }); } } @@ -5667,8 +7377,10 @@ impl SchemaAnalyzer { let details = schema.details(); - // If it has explicit additionalProperties, it should remain as a typed object - // that will be generated as BTreeMap or similar + // An explicit additionalProperties policy is structural even when no + // named properties exist. `true`/a schema needs a map carrier, while + // `false` is a closed empty object (GitHub's `empty-object`) and must + // not become `serde_json::Value`, which would match every oneOf branch. if self.has_explicit_additional_properties(schema) { return false; } @@ -5686,7 +7398,7 @@ impl SchemaAnalyzer { let has_structural_constraints = details .required .as_ref() - .map(|req| req.iter().any(|r| r != "type")) + .map(|req| !req.is_empty()) .unwrap_or(false) || details.pattern_properties.is_some() || details.property_names.is_some() @@ -5704,16 +7416,10 @@ impl SchemaAnalyzer { false } - /// Check if this is an object that explicitly allows arbitrary additional properties + /// Check whether the object declares any explicit additional-properties policy. fn has_explicit_additional_properties(&self, schema: &Schema) -> bool { let details = schema.details(); - - // Check if additionalProperties is explicitly set to true or a schema - matches!( - &details.additional_properties, - Some(crate::openapi::AdditionalProperties::Boolean(true)) - | Some(crate::openapi::AdditionalProperties::Schema(_)) - ) + details.additional_properties.is_some() } /// Analyze OpenAPI operations to extract request/response schemas @@ -6094,7 +7800,8 @@ impl SchemaAnalyzer { // Use the existing inline schema infrastructure let mut deps = HashSet::new(); - self.add_inline_schema(&synthetic_name, schema, &mut deps)?; + let synthetic_name = + self.add_inline_schema(&synthetic_name, schema, &mut deps)?; op_info .response_schemas @@ -6490,8 +8197,7 @@ impl SchemaAnalyzer { self.generate_inline_response_type_name(operation_id, "") }; let mut deps = HashSet::new(); - self.add_inline_schema(&synthetic_name, schema, &mut deps)?; - Ok(synthetic_name) + self.add_inline_schema(&synthetic_name, schema, &mut deps) } /// Resolve a parameter reference ($ref) to the actual parameter definition. @@ -6683,7 +8389,7 @@ impl SchemaAnalyzer { let param_pascal = name.to_pascal_case(); let synthetic_name = format!("{op_pascal}{param_pascal}"); let mut deps = HashSet::new(); - self.add_inline_schema(&synthetic_name, schema, &mut deps)?; + let synthetic_name = self.add_inline_schema(&synthetic_name, schema, &mut deps)?; schema_ref = Some(synthetic_name.clone()); query_serialization = if form_exploded && self.uses_aws_query_conventions() { match self.referenced_array_struct_item_type(&synthetic_name, 1) { @@ -6725,9 +8431,7 @@ impl SchemaAnalyzer { let format = schema.details().format.clone(); rust_type = match schema_type { crate::openapi::SchemaType::Boolean => "bool".to_string(), - crate::openapi::SchemaType::Integer => { - self.type_mapper.integer_format(format.as_deref()).rust_type - } + crate::openapi::SchemaType::Integer => self.integer_rust_type(schema.details()), crate::openapi::SchemaType::Number => { self.type_mapper.number_format(format.as_deref()).rust_type } @@ -6853,9 +8557,7 @@ impl SchemaAnalyzer { let format = unwrapped.details().format.clone(); let scalar = match unwrapped.schema_type()? { crate::openapi::SchemaType::String => "String".to_string(), - crate::openapi::SchemaType::Integer => { - self.type_mapper.integer_format(format.as_deref()).rust_type - } + crate::openapi::SchemaType::Integer => self.integer_rust_type(unwrapped.details()), crate::openapi::SchemaType::Number => { self.type_mapper.number_format(format.as_deref()).rust_type } @@ -7414,12 +9116,12 @@ fn json_pointer(path: &serde_path_to_error::Path) -> String { pointer } -fn disambiguate_component_schema_names(openapi_spec: &mut Value) { +pub(crate) fn component_schema_name_aliases(openapi_spec: &Value) -> BTreeMap { let Some(schemas) = openapi_spec - .pointer_mut("/components/schemas") - .and_then(Value::as_object_mut) + .pointer("/components/schemas") + .and_then(Value::as_object) else { - return; + return BTreeMap::new(); }; let mut names_by_rust_name = BTreeMap::>::new(); @@ -7433,7 +9135,7 @@ fn disambiguate_component_schema_names(openapi_spec: &mut Value) { // Reserve every identifier already represented by the document so a // suffix never steals another component's canonical Rust name. let mut claimed_rust_names = names_by_rust_name.keys().cloned().collect::>(); - let mut aliases = BTreeMap::::new(); + let mut aliases = BTreeMap::new(); for (rust_name, mut names) in names_by_rust_name { if names.len() < 2 { @@ -7453,17 +9155,33 @@ fn disambiguate_component_schema_names(openapi_spec: &mut Value) { suffix += 1; }; - eprintln!( - "⚠️ schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`" - ); aliases.insert(source_name, replacement); } } + aliases +} + +fn disambiguate_component_schema_names(openapi_spec: &mut Value) { + let aliases = component_schema_name_aliases(openapi_spec); if aliases.is_empty() { return; } + for (source_name, replacement) in &aliases { + let rust_name = crate::generator::rust_type_name(source_name); + eprintln!( + "⚠️ schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`" + ); + } + + let Some(schemas) = openapi_spec + .pointer_mut("/components/schemas") + .and_then(Value::as_object_mut) + else { + return; + }; + let original_schemas = std::mem::take(schemas); for (name, schema) in original_schemas { schemas.insert(aliases.get(&name).cloned().unwrap_or(name), schema); @@ -7633,12 +9351,13 @@ fn rewrite_schema_type_names(schema_type: &mut SchemaType, aliases: &BTreeMap { + SchemaType::Union { variants, .. } | SchemaType::Composition { schemas: variants } => { for variant in variants { variant.target = renamed_schema_name(&variant.target, aliases); } } SchemaType::Array { item_type } => rewrite_schema_type_names(item_type, aliases), + SchemaType::Nullable { inner_type } => rewrite_schema_type_names(inner_type, aliases), SchemaType::Untyped { .. } => {} SchemaType::Tuple { element_types } => { for element_type in element_types { diff --git a/src/bin/schema-roundtrip.rs b/src/bin/schema-roundtrip.rs new file mode 100644 index 0000000..e82b145 --- /dev/null +++ b/src/bin/schema-roundtrip.rs @@ -0,0 +1,53 @@ +//! Emit compiled round-trip tests for one OpenAPI document. +//! +//! The full corpus gate calls this after generating each scratch crate: +//! +//! ```text +//! schema-roundtrip SPEC OUTPUT_RS STATS_FILE +//! ``` + +use openapi_to_rust::schema_roundtrip::build_round_trip_plan; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let mut args = std::env::args_os().skip(1); + let spec_path = PathBuf::from(args.next().ok_or("missing SPEC argument")?); + let output_path = PathBuf::from(args.next().ok_or("missing OUTPUT_RS argument")?); + let stats_path = PathBuf::from(args.next().ok_or("missing STATS_FILE argument")?); + if args.next().is_some() { + return Err("usage: schema-roundtrip SPEC OUTPUT_RS STATS_FILE".into()); + } + + let body = fs::read_to_string(&spec_path)?; + let input_label = spec_path.to_string_lossy(); + let spec = openapi_to_rust::spec_source::parse_spec(&body, &input_label)?; + let plan = build_round_trip_plan(&spec, 4)?; + + for path in [&output_path, &stats_path] { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + } + fs::write(&output_path, &plan.source)?; + fs::write(&stats_path, plan.stats.to_shell())?; + + println!( + "schema-roundtrip: {} schema(s), {} sample(s), {} skipped ({} source-invalid, {} dependent, {} synthesis-skipped)", + plan.stats.tested_schemas, + plan.stats.samples, + plan.stats.skipped_schemas, + plan.stats.source_invalid_schemas, + plan.stats.dependent_schemas, + plan.stats.synthesis_skipped_schemas + ); + for skipped in plan.skipped.iter().take(12) { + println!(" skip {}: {}", skipped.schema, skipped.reason); + } + if plan.skipped.len() > 12 { + println!(" ... {} more skipped", plan.skipped.len() - 12); + } + Ok(()) +} diff --git a/src/client_generator.rs b/src/client_generator.rs index 6fe99b9..2115af9 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -166,6 +166,7 @@ struct BodyFieldPlan { value_ident: syn::Ident, value_type: TokenStream, access_path: Vec, + tri_state: bool, } #[derive(Clone, Copy)] @@ -770,6 +771,7 @@ impl CodeGenerator { &field.preferred_method_name, &mut used_methods, ); + let preferred_method_name = field.preferred_method_name.clone(); let value_ident = field.value_ident; let value_type = field.value_type; let wire_name = field.wire_name; @@ -779,15 +781,26 @@ impl CodeGenerator { for access in &access_path { target = quote! { #target.#access }; } - quote! { #target = Some(#value_ident); } + if field.tri_state { + quote! { #target = Some(Some(#value_ident)); } + } else { + quote! { #target = Some(#value_ident); } + } } else { let mut target = quote! { request }; for access in &access_path { target = quote! { #target.#access }; } - quote! { - let request = self.#body_ident.get_or_insert_with(Default::default); - #target = Some(#value_ident); + if field.tri_state { + quote! { + let request = self.#body_ident.get_or_insert_with(Default::default); + #target = Some(Some(#value_ident)); + } + } else { + quote! { + let request = self.#body_ident.get_or_insert_with(Default::default); + #target = Some(#value_ident); + } } }; setters.push(quote! { @@ -798,6 +811,64 @@ impl CodeGenerator { self } }); + if field.tri_state { + let null_ident = Self::allocate_builder_method( + &format!("{preferred_method_name}_null"), + &mut used_methods, + ); + let absent_ident = Self::allocate_builder_method( + &format!("{preferred_method_name}_absent"), + &mut used_methods, + ); + let null_assignment = if operation.request_body_required { + let mut target = quote! { self.#body_ident }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { #target = Some(None); } + } else { + let mut target = quote! { request }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { + let request = self.#body_ident.get_or_insert_with(Default::default); + #target = Some(None); + } + }; + let absent_assignment = if operation.request_body_required { + let mut target = quote! { self.#body_ident }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { #target = None; } + } else { + let mut target = quote! { request }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { + if let Some(request) = self.#body_ident.as_mut() { + #target = None; + } + } + }; + setters.push(quote! { + #[doc = concat!("Set the optional nullable request-body field `", #wire_name, "` to JSON null.")] + #[must_use] + pub fn #null_ident(mut self) -> Self { + #null_assignment + self + } + + #[doc = concat!("Omit the optional nullable request-body field `", #wire_name, "`.")] + #[must_use] + pub fn #absent_ident(mut self) -> Self { + #absent_assignment + self + } + }); + } } } call_arguments.push(quote! { self.#body_ident }); @@ -997,7 +1068,6 @@ impl CodeGenerator { required, additional_properties, analysis, - None, ); let required_fields: Vec<_> = emitted .iter() @@ -1080,7 +1150,6 @@ impl CodeGenerator { required, additional_properties, analysis, - None, ) { if field.is_required { continue; @@ -1098,6 +1167,12 @@ impl CodeGenerator { analysis, ), access_path: field_path, + tri_state: self.property_is_tri_state( + schema_name, + field.wire_name, + field.property, + field.is_required, + ), }); } } @@ -2560,7 +2635,6 @@ impl CodeGenerator { required, additional_properties, analysis, - None, ); if matches!( additional_properties, @@ -2577,6 +2651,13 @@ impl CodeGenerator { let mut parts = Vec::new(); for field in fields { let wire_name = field.wire_name; + let is_nullable = self.property_is_nullable(resolved_name, wire_name, field.property); + let is_tri_state = self.property_is_tri_state( + resolved_name, + wire_name, + field.property, + field.is_required, + ); let ident = field.ident; let wire_format = wire_properties .and_then(|properties| properties.get(wire_name)) @@ -2635,7 +2716,22 @@ impl CodeGenerator { form = form.text(#wire_name, value.to_string()); }, }; - parts.push(if field.is_required { + parts.push(if is_tri_state { + // Multipart has no representation for a JSON null part. A + // present concrete value is sent; both outer absence and an + // explicit-null model state omit the part. + quote! { + if let Some(Some(value)) = &request.#ident { + #add_value + } + } + } else if field.is_required && is_nullable { + quote! { + if let Some(value) = &request.#ident { + #add_value + } + } + } else if field.is_required { quote! { let value = &request.#ident; #add_value diff --git a/src/generator.rs b/src/generator.rs index 52967bb..72b1f49 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,4 +1,8 @@ -use crate::{GeneratorError, Result, analysis::SchemaAnalysis, streaming::StreamingConfig}; +use crate::{ + GeneratorError, Result, + analysis::{SchemaAnalysis, SchemaType}, + streaming::StreamingConfig, +}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use std::collections::BTreeMap; @@ -84,18 +88,7 @@ fn strip_trailing_zero(v: f64) -> String { } } -/// Info about schemas that are variants in discriminated unions -#[derive(Clone)] -pub(crate) struct DiscriminatedVariantInfo { - /// The discriminator field name (e.g., "type") - pub(crate) discriminator_field: String, - /// The const value of the discriminator (e.g., "text") - pub(crate) discriminator_value: String, - /// Whether the parent union is untagged - pub(crate) is_parent_untagged: bool, -} - -/// One object property after discriminator filtering and Rust-identifier +/// One object property after Rust-identifier /// disambiguation. Struct fields, request-model constructors, and builders /// all consume this shared projection so their names and types cannot drift. pub(crate) struct EmittedObjectProperty<'a> { @@ -115,7 +108,6 @@ struct TypeGenerationIndex { } struct TypeGenerationContext<'a> { - discriminated_variants: &'a BTreeMap, index: &'a TypeGenerationIndex, } @@ -395,6 +387,37 @@ fn untyped_tokens(shape: crate::analysis::UntypedShape) -> TokenStream { } } +fn schema_type_uses_serde_codec(schema_type: &SchemaType, codec: &str) -> bool { + match schema_type { + SchemaType::Primitive { + serde_with: Some(actual), + .. + } => actual == codec, + SchemaType::Object { + properties, + additional_properties, + .. + } => { + properties + .values() + .any(|property| schema_type_uses_serde_codec(&property.schema_type, codec)) + || matches!( + additional_properties, + crate::analysis::ObjectAdditionalProperties::Typed { value_type } + if schema_type_uses_serde_codec(value_type, codec) + ) + } + SchemaType::Array { item_type } + | SchemaType::Nullable { + inner_type: item_type, + } => schema_type_uses_serde_codec(item_type, codec), + SchemaType::Tuple { element_types } => element_types + .iter() + .any(|element| schema_type_uses_serde_codec(element, codec)), + _ => false, + } +} + impl CodeGenerator { pub fn new(config: GeneratorConfig) -> Self { Self { @@ -542,53 +565,8 @@ impl CodeGenerator { let provenance_attribute = self.provenance_attribute(); let mut type_definitions = TokenStream::new(); - // Collect all schemas that are used as variants in discriminated unions - // Only include direct references, not schemas wrapped in allOf - let mut discriminated_variant_info: BTreeMap = - BTreeMap::new(); - - // Sort schemas for deterministic processing - let mut sorted_schemas: Vec<_> = analysis.schemas.iter().collect(); - sorted_schemas.sort_by_key(|(name, _)| name.as_str()); - - for (_parent_name, schema) in sorted_schemas { - if let crate::analysis::SchemaType::DiscriminatedUnion { - variants, - discriminator_field, - } = &schema.schema_type - { - // Check if this discriminated union will be generated as untagged - let is_parent_untagged = - self.should_use_untagged_discriminated_union(schema, analysis); - - for variant in variants { - // Only add if it's a direct reference to a schema that will have the discriminator field - // Check if the schema exists and has the discriminator field as a property - if let Some(variant_schema) = analysis.schemas.get(&variant.type_name) { - if let crate::analysis::SchemaType::Object { properties, .. } = - &variant_schema.schema_type - { - if properties.contains_key(discriminator_field) { - discriminated_variant_info.insert( - variant.type_name.clone(), - DiscriminatedVariantInfo { - discriminator_field: discriminator_field.clone(), - discriminator_value: variant.discriminator_value.clone(), - is_parent_untagged, - }, - ); - } - } - } - } - } - } - let type_index = self.type_generation_index(analysis); - let type_context = TypeGenerationContext { - discriminated_variants: &discriminated_variant_info, - index: &type_index, - }; + let type_context = TypeGenerationContext { index: &type_index }; // Generate types based on dependency order let generation_order = analysis.dependencies.topological_sort()?; @@ -621,10 +599,63 @@ impl CodeGenerator { } } + let mut uses_plain_tri_state = false; + let mut tri_state_codecs = std::collections::HashSet::new(); + for (schema_name, schema) in &analysis.schemas { + let crate::analysis::SchemaType::Object { + properties, + required, + .. + } = &schema.schema_type + else { + continue; + }; + for (field_name, property) in properties { + if !self.property_is_tri_state( + schema_name, + field_name, + property, + required.contains(field_name), + ) { + continue; + } + if let Some(codec) = self.schema_type_serde_codec(&property.schema_type, analysis) { + tri_state_codecs.insert(codec); + } else { + uses_plain_tri_state = true; + } + } + } + // Helper modules emitted only when the analyzer actually // referenced their codecs. Avoids polluting every generated // file (and every snapshot) with dead code for specs that // don't use `format: byte`. + let base64_double_option = if tri_state_codecs.contains("base64_serde") { + quote! { + pub mod double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>>, + ser: S, + ) -> Result { + match value { + Some(value) => super::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>>, D::Error> { + super::option::deserialize(de).map(Some) + } + } + } + } else { + TokenStream::new() + }; let base64_helper = if analysis .used_type_features .contains(crate::type_mapping::TypeFeature::Base64) @@ -691,6 +722,190 @@ impl CodeGenerator { .transpose() } } + + #base64_double_option + } + } + } else { + TokenStream::new() + }; + + let uses_binary_bytes_codec = analysis + .schemas + .values() + .any(|schema| schema_type_uses_serde_codec(&schema.schema_type, "binary_bytes_serde")); + let binary_bytes_double_option = if tri_state_codecs.contains("binary_bytes_serde") { + quote! { + pub mod double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>, + ser: S, + ) -> Result { + match value { + Some(value) => super::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + super::option::deserialize(de).map(Some) + } + } + } + } else { + TokenStream::new() + }; + let binary_bytes_helper = if uses_binary_bytes_codec { + quote! { + /// UTF-8 JSON string codec for `bytes::Bytes` model fields + /// produced from `format: binary`. Raw HTTP body and multipart + /// paths use their byte carriers directly and do not invoke it. + mod binary_bytes_serde { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + bytes: &bytes::Bytes, + ser: S, + ) -> Result { + let value = std::str::from_utf8(bytes.as_ref()) + .map_err(serde::ser::Error::custom)?; + ser.serialize_str(value) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result { + String::deserialize(de).map(bytes::Bytes::from) + } + + pub mod option { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + value: &Option, + ser: S, + ) -> Result { + match value { + Some(bytes) => super::serialize(bytes, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + Option::::deserialize(de) + .map(|value| value.map(bytes::Bytes::from)) + } + } + + #binary_bytes_double_option + } + } + } else { + TokenStream::new() + }; + + let uses_binary_vec_codec = analysis + .schemas + .values() + .any(|schema| schema_type_uses_serde_codec(&schema.schema_type, "binary_vec_serde")); + let binary_vec_double_option = if tri_state_codecs.contains("binary_vec_serde") { + quote! { + /// Preserve the distinction between an omitted field and an + /// explicit JSON null while retaining the binary codec. + pub mod double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>>, + ser: S, + ) -> Result { + match value { + Some(value) => super::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>>, D::Error> { + super::option::deserialize(de).map(Some) + } + } + } + } else { + TokenStream::new() + }; + let binary_vec_helper = if uses_binary_vec_codec { + quote! { + /// UTF-8 JSON string codec for `Vec` model fields produced + /// from `format: binary` under the vec_u8 strategy. + mod binary_vec_serde { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + bytes: &Vec, + ser: S, + ) -> Result { + let value = std::str::from_utf8(bytes) + .map_err(serde::ser::Error::custom)?; + ser.serialize_str(value) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + String::deserialize(de).map(String::into_bytes) + } + + pub mod option { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + value: &Option>, + ser: S, + ) -> Result { + match value { + Some(bytes) => super::serialize(bytes, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + Option::::deserialize(de) + .map(|value| value.map(String::into_bytes)) + } + } + + #binary_vec_double_option + } + } + } else { + TokenStream::new() + }; + + let tri_state_helper = if uses_plain_tri_state { + quote! { + /// Serde normally maps both a missing `Option` field and an + /// explicit JSON null to `None`. Wrapping the decoded value in + /// `Some` retains the field-presence bit for `Option>`. + mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } } } } else { @@ -703,6 +918,31 @@ impl CodeGenerator { // the `format_description!` macro. It expands to a module // (with an `::option` submodule) referenced from fields as // `#[serde(with = "time_date_format")]` etc. + let time_date_double_option = if tri_state_codecs.contains("time_date_format") { + quote! { + mod time_date_double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>, + ser: S, + ) -> Result { + match value { + Some(value) => time_date_format::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + time_date_format::option::deserialize(de).map(Some) + } + } + } + } else { + TokenStream::new() + }; let time_date_helper = if analysis .used_type_features .contains(crate::type_mapping::TypeFeature::TimeDate) @@ -713,6 +953,8 @@ impl CodeGenerator { Date, "[year]-[month]-[day]" ); + + #time_date_double_option } } else { TokenStream::new() @@ -722,6 +964,31 @@ impl CodeGenerator { // format their contents, so whole seconds serialize with a // trailing ".0" — in exchange, parsing accepts inputs both // with and without fractional seconds. + let time_time_double_option = if tri_state_codecs.contains("time_time_format") { + quote! { + mod time_time_double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>, + ser: S, + ) -> Result { + match value { + Some(value) => time_time_format::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + time_time_format::option::deserialize(de).map(Some) + } + } + } + } else { + TokenStream::new() + }; let time_time_helper = if analysis .used_type_features .contains(crate::type_mapping::TypeFeature::TimeTime) @@ -733,6 +1000,35 @@ impl CodeGenerator { Time, "[hour]:[minute]:[second][optional [.[subsecond]]]" ); + + #time_time_double_option + } + } else { + TokenStream::new() + }; + + let time_rfc3339_double_option_helper = if tri_state_codecs.contains("time::serde::rfc3339") + { + quote! { + mod time_rfc3339_double_option { + use serde::{Deserializer, Serializer}; + + pub fn serialize( + value: &Option>, + ser: S, + ) -> Result { + match value { + Some(value) => time::serde::rfc3339::option::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + time::serde::rfc3339::option::deserialize(de).map(Some) + } + } } } else { TokenStream::new() @@ -756,10 +1052,18 @@ impl CodeGenerator { #base64_helper + #binary_bytes_helper + + #binary_vec_helper + + #tri_state_helper + #time_date_helper #time_time_helper + #time_rfc3339_double_option_helper + #type_definitions }; @@ -1490,6 +1794,7 @@ impl CodeGenerator { SchemaType::DiscriminatedUnion { discriminator_field, variants, + exclusive, } => { // Check if this discriminated union should be untagged due to being nested if self.should_use_untagged_discriminated_union(schema, analysis) { @@ -1501,17 +1806,21 @@ impl CodeGenerator { nullable: false, }) .collect(); - self.generate_union_enum(schema, &schema_refs, analysis) + self.generate_union_enum(schema, &schema_refs, *exclusive, analysis) } else { self.generate_discriminated_enum( schema, discriminator_field, variants, + *exclusive, analysis, ) } } - SchemaType::Union { variants } => self.generate_union_enum(schema, variants, analysis), + SchemaType::Union { + variants, + exclusive, + } => self.generate_union_enum(schema, variants, *exclusive, analysis), SchemaType::Reference { target } => { // For references, check if we need to generate a type alias // This handles cases like nullable patterns @@ -1555,47 +1864,8 @@ impl CodeGenerator { SchemaType::Array { item_type } => { // Generate type alias for named array schemas. // - // Special case: if the array item is a struct whose discriminator - // field was stripped (because it's used in a tagged enum), the bare - // struct won't serialize the discriminator in standalone contexts. - // Generate a single-variant tagged wrapper enum so the discriminator - // field is re-added by serde's tag attribute. let array_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); - // Check if the item type is a Reference to a discriminator-stripped struct - if let SchemaType::Reference { target } = item_type.as_ref() { - if let Some(info) = type_context.discriminated_variants.get(target) { - if !info.is_parent_untagged { - // Generate a wrapper enum that re-adds the discriminator tag - let wrapper_name = - format_ident!("{}Item", self.to_rust_type_name(&schema.name)); - let variant_type = format_ident!("{}", self.to_rust_type_name(target)); - let disc_field = &info.discriminator_field; - let disc_value = &info.discriminator_value; - - let doc_comment = if let Some(desc) = &schema.description { - quote! { #[doc = #desc] } - } else { - TokenStream::new() - }; - - return Ok(quote! { - /// Wrapper enum that re-adds the discriminator tag - /// for array contexts where the inner struct had its - /// discriminator field stripped for tagged enum use. - #[derive(Debug, Clone, Deserialize, Serialize)] - #[serde(tag = #disc_field)] - pub enum #wrapper_name { - #[serde(rename = #disc_value)] - #variant_type(#variant_type), - } - #doc_comment - pub type #array_name = Vec<#wrapper_name>; - }); - } - } - } - let inner_type = self.generate_array_item_type(item_type, analysis); let doc_comment = if let Some(desc) = &schema.description { @@ -1609,6 +1879,13 @@ impl CodeGenerator { pub type #array_name = Vec<#inner_type>; }) } + SchemaType::Nullable { inner_type } => { + let nullable_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); + let inner_type = self.generate_array_item_type(inner_type, analysis); + Ok(quote! { + pub type #nullable_name = Option<#inner_type>; + }) + } SchemaType::Composition { schemas } => { self.generate_composition_struct(schema, schemas) } @@ -1970,8 +2247,9 @@ impl CodeGenerator { required, additional_properties, analysis, - type_context.discriminated_variants.get(&schema.name), ); + let variant_field_ident = + variant.map(|_| format_ident!("{}", self.variant_field_name(properties))); let mut fields: Vec = emitted_properties .iter() @@ -1980,14 +2258,18 @@ impl CodeGenerator { let property = emitted.property; let field_ident = &emitted.ident; let field_type = &emitted.field_type; - let serde_attrs = self.generate_serde_field_attrs( - &schema.name, - field_name, - field_ident, - property, - emitted.is_required, - analysis, - ); + let serde_attrs = if variant.is_none() { + self.generate_serde_field_attrs( + &schema.name, + field_name, + field_ident, + property, + emitted.is_required, + analysis, + ) + } else { + TokenStream::new() + }; let specta_attrs = self.generate_specta_field_attrs(field_name); let doc_comment = if let Some(desc) = &property.description { @@ -2016,19 +2298,29 @@ impl CodeGenerator { match additional_properties { crate::analysis::ObjectAdditionalProperties::Forbidden => {} crate::analysis::ObjectAdditionalProperties::Untyped => { + let serde_flatten = if variant.is_none() { + quote! { #[serde(flatten)] } + } else { + TokenStream::new() + }; fields.push(quote! { /// Additional properties not explicitly defined in the schema - #[serde(flatten)] + #serde_flatten pub additional_properties: std::collections::BTreeMap, }); } crate::analysis::ObjectAdditionalProperties::Typed { value_type } => { let value_tokens = self.generate_array_item_type(value_type, analysis); + let serde_flatten = if variant.is_none() { + quote! { #[serde(flatten)] } + } else { + TokenStream::new() + }; fields.push(quote! { /// Additional properties matching the spec's /// `additionalProperties` value schema. - #[serde(flatten)] + #serde_flatten pub additional_properties: std::collections::BTreeMap, }); @@ -2039,12 +2331,10 @@ impl CodeGenerator { // fields, and one of these shapes". The union rides in a flattened // field so both halves round-trip: serde reads the declared properties // and hands the remaining keys to the variant enum. - if let Some(variant) = variant { + if let (Some(variant), Some(variant_field)) = (variant, variant_field_ident.as_ref()) { let variant_type = format_ident!("{}", self.to_rust_type_name(&variant.target)); - let variant_field = format_ident!("{}", self.variant_field_name(properties)); fields.push(quote! { /// The variant this value takes, alongside the fields above. - #[serde(flatten)] pub #variant_field: #variant_type, }); } @@ -2055,38 +2345,207 @@ impl CodeGenerator { TokenStream::new() }; - // Default is safe only when no emitted wire property is required. + // Default is safe only when the schema requires no wire property. // Optional fields are represented as Option, and the generated // additional-properties map (when present) is empty by default. We do // not invent values for required data, even when the Rust type itself // happens to implement Default. // A flattened variant is one of several shapes, and picking one would // invent data the same way a required field would. - let can_derive_default = variant.is_none() - && emitted_properties - .iter() - .all(|property| !property.is_required); + let can_derive_default = variant.is_none() && required.is_empty(); // Generate derives with optional Specta support // Note: We use snake_case everywhere (matching the OpenAPI spec) for consistency // between Rust, JSON API, and TypeScript - let derives = match (self.config.enable_specta, can_derive_default) { - (true, true) => quote! { + let derives = match ( + self.config.enable_specta, + can_derive_default, + variant.is_some(), + ) { + (true, _, true) => quote! { + #[derive(Debug, Clone)] + #[cfg_attr(feature = "specta", derive(specta::Type))] + }, + (false, _, true) => quote! { + #[derive(Debug, Clone)] + }, + (true, true, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize, Default)] #[cfg_attr(feature = "specta", derive(specta::Type))] }, - (true, false) => quote! { + (true, false, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] }, - (false, true) => quote! { + (false, true, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize, Default)] }, - (false, false) => quote! { + (false, false, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize)] }, }; + // `#[serde(flatten)]` removes keys already consumed by sibling fields + // before it invokes the flattened value's deserializer. That is wrong + // for a sibling-property + oneOf schema when both halves intentionally + // share the discriminator. Decode both halves from the complete JSON + // object, and merge their serialized maps with duplicate-value checks. + let shared_variant_serde = if let (Some(variant), Some(variant_field)) = + (variant, variant_field_ident.as_ref()) + { + let variant_type = format_ident!("{}", self.to_rust_type_name(&variant.target)); + let helper_name = format_ident!("__{}Base", self.to_rust_type_name(&schema.name)); + let variant_properties = self.union_declared_properties( + &variant.target, + analysis, + &mut std::collections::HashSet::new(), + ); + let variant_projection_removals = emitted_properties + .iter() + .filter(|emitted| !variant_properties.contains(emitted.wire_name)) + .map(|emitted| { + let wire_name = emitted.wire_name; + quote! { object.remove(#wire_name); } + }) + .collect::>(); + let mut helper_fields: Vec = emitted_properties + .iter() + .map(|emitted| { + let field_name = emitted.wire_name; + let property = emitted.property; + let field_ident = &emitted.ident; + let field_type = &emitted.field_type; + let serde_attrs = self.generate_serde_field_attrs( + &schema.name, + field_name, + field_ident, + property, + emitted.is_required, + analysis, + ); + quote! { + #serde_attrs + #field_ident: #field_type, + } + }) + .collect(); + let mut base_initializers: Vec = emitted_properties + .iter() + .map(|emitted| { + let field_ident = &emitted.ident; + quote! { #field_ident: self.#field_ident.clone(), } + }) + .collect(); + let mut result_fields: Vec = emitted_properties + .iter() + .map(|emitted| { + let field_ident = &emitted.ident; + quote! { #field_ident: base.#field_ident, } + }) + .collect(); + + match additional_properties { + crate::analysis::ObjectAdditionalProperties::Forbidden => {} + crate::analysis::ObjectAdditionalProperties::Untyped => { + helper_fields.push(quote! { + #[serde(flatten)] + additional_properties: + std::collections::BTreeMap, + }); + base_initializers.push(quote! { + additional_properties: self.additional_properties.clone(), + }); + result_fields.push(quote! { + additional_properties: base.additional_properties, + }); + } + crate::analysis::ObjectAdditionalProperties::Typed { value_type } => { + let value_tokens = self.generate_array_item_type(value_type, analysis); + helper_fields.push(quote! { + #[serde(flatten)] + additional_properties: + std::collections::BTreeMap, + }); + base_initializers.push(quote! { + additional_properties: self.additional_properties.clone(), + }); + result_fields.push(quote! { + additional_properties: base.additional_properties, + }); + } + } + + quote! { + #[derive(Deserialize, Serialize)] + struct #helper_name { + #(#helper_fields)* + } + + impl serde::Serialize for #struct_name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let base = #helper_name { + #(#base_initializers)* + }; + let mut value = serde_json::to_value(base) + .map_err(serde::ser::Error::custom)?; + let variant = serde_json::to_value(&self.#variant_field) + .map_err(serde::ser::Error::custom)?; + let object = value.as_object_mut().ok_or_else(|| { + serde::ser::Error::custom("shared union base did not serialize as an object") + })?; + let variant_object = variant.as_object().ok_or_else(|| { + serde::ser::Error::custom("shared union variant did not serialize as an object") + })?; + for (key, variant_value) in variant_object { + if let Some(base_value) = object.get(key) + && base_value != variant_value + { + return Err(serde::ser::Error::custom(format!( + "shared union field `{key}` serialized conflicting values", + ))); + } + object.insert(key.clone(), variant_value.clone()); + } + serde::Serialize::serialize(&value, serializer) + } + } + + impl<'de> serde::Deserialize<'de> for #struct_name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let base = serde_json::from_value::<#helper_name>(value.clone()) + .map_err(serde::de::Error::custom)?; + let variant = match serde_json::from_value::<#variant_type>(value.clone()) { + Ok(variant) => variant, + Err(complete_error) => { + let mut variant_input = value; + if let Some(object) = variant_input.as_object_mut() { + #(#variant_projection_removals)* + } + serde_json::from_value::<#variant_type>(variant_input).map_err( + |projected_error| serde::de::Error::custom(format!( + "complete shared-union input failed: {complete_error}; projected input failed: {projected_error}", + )), + )? + } + }; + Ok(Self { + #(#result_fields)* + #variant_field: variant, + }) + } + } + } + } else { + TokenStream::new() + }; + let builder = if type_context.index.request_body_roots.contains(&schema.name) && variant.is_none() && emitted_properties @@ -2117,6 +2576,7 @@ impl CodeGenerator { #(#fields)* } + #shared_variant_serde #builder }) } @@ -2132,7 +2592,6 @@ impl CodeGenerator { required: &std::collections::HashSet, additional_properties: &crate::analysis::ObjectAdditionalProperties, analysis: &crate::analysis::SchemaAnalysis, - discriminator_info: Option<&DiscriminatedVariantInfo>, ) -> Vec> { let mut sorted_properties: Vec<_> = properties.iter().collect(); sorted_properties.sort_by_key(|(name, _)| name.as_str()); @@ -2147,12 +2606,6 @@ impl CodeGenerator { let mut emitted = Vec::new(); for (field_name, property) in sorted_properties { - if discriminator_info.is_some_and(|info| { - !info.is_parent_untagged && field_name.as_str() == info.discriminator_field.as_str() - }) { - continue; - } - let raw = self.to_rust_field_name(field_name); let mut chosen = raw.clone(); let mut suffix = 2; @@ -2299,12 +2752,60 @@ impl CodeGenerator { } let setter_ident = Self::to_field_ident(&setter_name); let wire_name = property.wire_name; - quote! { - #[doc = concat!("Set the optional `", #wire_name, "` request field.")] - #[must_use] - pub fn #setter_ident(mut self, #field_ident: #field_type) -> Self { - self.value.#field_ident = Some(#field_ident); - self + if self.property_is_tri_state( + &schema.name, + property.wire_name, + property.property, + property.is_required, + ) { + let mut null_name = format!("{plain_field_name}_null"); + let null_base = null_name.clone(); + let mut suffix = 2; + while !used_builder_methods.insert(null_name.clone()) { + null_name = format!("{null_base}_{suffix}"); + suffix += 1; + } + let null_ident = Self::to_field_ident(&null_name); + + let mut absent_name = format!("{plain_field_name}_absent"); + let absent_base = absent_name.clone(); + let mut suffix = 2; + while !used_builder_methods.insert(absent_name.clone()) { + absent_name = format!("{absent_base}_{suffix}"); + suffix += 1; + } + let absent_ident = Self::to_field_ident(&absent_name); + + quote! { + #[doc = concat!("Set the optional nullable `", #wire_name, "` request field to a value.")] + #[must_use] + pub fn #setter_ident(mut self, #field_ident: #field_type) -> Self { + self.value.#field_ident = Some(Some(#field_ident)); + self + } + + #[doc = concat!("Set the optional nullable `", #wire_name, "` request field to JSON null.")] + #[must_use] + pub fn #null_ident(mut self) -> Self { + self.value.#field_ident = Some(None); + self + } + + #[doc = concat!("Omit the optional nullable `", #wire_name, "` request field.")] + #[must_use] + pub fn #absent_ident(mut self) -> Self { + self.value.#field_ident = None; + self + } + } + } else { + quote! { + #[doc = concat!("Set the optional `", #wire_name, "` request field.")] + #[must_use] + pub fn #setter_ident(mut self, #field_ident: #field_type) -> Self { + self.value.#field_ident = Some(#field_ident); + self + } } } }) @@ -2393,6 +2894,7 @@ impl CodeGenerator { schema: &crate::analysis::AnalyzedSchema, discriminator_field: &str, variants: &[crate::analysis::UnionVariant], + exclusive: bool, analysis: &crate::analysis::SchemaAnalysis, ) -> Result { let enum_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); @@ -2419,33 +2921,212 @@ impl CodeGenerator { nullable: false, }) .collect(); - return self.generate_union_enum(schema, &schema_refs, analysis); + return self.generate_union_enum(schema, &schema_refs, exclusive, analysis); } let enclosing = self.to_rust_type_name(&schema.name); - let enum_variants = variants.iter().map(|variant| { - let variant_name = format_ident!("{}", variant.rust_name); - let variant_value = &variant.discriminator_value; - - let variant_type = format_ident!("{}", self.to_rust_type_name(&variant.type_name)); - // Box variant payloads that point at the enclosing enum or any - // schema in the analysis's recursive set, otherwise the enum has - // infinite size (E0072). - let payload = if self.to_rust_type_name(&variant.type_name) == enclosing - || analysis - .dependencies - .recursive_schemas - .contains(&variant.type_name) - { - quote! { Box<#variant_type> } + let variant_shapes: Vec<_> = variants + .iter() + .map(|variant| { + let variant_name = format_ident!("{}", variant.rust_name); + let variant_type = format_ident!("{}", self.to_rust_type_name(&variant.type_name)); + // Box variant payloads that point at the enclosing enum or any + // schema in the analysis's recursive set, otherwise the enum has + // infinite size (E0072). + let payload = if self.to_rust_type_name(&variant.type_name) == enclosing + || analysis + .dependencies + .recursive_schemas + .contains(&variant.type_name) + { + quote! { Box<#variant_type> } + } else { + quote! { #variant_type } + }; + (variant, variant_name, payload) + }) + .collect(); + let enum_variants = variant_shapes.iter().map(|(_, variant_name, payload)| { + quote! { #variant_name(#payload), } + }); + let serialize_arms = variant_shapes.iter().map(|(variant, variant_name, _)| { + let canonical_value = &variant.discriminator_value; + let variant_values = &variant.discriminator_values; + let serialize_discriminator = if variant.discriminator_field_declared { + quote! { + match object.get(#discriminator_field) { + Some(serde_json::Value::String(tag)) + if matches!(tag.as_str(), #(#variant_values)|*) => {} + Some(serde_json::Value::String(tag)) => { + return Err(serde::ser::Error::custom(format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + #discriminator_field, + stringify!(#variant_name), + ))); + } + Some(_) => { + return Err(serde::ser::Error::custom(concat!( + "discriminator `", + #discriminator_field, + "` did not serialize as a string", + ))); + } + None => { + object.insert( + #discriminator_field.to_string(), + serde_json::Value::String(#canonical_value.to_string()), + ); + } + } + } } else { - quote! { #variant_type } + TokenStream::new() }; quote! { - #[serde(rename = #variant_value)] - #variant_name(#payload), + Self::#variant_name(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value.as_object_mut().ok_or_else(|| { + serde::ser::Error::custom(concat!( + "discriminated union variant `", + stringify!(#variant_name), + "` did not serialize as an object", + )) + })?; + #serialize_discriminator + value.serialize(serializer) + } } }); + let mut deserialize_arms = Vec::new(); + for (primary_index, (variant, variant_name, payload)) in variant_shapes.iter().enumerate() { + for tag in &variant.preferred_discriminator_values { + let fallback_attempts = variant_shapes.iter().enumerate().filter_map( + |(fallback_index, (_, fallback_name, fallback_payload))| { + if fallback_index == primary_index { + return None; + } + if exclusive { + Some(quote! { + if let Ok(payload) = + serde_json::from_value::<#fallback_payload>(value.clone()) + { + if let Some((_, first_name)) = &structural_match { + return Err(serde::de::Error::custom(format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + #discriminator_field, + #tag, + first_name, + stringify!(#fallback_name), + ))); + } + structural_match = Some(( + Self::#fallback_name(payload), + stringify!(#fallback_name), + )); + } + }) + } else { + Some(quote! { + if let Ok(payload) = + serde_json::from_value::<#fallback_payload>(value.clone()) + { + return Ok(Self::#fallback_name(payload)); + } + }) + } + }, + ); + let structural_fallback = if exclusive { + quote! { + let mut structural_match: Option<(Self, &'static str)> = None; + #(#fallback_attempts)* + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + } else { + quote! { + #(#fallback_attempts)* + Err(serde::de::Error::custom(primary_error)) + } + }; + deserialize_arms.push(quote! { + #tag => { + let primary_error = match serde_json::from_value::<#payload>(value.clone()) { + Ok(payload) => return Ok(Self::#variant_name(payload)), + Err(error) => error, + }; + #structural_fallback + } + }); + } + } + let missing_discriminator_attempts = variant_shapes.iter().filter_map( + |(variant, variant_name, payload)| { + if variant.discriminator_field_required { + return None; + } + if exclusive { + Some(quote! { + if let Ok(payload) = serde_json::from_value::<#payload>(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err(serde::de::Error::custom(format!( + "missing discriminator `{}` structurally matched both `{}` and `{}`", + #discriminator_field, + first_name, + stringify!(#variant_name), + ))); + } + structural_match = Some(( + Self::#variant_name(payload), + stringify!(#variant_name), + )); + } + }) + } else { + Some(quote! { + if let Ok(payload) = serde_json::from_value::<#payload>(value.clone()) { + return Ok(Self::#variant_name(payload)); + } + }) + } + }, + ); + let has_missing_discriminator_candidates = variants + .iter() + .any(|variant| !variant.discriminator_field_required); + let missing_discriminator_fallback = if has_missing_discriminator_candidates && exclusive { + quote! { + let mut structural_match: Option<(Self, &'static str)> = None; + #(#missing_discriminator_attempts)* + structural_match + .map(|(payload, _)| payload) + .ok_or_else(|| serde::de::Error::custom(concat!( + "missing string discriminator `", + #discriminator_field, + "` and no tagless branch matched", + ))) + } + } else if has_missing_discriminator_candidates { + quote! { + #(#missing_discriminator_attempts)* + Err(serde::de::Error::custom(concat!( + "missing string discriminator `", + #discriminator_field, + "` and no tagless branch matched", + ))) + } + } else { + quote! { + Err(serde::de::Error::custom(concat!( + "missing string discriminator `", + #discriminator_field, + "`", + ))) + } + }; let doc_comment = if let Some(desc) = &schema.description { quote! { #[doc = #desc] } @@ -2453,17 +3134,20 @@ impl CodeGenerator { TokenStream::new() }; - // Generate derives with optional Specta support + // Keep the discriminator on each standalone component model, then do + // explicit discriminator-directed dispatch here. A derive-based + // internally tagged enum requires stripping the tag from its payload; + // that made the same component serialize invalid JSON when used + // directly or in an array. Explicit dispatch retains O(1)-by-tag + // behavior without giving the payload two incompatible wire shapes. let derives = if self.config.enable_specta { quote! { - #[derive(Debug, Clone, Deserialize, Serialize)] + #[derive(Debug, Clone)] #[cfg_attr(feature = "specta", derive(specta::Type))] - #[serde(tag = #discriminator_field)] } } else { quote! { - #[derive(Debug, Clone, Deserialize, Serialize)] - #[serde(tag = #discriminator_field)] + #[derive(Debug, Clone)] } }; @@ -2473,6 +3157,49 @@ impl CodeGenerator { pub enum #enum_name { #(#enum_variants)* } + + impl serde::Serialize for #enum_name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + #(#serialize_arms)* + } + } + } + + impl<'de> serde::Deserialize<'de> for #enum_name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get(#discriminator_field) { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err(serde::de::Error::custom(concat!( + "non-string discriminator `", + #discriminator_field, + "`", + ))); + } + None => None, + }; + match discriminator { + Some(discriminator) => match discriminator { + #(#deserialize_arms)* + other => Err(serde::de::Error::custom(format!( + "unknown discriminator value `{other}` for `{}`", + #discriminator_field, + ))), + }, + None => { #missing_discriminator_fallback } + } + } + } }) } @@ -2487,10 +3214,8 @@ impl CodeGenerator { // Check if this schema is used as a variant in another discriminated union for other_schema in analysis.schemas.values() { - if let crate::analysis::SchemaType::DiscriminatedUnion { - variants, - discriminator_field: _, - } = &other_schema.schema_type + if let crate::analysis::SchemaType::DiscriminatedUnion { variants, .. } = + &other_schema.schema_type { for variant in variants { if variant.type_name == schema.name { @@ -2536,58 +3261,88 @@ impl CodeGenerator { &self, schema: &crate::analysis::AnalyzedSchema, variants: &[crate::analysis::SchemaRef], + exclusive: bool, analysis: &crate::analysis::SchemaAnalysis, ) -> Result { let enum_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); // Generate meaningful variant names based on type names let mut used_variant_names = std::collections::HashSet::new(); - let enum_variants = variants.iter().enumerate().map(|(i, variant)| { - // Generate a meaningful variant name from the type name - let base_variant_name = self.type_name_to_variant_name(&variant.target); - let variant_name = self.ensure_unique_variant_name_generator( - base_variant_name, - &mut used_variant_names, - i, - ); - let variant_name_ident = format_ident!("{}", variant_name); - - // For primitive types and Vec types, use them directly without conversion - let variant_type_tokens = if matches!( - variant.target.as_str(), - "bool" - | "i8" - | "i16" - | "i32" - | "i64" - | "i128" - | "u8" - | "u16" - | "u32" - | "u64" - | "u128" - | "f32" - | "f64" - | "String" - ) { - let type_ident = format_ident!("{}", variant.target); - quote! { #type_ident } - } else if variant.target == "serde_json::Value" { - // The target is a fully-qualified path; emit it as a path so - // it doesn't get mangled into a phantom `SerdeJsonValue` ident. - quote! { serde_json::Value } - } else if variant.target.starts_with("Vec<") && variant.target.ends_with(">") { - // Handle Vec types by parsing the inner type - let inner = &variant.target[4..variant.target.len() - 1]; - - // Handle nested Vec types (e.g., Vec>) - if inner.starts_with("Vec<") && inner.ends_with(">") { - let inner_inner = &inner[4..inner.len() - 1]; - if inner_inner == "serde_json::Value" { - quote! { Vec> } + let enum_variants = variants + .iter() + .enumerate() + .map(|(i, variant)| { + // Generate a meaningful variant name from the type name + let base_variant_name = self.type_name_to_variant_name(&variant.target); + let variant_name = self.ensure_unique_variant_name_generator( + base_variant_name, + &mut used_variant_names, + i, + ); + let variant_name_ident = format_ident!("{}", variant_name); + + // For primitive types and Vec types, use them directly without conversion + let variant_type_tokens = if matches!( + variant.target.as_str(), + "bool" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "f32" + | "f64" + | "String" + ) { + let type_ident = format_ident!("{}", variant.target); + quote! { #type_ident } + } else if variant.target == "serde_json::Value" { + // The target is a fully-qualified path; emit it as a path so + // it doesn't get mangled into a phantom `SerdeJsonValue` ident. + quote! { serde_json::Value } + } else if variant.target.starts_with("Vec<") && variant.target.ends_with(">") { + // Handle Vec types by parsing the inner type + let inner = &variant.target[4..variant.target.len() - 1]; + + // Handle nested Vec types (e.g., Vec>) + if inner.starts_with("Vec<") && inner.ends_with(">") { + let inner_inner = &inner[4..inner.len() - 1]; + if inner_inner == "serde_json::Value" { + quote! { Vec> } + } else { + let inner_inner_type = if matches!( + inner_inner, + "bool" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "f32" + | "f64" + | "String" + ) { + format_ident!("{}", inner_inner) + } else { + format_ident!("{}", self.to_rust_type_name(inner_inner)) + }; + quote! { Vec> } + } + } else if inner == "serde_json::Value" { + quote! { Vec } } else { - let inner_inner_type = if matches!( - inner_inner, + let inner_type = if matches!( + inner, "bool" | "i8" | "i16" @@ -2603,75 +3358,57 @@ impl CodeGenerator { | "f64" | "String" ) { - format_ident!("{}", inner_inner) + format_ident!("{}", inner) } else { - format_ident!("{}", self.to_rust_type_name(inner_inner)) + format_ident!("{}", self.to_rust_type_name(inner)) }; - quote! { Vec> } + quote! { Vec<#inner_type> } } - } else if inner == "serde_json::Value" { - quote! { Vec } + } else if variant.target.contains("::") || variant.target.contains('<') { + // Qualified Rust path or generic (chrono::DateTime, + // bytes::Bytes, std::net::Ipv4Addr) emitted by TypeMapper. Pass + // it straight to syn — the to_rust_type_name PascalCase + // pipeline below would mangle it into a non-existent ident. + parse_rust_type(&variant.target).unwrap_or_else(|_| { + let fallback = format_ident!("{}", self.to_rust_type_name(&variant.target)); + quote! { #fallback } + }) } else { - let inner_type = if matches!( - inner, - "bool" - | "i8" - | "i16" - | "i32" - | "i64" - | "i128" - | "u8" - | "u16" - | "u32" - | "u64" - | "u128" - | "f32" - | "f64" - | "String" - ) { - format_ident!("{}", inner) - } else { - format_ident!("{}", self.to_rust_type_name(inner)) - }; - quote! { Vec<#inner_type> } - } - } else if variant.target.contains("::") || variant.target.contains('<') { - // Qualified Rust path or generic (chrono::DateTime, - // bytes::Bytes, std::net::Ipv4Addr) emitted by TypeMapper. Pass - // it straight to syn — the to_rust_type_name PascalCase - // pipeline below would mangle it into a non-existent ident. - parse_rust_type(&variant.target).unwrap_or_else(|_| { - let fallback = format_ident!("{}", self.to_rust_type_name(&variant.target)); - quote! { #fallback } - }) - } else { - let type_ident = format_ident!("{}", self.to_rust_type_name(&variant.target)); - quote! { #type_ident } - }; + let type_ident = format_ident!("{}", self.to_rust_type_name(&variant.target)); + quote! { #type_ident } + }; - // Self-referential variant (variant payload type == enclosing - // enum) yields an infinite-size enum (E0072). Wrap in `Box` to - // break the cycle. Observed in microsoft-graph.yaml. - let target_rust_name = self.to_rust_type_name(&variant.target); - let enclosing_name = self.to_rust_type_name(&schema.name); - let is_self_ref = target_rust_name == enclosing_name; - // Indirect cycles (stripe BankAccount → BankAccountCustomer → - // Customer → BankAccountCustomer): variants pointing into the - // analysis's recursive_schemas set must also be heap-allocated. - let is_recursive_target = analysis - .dependencies - .recursive_schemas - .contains(&variant.target); - let variant_type_tokens = if is_self_ref || is_recursive_target { - quote! { Box<#variant_type_tokens> } - } else { - variant_type_tokens - }; + // Self-referential variant (variant payload type == enclosing + // enum) yields an infinite-size enum (E0072). Wrap in `Box` to + // break the cycle. Observed in microsoft-graph.yaml. + let target_rust_name = self.to_rust_type_name(&variant.target); + let enclosing_name = self.to_rust_type_name(&schema.name); + let is_self_ref = target_rust_name == enclosing_name; + // Indirect cycles (stripe BankAccount → BankAccountCustomer → + // Customer → BankAccountCustomer): variants pointing into the + // analysis's recursive_schemas set must also be heap-allocated. + let is_recursive_target = analysis + .dependencies + .recursive_schemas + .contains(&variant.target); + let variant_type_tokens = if is_self_ref || is_recursive_target { + quote! { Box<#variant_type_tokens> } + } else { + variant_type_tokens + }; + let variant_type_tokens = if variant.nullable { + quote! { Option<#variant_type_tokens> } + } else { + variant_type_tokens + }; - quote! { - #variant_name_ident(#variant_type_tokens), - } - }); + (variant_name_ident, variant_type_tokens) + }) + .collect::>(); + let variant_declarations = enum_variants + .iter() + .map(|(variant_name, variant_type)| quote! { #variant_name(#variant_type), }) + .collect::>(); let doc_comment = if let Some(desc) = &schema.description { quote! { #[doc = #desc] } @@ -2679,7 +3416,229 @@ impl CodeGenerator { TokenStream::new() }; - // Generate derives with optional Specta support + let object_only = variants.iter().all(|variant| { + self.union_target_serializes_as_object( + &variant.target, + analysis, + &mut std::collections::HashSet::new(), + ) + }); + + if exclusive || object_only { + let derives = if self.config.enable_specta { + quote! { + #[derive(Debug, Clone)] + #[cfg_attr(feature = "specta", derive(specta::Type))] + } + } else { + quote! { #[derive(Debug, Clone)] } + }; + let serialize_arms = enum_variants + .iter() + .map(|(variant_name, _)| { + quote! { + Self::#variant_name(value) => serde::Serialize::serialize(value, serializer), + } + }) + .collect::>(); + let deserialize_attempts = enum_variants + .iter() + .zip(variants) + .map(|((variant_name, variant_type), variant)| { + if exclusive { + let constraints = self.union_branch_literal_constraints( + &variant.target, + analysis, + &mut std::collections::HashSet::new(), + ); + let constraint_checks = + constraints.iter().map(|(field, (required, allowed))| { + let allowed = allowed + .iter() + .map(serde_json::Value::to_string) + .collect::>(); + if allowed.is_empty() && *required { + quote! { false } + } else if allowed.is_empty() { + quote! { object.get(#field).is_none() } + } else if *required { + quote! { + object.get(#field).is_some_and(|value| { + value.is_null() + || matches!(value.to_string().as_str(), #(#allowed)|*) + }) + } + } else { + quote! { + object.get(#field).is_none_or(|value| { + value.is_null() + || matches!(value.to_string().as_str(), #(#allowed)|*) + }) + } + } + }); + let constraints_match = if constraints.is_empty() { + quote! { true } + } else { + quote! { + input.as_object().is_some_and(|object| { + true #(&& #constraint_checks)* + }) + } + }; + quote! { + if #constraints_match { + if let Ok(candidate) = + serde_json::from_value::<#variant_type>(input.clone()) + { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| encoded == input) + .unwrap_or(false); + if preserves_complete_input { + if matched.is_some() { + return Err(serde::de::Error::custom(concat!( + "ambiguous oneOf value for ", + stringify!(#enum_name), + ": more than one branch preserved the complete input", + ))); + } + matched = Some(Self::#variant_name(candidate)); + } + } + } + } + } else { + quote! { + if let Ok(candidate) = + serde_json::from_value::<#variant_type>(input.clone()) + { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| { + preserves_complete_json_input(&encoded, &input) + }) + .unwrap_or(false); + if preserves_complete_input { + return Ok(Self::#variant_name(candidate)); + } + } + } + } + }) + .collect::>(); + let no_match = if exclusive { + quote! { + matched.ok_or_else(|| serde::de::Error::custom(concat!( + "no oneOf branch for ", + stringify!(#enum_name), + " preserved the complete input", + ))) + } + } else { + quote! { + Err(serde::de::Error::custom(concat!( + "no anyOf branch for ", + stringify!(#enum_name), + " preserved the complete input", + ))) + } + }; + let matched_declaration = exclusive.then(|| quote! { let mut matched = None; }); + let preservation_helper = (!exclusive).then(|| { + quote! { + fn exact_json_integer(number: &serde_json::Number) -> Option { + number + .as_i64() + .map(i128::from) + .or_else(|| number.as_u64().map(i128::from)) + } + + fn json_numbers_have_same_value( + encoded: &serde_json::Number, + input: &serde_json::Number, + ) -> bool { + match (exact_json_integer(encoded), exact_json_integer(input)) { + (Some(encoded), Some(input)) => encoded == input, + (Some(encoded), None) => input.as_f64().is_some_and(|input| { + input.is_finite() + && input.fract() == 0.0 + && input as i128 == encoded + }), + (None, Some(input)) => encoded.as_f64().is_some_and(|encoded| { + encoded.is_finite() + && encoded.fract() == 0.0 + && encoded as i128 == input + }), + (None, None) => encoded.as_f64() == input.as_f64(), + } + } + + fn preserves_complete_json_input( + encoded: &serde_json::Value, + input: &serde_json::Value, + ) -> bool { + match (encoded, input) { + ( + serde_json::Value::Object(encoded), + serde_json::Value::Object(input), + ) => input.iter().all(|(key, value)| { + encoded.get(key).is_some_and(|encoded_value| { + preserves_complete_json_input(encoded_value, value) + }) + }), + ( + serde_json::Value::Array(encoded), + serde_json::Value::Array(input), + ) => { + encoded.len() == input.len() + && encoded.iter().zip(input).all(|(encoded, input)| { + preserves_complete_json_input(encoded, input) + }) + } + ( + serde_json::Value::Number(encoded), + serde_json::Value::Number(input), + ) => json_numbers_have_same_value(encoded, input), + _ => encoded == input, + } + } + } + }); + + return Ok(quote! { + #doc_comment + #derives + pub enum #enum_name { + #(#variant_declarations)* + } + + impl Serialize for #enum_name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + #(#serialize_arms)* + } + } + } + + impl<'de> Deserialize<'de> for #enum_name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #preservation_helper + let input = ::deserialize(deserializer)?; + #matched_declaration + #(#deserialize_attempts)* + #no_match + } + } + }); + } + + // Generate derives with optional Specta support for non-exclusive + // unions, where multiple anyOf branches may legitimately match. let derives = if self.config.enable_specta { quote! { #[derive(Debug, Clone, Deserialize, Serialize)] @@ -2697,11 +3656,170 @@ impl CodeGenerator { #doc_comment #derives pub enum #enum_name { - #(#enum_variants)* + #(#variant_declarations)* } }) } + fn union_branch_literal_constraints( + &self, + target: &str, + analysis: &crate::analysis::SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> BTreeMap)> { + if !visited.insert(target.to_string()) { + return BTreeMap::new(); + } + let constraints = analysis + .schemas + .get(target) + .map(|schema| { + self.schema_literal_property_constraints(&schema.original, analysis, visited) + }) + .unwrap_or_default(); + visited.remove(target); + constraints + } + + fn schema_literal_property_constraints( + &self, + schema: &serde_json::Value, + analysis: &crate::analysis::SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> BTreeMap)> { + let Some(object) = schema.as_object() else { + return BTreeMap::new(); + }; + if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) + && let Some(target) = reference.rsplit('/').next() + { + return self.union_branch_literal_constraints(target, analysis, visited); + } + + let required = object + .get("required") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect::>(); + let mut constraints = BTreeMap::new(); + if let Some(properties) = object + .get("properties") + .and_then(serde_json::Value::as_object) + { + for (field, property) in properties { + if let Some(values) = self.schema_literal_domain( + property, + analysis, + &mut std::collections::HashSet::new(), + ) && !values.is_empty() + { + constraints.insert(field.clone(), (required.contains(field.as_str()), values)); + } + } + } + if let Some(branches) = object.get("allOf").and_then(serde_json::Value::as_array) { + for branch in branches { + for (field, (branch_required, values)) in + self.schema_literal_property_constraints(branch, analysis, visited) + { + constraints + .entry(field) + .and_modify(|(is_required, existing)| { + *is_required |= branch_required; + existing.retain(|value| values.contains(value)); + }) + .or_insert((branch_required, values)); + } + } + } + constraints + } + + fn schema_literal_domain( + &self, + schema: &serde_json::Value, + analysis: &crate::analysis::SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> Option> { + let object = schema.as_object()?; + if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) + && let Some(target) = reference.rsplit('/').next() + { + if !visited.insert(target.to_string()) { + return None; + } + let result = analysis + .schemas + .get(target) + .and_then(|schema| self.schema_literal_domain(&schema.original, analysis, visited)); + visited.remove(target); + return result; + } + let own = object + .get("const") + .map(|value| vec![value.clone()]) + .or_else(|| { + object + .get("enum") + .and_then(serde_json::Value::as_array) + .cloned() + }); + let composed = object + .get("allOf") + .and_then(serde_json::Value::as_array) + .and_then(|branches| { + branches.iter().fold(None, |domain, branch| { + let branch_domain = self.schema_literal_domain(branch, analysis, visited); + match (domain, branch_domain) { + (None, other) | (other, None) => other, + (Some(mut left), Some(right)) => { + left.retain(|value| right.contains(value)); + Some(left) + } + } + }) + }); + match (own, composed) { + (None, other) | (other, None) => other, + (Some(mut left), Some(right)) => { + left.retain(|value| right.contains(value)); + Some(left) + } + } + } + + fn union_target_serializes_as_object( + &self, + target: &str, + analysis: &crate::analysis::SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> bool { + if !visited.insert(target.to_string()) { + return false; + } + let result = analysis + .schemas + .get(target) + .is_some_and(|schema| match &schema.schema_type { + crate::analysis::SchemaType::Object { .. } + | crate::analysis::SchemaType::Composition { .. } + | crate::analysis::SchemaType::DiscriminatedUnion { .. } => true, + crate::analysis::SchemaType::Reference { target } => { + self.union_target_serializes_as_object(target, analysis, visited) + } + crate::analysis::SchemaType::Union { variants, .. } => { + variants.iter().all(|variant| { + self.union_target_serializes_as_object(&variant.target, analysis, visited) + }) + } + _ => false, + }); + visited.remove(target); + result + } + /// Walk a chain of type-alias `Reference`s starting from `target` and /// return true if the chain reaches the schema named by /// `enclosing_rust_name` (Rust name). Bounded depth to prevent infinite @@ -2743,13 +3861,47 @@ impl CodeGenerator { ) -> TokenStream { let base_type = self.generate_property_base_type(schema_name, field_name, prop, analysis); - if self.property_is_option_wrapped(schema_name, field_name, prop, is_required, analysis) { + if !is_required && self.property_is_nullable(schema_name, field_name, prop) { + quote! { Option> } + } else if self.property_is_option_wrapped( + schema_name, + field_name, + prop, + is_required, + analysis, + ) { quote! { Option<#base_type> } } else { base_type } } + pub(crate) fn property_is_nullable( + &self, + schema_name: &str, + field_name: &str, + prop: &crate::analysis::PropertyInfo, + ) -> bool { + let override_key = format!("{schema_name}.{field_name}"); + prop.nullable + || self + .config + .nullable_field_overrides + .get(&override_key) + .copied() + .unwrap_or(false) + } + + pub(crate) fn property_is_tri_state( + &self, + schema_name: &str, + field_name: &str, + prop: &crate::analysis::PropertyInfo, + is_required: bool, + ) -> bool { + !is_required && self.property_is_nullable(schema_name, field_name, prop) + } + fn property_is_option_wrapped( &self, schema_name: &str, @@ -2758,17 +3910,8 @@ impl CodeGenerator { is_required: bool, analysis: &crate::analysis::SchemaAnalysis, ) -> bool { - let override_key = format!("{schema_name}.{field_name}"); - let is_nullable_override = self - .config - .nullable_field_overrides - .get(&override_key) - .copied() - .unwrap_or(false); - !is_required - || prop.nullable - || is_nullable_override + || self.property_is_nullable(schema_name, field_name, prop) || (prop.default.is_some() && self.type_lacks_default(&prop.schema_type, analysis)) } @@ -2829,6 +3972,10 @@ impl CodeGenerator { let inner_type = self.generate_array_item_type(item_type, analysis); quote! { Vec<#inner_type> } } + SchemaType::Nullable { inner_type } => { + let inner_type = self.generate_array_item_type(inner_type, analysis); + quote! { Option<#inner_type> } + } SchemaType::Tuple { element_types } => { self.generate_tuple_type(element_types, analysis) } @@ -2882,8 +4029,13 @@ impl CodeGenerator { attrs.push(quote! { rename = #field_name }); } - // Add skip_serializing_if for optional fields to avoid sending null values - if !is_required || prop.nullable { + let is_tri_state = self.property_is_tri_state(schema_name, field_name, prop, is_required); + + // Optional fields may be omitted when their outer Option is None. + // Optional nullable fields use Option> so Some(None) remains + // an explicit JSON null. Required nullable fields stay Option and + // must serialize None rather than skipping the required wire name. + if !is_required { attrs.push(quote! { skip_serializing_if = "Option::is_none" }); } @@ -2904,11 +4056,7 @@ impl CodeGenerator { // the `::option` submodule of the codec — serde dispatches // on field type, and the base codec works on Vec / // chrono::Duration / etc., not their Option wrappers. - if let crate::analysis::SchemaType::Primitive { - serde_with: Some(codec), - .. - } = &prop.schema_type - { + if let Some(codec) = self.schema_type_serde_codec(&prop.schema_type, analysis) { let is_option_wrapped = self.property_is_option_wrapped( schema_name, field_name, @@ -2916,10 +4064,12 @@ impl CodeGenerator { is_required, analysis, ); - let codec_path = if is_option_wrapped { + let codec_path = if is_tri_state { + Self::double_option_codec_path(&codec) + } else if is_option_wrapped { format!("{codec}::option") } else { - codec.clone() + codec }; attrs.push(quote! { with = #codec_path }); // A `with` codec disables serde's implicit @@ -2929,6 +4079,9 @@ impl CodeGenerator { if is_option_wrapped { attrs.push(quote! { default }); } + } else if is_tri_state { + attrs.push(quote! { default }); + attrs.push(quote! { deserialize_with = "tri_state_serde::deserialize" }); } if attrs.is_empty() { @@ -2938,6 +4091,45 @@ impl CodeGenerator { } } + fn double_option_codec_path(codec: &str) -> String { + match codec { + "time::serde::rfc3339" => "time_rfc3339_double_option".to_string(), + "time_date_format" => "time_date_double_option".to_string(), + "time_time_format" => "time_time_double_option".to_string(), + _ => format!("{codec}::double_option"), + } + } + + /// Resolve a field codec through named scalar aliases. A property may + /// reference a component whose generated Rust type is `bytes::Bytes`; the + /// codec belongs on the property field, because a Rust type alias cannot + /// carry serde attributes of its own. + fn schema_type_serde_codec( + &self, + schema_type: &crate::analysis::SchemaType, + analysis: &crate::analysis::SchemaAnalysis, + ) -> Option { + let mut current = schema_type; + let mut visited = std::collections::HashSet::new(); + loop { + match current { + crate::analysis::SchemaType::Primitive { + serde_with: Some(codec), + .. + } => return Some(codec.clone()), + crate::analysis::SchemaType::Reference { target } + if visited.insert(target.clone()) => + { + current = &analysis.schemas.get(target)?.schema_type; + } + crate::analysis::SchemaType::Nullable { inner_type } => { + current = inner_type; + } + _ => return None, + } + } + } + /// Check if a schema type resolves to a type that doesn't implement `Default`. /// Discriminated unions and union enums don't derive Default, so fields with /// these types can't use `#[serde(default)]`. @@ -3309,12 +4501,17 @@ impl CodeGenerator { result = format!("{leading_marker}{result}"); } - // `self`, `super`, `crate`, `Self` are NOT permitted as raw identifiers - // (they trigger an `r#self cannot be a raw identifier` panic in - // proc_macro2). Suffix them instead. + // `self`, `super`, `crate`, and `Self` are not permitted as raw + // identifiers. Suffix them before constructing a proc-macro ident. if matches!(result.as_str(), "self" | "super" | "crate" | "Self") { return format!("{result}_field"); } + // Keep boolean literal property names as stable ordinary identifiers + // and let the field allocator disambiguate an actual `true_field` or + // `false_field` property. Serde preserves the original wire name. + if matches!(result.as_str(), "true" | "false") { + return format!("{result}_field"); + } // Handle reserved keywords using raw identifiers (r#keyword) if Self::is_rust_keyword(&result) { format!("r#{result}") @@ -3479,6 +4676,61 @@ impl CodeGenerator { }) } + fn union_declared_properties( + &self, + target: &str, + analysis: &crate::analysis::SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> std::collections::HashSet { + if !visited.insert(target.to_string()) { + return std::collections::HashSet::new(); + } + let mut properties = std::collections::HashSet::new(); + if let Some(schema) = analysis.schemas.get(target) { + match &schema.schema_type { + crate::analysis::SchemaType::Object { + properties: own, + variant, + .. + } => { + properties.extend(own.keys().cloned()); + if let Some(variant) = variant { + properties.extend(self.union_declared_properties( + &variant.target, + analysis, + visited, + )); + } + } + crate::analysis::SchemaType::DiscriminatedUnion { variants, .. } => { + for variant in variants { + properties.extend(self.union_declared_properties( + &variant.type_name, + analysis, + visited, + )); + } + } + crate::analysis::SchemaType::Union { variants, .. } + | crate::analysis::SchemaType::Composition { schemas: variants } => { + for variant in variants { + properties.extend(self.union_declared_properties( + &variant.target, + analysis, + visited, + )); + } + } + crate::analysis::SchemaType::Reference { target } => { + properties.extend(self.union_declared_properties(target, analysis, visited)); + } + _ => {} + } + } + visited.remove(target); + properties + } + #[allow(dead_code)] fn find_missing_types(&self, analysis: &SchemaAnalysis) -> std::collections::HashSet { let mut missing = std::collections::HashSet::new(); @@ -3488,7 +4740,7 @@ impl CodeGenerator { // Check all references in union variants for schema in analysis.schemas.values() { match &schema.schema_type { - crate::analysis::SchemaType::Union { variants } => { + crate::analysis::SchemaType::Union { variants, .. } => { for variant in variants { if !defined_types.contains(&variant.target) { missing.insert(variant.target.clone()); @@ -3568,6 +4820,10 @@ impl CodeGenerator { let inner_type = self.generate_array_item_type(item_type, analysis); quote! { Vec<#inner_type> } } + SchemaType::Nullable { inner_type } => { + let inner_type = self.generate_array_item_type(inner_type, analysis); + quote! { Option<#inner_type> } + } SchemaType::Tuple { element_types } => { self.generate_tuple_type(element_types, analysis) } diff --git a/src/lib.rs b/src/lib.rs index f81a42a..6312b7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,8 @@ pub mod http_error; pub mod openapi; pub mod patterns; pub mod registry_generator; +#[cfg(feature = "internal-tools")] +pub mod schema_roundtrip; pub mod server; pub mod spec_source; pub mod streaming; diff --git a/src/openapi.rs b/src/openapi.rs index b973928..a420828 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -191,6 +191,11 @@ pub enum Schema { #[serde(flatten)] details: SchemaDetails, }, + /// JSON Schema 2020-12 boolean schema. `true` accepts every value and + /// `false` accepts none, and both are legal anywhere a schema is — a + /// property, a `$defs` entry, a `oneOf` branch, `not`. Specs write + /// `properties: {extra: true}` to say "this key exists, any value". + Bool(bool), } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] @@ -255,14 +260,24 @@ pub struct SchemaDetails { // Number-specific #[serde(skip_serializing_if = "Option::is_none")] - pub minimum: Option, + pub minimum: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub maximum: Option, + pub maximum: Option, // Validation - #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "minLength", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub min_length: Option, - #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "maxLength", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub max_length: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pattern: Option, @@ -275,15 +290,35 @@ pub struct SchemaDetails { pub exclusive_maximum: Option, #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")] pub multiple_of: Option, - #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "minItems", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub min_items: Option, - #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "maxItems", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub max_items: Option, #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")] pub unique_items: Option, - #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "minProperties", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub min_properties: Option, - #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "maxProperties", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub max_properties: Option, // JSON Schema 2020-12 array keywords (J4, J8). @@ -291,9 +326,19 @@ pub struct SchemaDetails { pub prefix_items: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub contains: Option>, - #[serde(rename = "minContains", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "minContains", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub min_contains: Option, - #[serde(rename = "maxContains", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "maxContains", + default, + deserialize_with = "deserialize_count", + skip_serializing_if = "Option::is_none" + )] pub max_contains: Option, // JSON Schema 2020-12 object keywords (J5, J6, J7). @@ -358,6 +403,44 @@ pub struct SchemaDetails { pub extra: BTreeMap, } +/// Deserialize a non-negative integer keyword that a spec may have written as a +/// decimal. +/// +/// JSON Schema requires these to be non-negative integers but says nothing +/// about their JSON spelling, so `maxItems: 2.0` is valid and appears in the +/// 2020-12 test suite. Reading them as `u64` alone rejected the whole document. +fn deserialize_count<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + match &value { + Value::Null => Ok(None), + Value::Number(number) => { + if let Some(count) = number.as_u64() { + return Ok(Some(count)); + } + // A float with no fractional part is the same count written + // differently; anything else is not a count at all. + match number.as_f64() { + Some(float) if float.fract() == 0.0 && float >= 0.0 && float <= u64::MAX as f64 => { + Ok(Some(float as u64)) + } + _ => Err(D::Error::custom(format!( + "expected a non-negative integer, found {number}" + ))), + } + } + other => Err(D::Error::custom(format!( + "expected a non-negative integer, found {other}" + ))), + } +} + fn deserialize_present_value<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -390,10 +473,6 @@ pub enum Items { Single(Box), /// Draft-04 tuple form: one schema per position. Positional(Vec), - /// 2020-12 boolean schema. `items: false` is the canonical way to close a - /// tuple — no elements beyond `prefixItems` — and `items: true` is the - /// no-op "anything goes" schema. - Bool(bool), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -762,9 +841,24 @@ impl Schema { /// (openapi-generator-dsu) — which silently generated non-`Option` fields /// for values the API really does send as `null`. pub fn is_nullable_any(&self) -> bool { - self.details().is_nullable() + self.reference_siblings_are_nullable() + || self.details().is_nullable() || self.type_array_contains_null() - || self.is_nullable_pattern() + || self.has_explicit_null_variant() + } + + /// Reference nodes retain siblings in `extra` because OpenAPI 3.0-era + /// documents commonly attach `nullable: true` directly beside `$ref`. + /// `details()` intentionally returns an empty value for references, so + /// nullability must read that retained annotation explicitly. + fn reference_siblings_are_nullable(&self) -> bool { + let extra = match self { + Schema::Reference { extra, .. } + | Schema::RecursiveRef { extra, .. } + | Schema::DynamicRef { extra, .. } => extra, + _ => return false, + }; + extra.get("nullable").and_then(Value::as_bool) == Some(true) } /// Get schema details @@ -773,9 +867,12 @@ impl Schema { match self { Schema::Typed { details, .. } => details, Schema::TypedMulti { details, .. } => details, - Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => { - &EMPTY_DETAILS - } + // Neither a reference nor a boolean schema carries details of its + // own: one points elsewhere, the other accepts or rejects outright. + Schema::Reference { .. } + | Schema::RecursiveRef { .. } + | Schema::DynamicRef { .. } + | Schema::Bool(_) => &EMPTY_DETAILS, Schema::OneOf { details, .. } => details, Schema::AnyOf { details, .. } => details, Schema::AllOf { details, .. } => details, @@ -797,6 +894,9 @@ impl Schema { Schema::DynamicRef { .. } => { panic!("Cannot get mutable details for dynamic reference schema") } + Schema::Bool(_) => { + panic!("Cannot get mutable details for a boolean schema") + } Schema::OneOf { details, .. } => details, Schema::AnyOf { details, .. } => details, Schema::AllOf { details, .. } => details, @@ -857,6 +957,15 @@ impl Schema { self.non_null_variant().is_some() } + /// Whether an `anyOf` or `oneOf` contains a branch that admits only JSON + /// `null`. Unlike [`Self::is_nullable_pattern`], this does not require the + /// union to collapse to one non-null branch, so it also preserves + /// nullability for unions such as `[string, integer, null]`. + pub fn has_explicit_null_variant(&self) -> bool { + self.union_variants() + .is_some_and(|variants| variants.iter().any(Self::is_explicit_null_only)) + } + /// The one meaningful branch of a two-branch union whose other branch /// exists only to admit `null`. pub fn non_null_variant(&self) -> Option<&Schema> { @@ -869,8 +978,8 @@ impl Schema { return None; }; match ( - Self::is_null_marker_beside(first, second), - Self::is_null_marker_beside(second, first), + first.is_explicit_null_only(), + second.is_explicit_null_only(), ) { (true, false) => Some(second), (false, true) => Some(first), @@ -879,54 +988,29 @@ impl Schema { } } - /// Whether `candidate` exists only to admit `null` alongside `sibling`. - /// - /// 3.1 spells that `type: "null"`, which says so on its own. Tooling that - /// predates it spells it as an empty object carrying `nullable: true`, and - /// that spelling is ambiguous: read literally, - /// `anyOf: [X, {type: object, nullable: true}]` is "X, any object, or - /// null". + /// Whether this branch explicitly admits no JSON value other than `null`. /// - /// The empty-object spelling is read as a null marker only beside a - /// `$ref`, which is the shape - /// OData emits for every navigation property and where the intent is not - /// in doubt. Beside a scalar the literal reading wins — the corpus has 33 - /// `anyOf: [{type: string, nullable: true}, {type: object, nullable: true}]` - /// fields, and typing those as `Option` would fail to deserialize - /// the objects the schema plainly allows. - fn is_null_marker_beside(candidate: &Schema, sibling: &Schema) -> bool { - matches!(candidate.schema_type(), Some(SchemaType::Null)) - || (candidate.is_empty_nullable_object() && sibling.reference().is_some()) - } - - /// An object schema carrying `nullable: true` and constraining nothing. - fn is_empty_nullable_object(&self) -> bool { - let details = self.details(); - if !details.is_nullable() { - return false; - } - if !matches!( - self.schema_type(), - Some(SchemaType::Object) | Some(SchemaType::Null) | None - ) { - return false; - } - self.reference().is_none() - && details.properties.as_ref().is_none_or(BTreeMap::is_empty) - && details.additional_properties.is_none() - && details.enum_values.is_none() - && details.const_value.is_none() - && details.pattern_properties.is_none() - && details.items.is_none() - && details.prefix_items.is_none() - && self.all_of_len() == 0 - } - - /// Number of `allOf` members, for shapes that only matter when empty. - fn all_of_len(&self) -> usize { + /// This is intentionally branch-local. OpenAPI 3.0's `nullable: true` + /// widens the schema it annotates; it does not erase that schema's other + /// accepted values. In particular, `{nullable: true}` and + /// `{type: object, nullable: true}` remain real alternatives even beside + /// a `$ref`. + pub(crate) fn is_explicit_null_only(&self) -> bool { match self { - Schema::AllOf { all_of, .. } => all_of.len(), - _ => 0, + Schema::Typed { schema_type, .. } => *schema_type == SchemaType::Null, + Schema::TypedMulti { schema_types, .. } => { + !schema_types.is_empty() + && schema_types + .iter() + .all(|schema_type| *schema_type == SchemaType::Null) + } + Schema::Untyped { details } => { + details.const_value.as_ref().is_some_and(Value::is_null) + || details.enum_values.as_ref().is_some_and( + |values| matches!(values.as_slice(), [value] if value.is_null()), + ) + } + _ => false, } } @@ -937,7 +1021,9 @@ impl Schema { Schema::TypedMulti { .. } => self.schema_type().cloned(), Schema::Untyped { details } => { // Infer from structure - if details.properties.is_some() { + if self.is_explicit_null_only() { + Some(SchemaType::Null) + } else if details.properties.is_some() { Some(SchemaType::Object) } else if details.items.is_some() || details.prefix_items.is_some() { Some(SchemaType::Array) @@ -956,12 +1042,12 @@ impl SchemaDetails { /// The schema every array element must satisfy, i.e. `items` in its /// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple /// form, which constrains positions rather than every element — read that - /// through [`Self::positional_items`] — and for a boolean schema, which - /// constrains nothing worth typing. + /// through [`Self::positional_items`]. A boolean schema is a schema like + /// any other here: `items: true` accepts every element. pub fn item_schema(&self) -> Option<&Schema> { match self.items.as_ref()? { Items::Single(schema) => Some(schema), - Items::Positional(_) | Items::Bool(_) => None, + Items::Positional(_) => None, } } @@ -973,7 +1059,7 @@ impl SchemaDetails { } match self.items.as_ref()? { Items::Positional(schemas) => Some(schemas), - Items::Single(_) | Items::Bool(_) => None, + Items::Single(_) => None, } } @@ -986,7 +1072,12 @@ impl SchemaDetails { let Some(positions) = self.positional_items() else { return false; }; - if matches!(self.items, Some(Items::Bool(false))) { + // `items: false` — no element beyond the positions may exist. It is a + // boolean schema in the ordinary single-schema slot. + if matches!( + self.items.as_ref(), + Some(Items::Single(schema)) if matches!(**schema, Schema::Bool(false)) + ) { return true; } if self.extra.get("additionalItems") == Some(&Value::Bool(false)) { @@ -1665,6 +1756,141 @@ mod tests { assert!(non_null.is_reference()); } + #[test] + fn explicit_null_only_branches_are_order_independent_nullable_patterns() { + for union_keyword in ["anyOf", "oneOf"] { + for null_schema in [ + json!({"type": "null"}), + json!({"type": ["null"]}), + json!({"const": null}), + json!({"enum": [null]}), + ] { + for variants in [ + vec![ + json!({"$ref": "#/components/schemas/User"}), + null_schema.clone(), + ], + vec![ + null_schema.clone(), + json!({"$ref": "#/components/schemas/User"}), + ], + ] { + let schema: Schema = serde_json::from_value(json!({ + (union_keyword): variants, + })) + .unwrap(); + let non_null = schema.non_null_variant().unwrap_or_else(|| { + panic!("{union_keyword} must recognize {null_schema} as null-only") + }); + assert_eq!( + non_null.reference(), + Some("#/components/schemas/User"), + "{union_keyword} with {null_schema}" + ); + } + } + } + } + + #[test] + fn nullable_schemas_are_real_union_branches_even_beside_references() { + for union_keyword in ["anyOf", "oneOf"] { + for nullable_branch in [ + json!({"nullable": true}), + json!({"type": "object", "nullable": true}), + json!({ + "$ref": "#/components/schemas/Other", + "nullable": true + }), + ] { + for variants in [ + vec![ + json!({"$ref": "#/components/schemas/User"}), + nullable_branch.clone(), + ], + vec![ + nullable_branch.clone(), + json!({"$ref": "#/components/schemas/User"}), + ], + ] { + let schema: Schema = serde_json::from_value(json!({ + (union_keyword): variants, + })) + .unwrap(); + assert!( + schema.non_null_variant().is_none(), + "{union_keyword} must preserve nullable branch {nullable_branch}" + ); + } + } + } + } + + #[test] + fn reference_sibling_nullable_annotation_is_not_lost() { + for reference_keyword in ["$ref", "$recursiveRef", "$dynamicRef"] { + let nullable: Schema = serde_json::from_value(json!({ + (reference_keyword): "#/components/schemas/Value", + "nullable": true, + "description": "retained sibling" + })) + .unwrap(); + assert!( + nullable.is_nullable_any(), + "{reference_keyword} should retain nullable:true" + ); + + let non_nullable: Schema = serde_json::from_value(json!({ + (reference_keyword): "#/components/schemas/Value", + "nullable": false + })) + .unwrap(); + assert!( + !non_nullable.is_nullable_any(), + "{reference_keyword} nullable:false must stay non-nullable" + ); + } + } + + #[test] + fn null_only_enum_and_const_infer_null_instead_of_string() { + for source in [json!({"enum": [null]}), json!({"const": null})] { + let schema: Schema = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(schema.inferred_type(), Some(SchemaType::Null), "{source}"); + } + + for source in [ + json!({"enum": ["null"]}), + json!({"enum": ["ready", null]}), + json!({"type": "string", "enum": ["ready"]}), + ] { + let schema: Schema = serde_json::from_value(source.clone()).unwrap(); + assert_ne!(schema.inferred_type(), Some(SchemaType::Null), "{source}"); + } + } + + #[test] + fn three_branch_unions_do_not_collapse_even_with_an_explicit_null() { + for union_keyword in ["anyOf", "oneOf"] { + let schema: Schema = serde_json::from_value(json!({ + (union_keyword): [ + {"$ref": "#/components/schemas/User"}, + {"type": "null"}, + {"type": "array", "items": {"type": "string"}} + ], + })) + .unwrap(); + assert!( + schema.non_null_variant().is_none(), + "{union_keyword} with three branches must retain its non-null union" + ); + assert!( + schema.is_nullable_any(), + "{union_keyword} with an explicit null branch must remain nullable" + ); + } + } + #[test] fn is_json_media_type_accepts_canonical_and_structured_suffix() { // Canonical diff --git a/src/schema_roundtrip.rs b/src/schema_roundtrip.rs new file mode 100644 index 0000000..0cd745b --- /dev/null +++ b/src/schema_roundtrip.rs @@ -0,0 +1,2851 @@ +//! Synthetic JSON round-trip planning for generated Rust models. +//! +//! This is an internal conformance tool rather than a public data-faking API. +//! It deliberately uses the source schemas as the oracle: candidates are +//! generated deterministically, rejected unless an independent `jsonschema` +//! validator accepts them, then emitted into a scratch-crate test that runs +//! the exact generated Rust model through Serde twice. + +use crate::{ + SchemaAnalyzer, analysis::component_schema_name_aliases, generator::rust_type_name, + spec_source::parse_oas_version, type_mapping::normalize_builtin_format, +}; +use serde_json::{Map, Number, Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_ATTEMPTS_PER_SCHEMA: usize = 96; +const MAX_SYNTHESIS_DEPTH: usize = 20; +const MAX_DYNAMIC_REF_OCCURRENCES: usize = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Dialect { + Draft4, + Draft202012, +} + +impl Dialect { + fn from_spec(spec: &Value) -> Result> { + let version = spec + .get("openapi") + .and_then(Value::as_str) + .ok_or("OpenAPI document has no string `openapi` version")?; + match parse_oas_version(version) { + Some((3, 0)) => Ok(Self::Draft4), + Some((3, 1 | 2)) => Ok(Self::Draft202012), + _ => Err(format!("unsupported OpenAPI version `{version}`").into()), + } + } + + fn definitions_key(self) -> &'static str { + match self { + Self::Draft4 => "definitions", + Self::Draft202012 => "$defs", + } + } + + fn schema_uri(self) -> &'static str { + match self { + Self::Draft4 => "http://json-schema.org/draft-04/schema#", + Self::Draft202012 => "https://json-schema.org/draft/2020-12/schema", + } + } + + fn rust_variant(self) -> &'static str { + match self { + Self::Draft4 => "jsonschema::Draft::Draft4", + Self::Draft202012 => "jsonschema::Draft::Draft202012", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RoundTripStats { + pub component_schemas: usize, + pub tested_schemas: usize, + pub skipped_schemas: usize, + pub source_invalid_schemas: usize, + pub dependent_schemas: usize, + pub synthesis_skipped_schemas: usize, + pub samples: usize, +} + +impl RoundTripStats { + pub fn to_shell(&self) -> String { + format!( + concat!( + "component_schemas={}\n", + "tested_schemas={}\n", + "skipped_schemas={}\n", + "source_invalid_schemas={}\n", + "dependent_schemas={}\n", + "synthesis_skipped_schemas={}\n", + "samples={}\n" + ), + self.component_schemas, + self.tested_schemas, + self.skipped_schemas, + self.source_invalid_schemas, + self.dependent_schemas, + self.synthesis_skipped_schemas, + self.samples + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedSchema { + pub schema: String, + pub reason: String, +} + +#[derive(Debug, Clone)] +pub struct RoundTripPlan { + pub source: String, + pub stats: RoundTripStats, + pub skipped: Vec, +} + +#[derive(Debug)] +struct ModelCase { + schema_name: String, + rust_type: String, + pointer: String, + samples: Vec, +} + +#[derive(Debug, Default)] +struct SchemaQuarantine { + source_invalid: BTreeMap, + dependent: BTreeMap, +} + +impl SchemaQuarantine { + fn contains(&self, name: &str) -> bool { + self.source_invalid.contains_key(name) || self.dependent.contains_key(name) + } +} + +/// Build the Rust integration test mounted into a generated scratch crate. +pub fn build_round_trip_plan( + spec: &Value, + samples_per_schema: usize, +) -> Result> { + if samples_per_schema == 0 { + return Err("samples_per_schema must be positive".into()); + } + let dialect = Dialect::from_spec(spec)?; + let raw_components = spec + .pointer("/components/schemas") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let component_aliases = component_schema_name_aliases(spec); + + let analysis = SchemaAnalyzer::new(spec.clone())?.analyze()?; + let mut normalized = Map::new(); + for (name, schema) in &raw_components { + normalized.insert( + name.clone(), + normalize_component_schema(name, schema, dialect), + ); + } + let quarantine = quarantine_invalid_components(&normalized, dialect); + let valid_components = normalized + .into_iter() + .filter(|(name, _)| !quarantine.contains(name)) + .collect::>(); + let document = json!({ + "$schema": dialect.schema_uri(), + dialect.definitions_key(): Value::Object(valid_components), + }); + + let validators = validator_options(dialect).build_map(&document)?; + let generator = SyntheticGenerator::with_validators(&document, &validators); + let mut cases = Vec::new(); + let mut skipped = Vec::new(); + + for (name, raw_schema) in &raw_components { + if let Some(reason) = quarantine.source_invalid.get(name) { + skipped.push(SkippedSchema { + schema: name.clone(), + reason: reason.clone(), + }); + continue; + } + if let Some(reason) = quarantine.dependent.get(name) { + skipped.push(SkippedSchema { + schema: name.clone(), + reason: reason.clone(), + }); + continue; + } + let pointer = format!( + "#/{}/{}", + dialect.definitions_key(), + escape_pointer_segment(name) + ); + let target = document + .pointer(pointer.trim_start_matches('#')) + .ok_or_else(|| format!("missing normalized schema at {pointer}"))?; + let analyzed_name = component_aliases.get(name).unwrap_or(name); + let Some(model) = analysis.schemas.get(analyzed_name) else { + skipped.push(SkippedSchema { + schema: name.clone(), + reason: "analysis did not emit a named Rust model".to_string(), + }); + continue; + }; + if contains_required_binary_format(target, &document) { + skipped.push(SkippedSchema { + schema: name.clone(), + reason: "required format: binary is a raw-body contract, not JSON".to_string(), + }); + continue; + } + + let Some(validator) = validators.get(&pointer) else { + skipped.push(SkippedSchema { + schema: name.clone(), + reason: format!("validator map did not retain target `{pointer}`"), + }); + continue; + }; + let mut samples = Vec::new(); + let mut seen = BTreeSet::new(); + for seed in 0..MAX_ATTEMPTS_PER_SCHEMA { + let Some(candidate) = generator.generate(target, seed, 0, &mut Vec::new()) else { + continue; + }; + // A named Rust model is the non-null carrier for a nullable + // component. Null is represented by Option at each reference + // site, where enclosing model samples exercise it. + if model.nullable && candidate.is_null() { + continue; + } + if !validator.is_valid(&candidate) { + continue; + } + let canonical = serde_json::to_string(&candidate)?; + if seen.insert(canonical) { + samples.push(candidate); + } + if samples.len() == samples_per_schema { + break; + } + } + if samples.is_empty() { + let reason = if raw_schema == &Value::Bool(false) { + "schema is uninhabited (`false`)".to_string() + } else { + "deterministic generator found no independently valid JSON instance".to_string() + }; + skipped.push(SkippedSchema { + schema: name.clone(), + reason, + }); + continue; + } + cases.push(ModelCase { + schema_name: name.clone(), + rust_type: rust_type_name(analyzed_name), + pointer, + samples, + }); + } + + let stats = RoundTripStats { + component_schemas: raw_components.len(), + tested_schemas: cases.len(), + skipped_schemas: skipped.len(), + source_invalid_schemas: quarantine.source_invalid.len(), + dependent_schemas: quarantine.dependent.len(), + synthesis_skipped_schemas: skipped + .len() + .saturating_sub(quarantine.source_invalid.len() + quarantine.dependent.len()), + samples: cases.iter().map(|case| case.samples.len()).sum(), + }; + debug_assert_eq!( + stats.skipped_schemas, + stats.source_invalid_schemas + stats.dependent_schemas + stats.synthesis_skipped_schemas + ); + debug_assert_eq!( + stats.component_schemas, + stats.tested_schemas + stats.skipped_schemas + ); + let source = render_test_source(&document, dialect, &cases)?; + Ok(RoundTripPlan { + source, + stats, + skipped, + }) +} + +fn validator_options(dialect: Dialect) -> jsonschema::ValidationOptions<'static> { + jsonschema::options() + .with_draft(match dialect { + Dialect::Draft4 => jsonschema::Draft::Draft4, + Dialect::Draft202012 => jsonschema::Draft::Draft202012, + }) + .should_validate_formats(true) + .with_pattern_options(jsonschema::PatternOptions::regex()) +} + +fn quarantine_invalid_components( + normalized: &Map, + dialect: Dialect, +) -> SchemaQuarantine { + let known: BTreeSet<&str> = normalized.keys().map(String::as_str).collect(); + let mut quarantine = SchemaQuarantine::default(); + let mut dependencies = BTreeMap::>::new(); + + for (name, schema) in normalized { + if let Some(reason) = legacy_recursive_scope_error(name, schema, dialect) { + quarantine.source_invalid.insert(name.clone(), reason); + } + let mut isolated = schema.clone(); + neutralize_local_component_refs(&mut isolated, dialect); + if let Some(reason) = meta_validation_error(name, &isolated, dialect) { + quarantine + .source_invalid + .entry(name.clone()) + .or_insert(reason); + } + + let mut refs = Vec::new(); + collect_local_component_refs(schema, dialect, "", &mut refs); + let mut component_dependencies = BTreeSet::new(); + for local_ref in refs { + if known.contains(local_ref.target.as_str()) { + component_dependencies.insert(local_ref.target); + } else { + quarantine.source_invalid.entry(name.clone()).or_insert_with(|| { + format!( + "source schema invalid at #/components/schemas/{}{}: unresolved local component reference `{}`", + escape_pointer_segment(name), local_ref.location, local_ref.reference + ) + }); + } + } + dependencies.insert(name.clone(), component_dependencies); + } + + let mut blocked: BTreeSet = quarantine.source_invalid.keys().cloned().collect(); + loop { + let mut added = Vec::new(); + for (name, referenced) in &dependencies { + if blocked.contains(name) { + continue; + } + if let Some(dependency) = referenced.iter().find(|target| blocked.contains(*target)) { + added.push((name.clone(), dependency.clone())); + } + } + if added.is_empty() { + break; + } + for (name, dependency) in added { + blocked.insert(name.clone()); + quarantine.dependent.insert( + name, + format!( + "depends on quarantined component at #/components/schemas/{}", + escape_pointer_segment(&dependency) + ), + ); + } + } + + quarantine +} + +fn meta_validation_error(name: &str, schema: &Value, dialect: Dialect) -> Option { + let error = match dialect { + Dialect::Draft4 => jsonschema::draft4::meta::validate(schema).err(), + Dialect::Draft202012 => jsonschema::draft202012::meta::validate(schema).err(), + }?; + Some(format!( + "source schema invalid at #/components/schemas/{}{}: {} (meta-schema path {})", + escape_pointer_segment(name), + error.instance_path(), + error, + error.schema_path() + )) +} + +fn neutralize_local_component_refs(value: &mut Value, dialect: Dialect) { + match value { + Value::Array(values) => { + for value in values { + neutralize_local_component_refs(value, dialect); + } + } + Value::Object(object) => { + if object + .get("$ref") + .and_then(Value::as_str) + .and_then(|reference| normalized_component_ref_target(reference, dialect)) + .is_some() + { + object.remove("$ref"); + } + for value in object.values_mut() { + neutralize_local_component_refs(value, dialect); + } + } + _ => {} + } +} + +#[derive(Debug)] +struct LocalComponentRef { + location: String, + target: String, + reference: String, +} + +fn collect_local_component_refs( + value: &Value, + dialect: Dialect, + location: &str, + refs: &mut Vec, +) { + match value { + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + collect_local_component_refs(value, dialect, &format!("{location}/{index}"), refs); + } + } + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && let Some(target) = normalized_component_ref_target(reference, dialect) + { + refs.push(LocalComponentRef { + location: format!("{location}/$ref"), + target, + reference: reference.to_string(), + }); + } + for (key, value) in object { + collect_local_component_refs( + value, + dialect, + &format!("{location}/{}", escape_pointer_segment(key)), + refs, + ); + } + } + _ => {} + } +} + +fn normalized_component_ref_target(reference: &str, dialect: Dialect) -> Option { + let prefix = format!("#/{}/", dialect.definitions_key()); + let segment = reference.strip_prefix(&prefix)?.split('/').next()?; + unescape_pointer_segment(segment) +} + +fn unescape_pointer_segment(value: &str) -> Option { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(character) = chars.next() { + if character != '~' { + output.push(character); + continue; + } + match chars.next()? { + '0' => output.push('~'), + '1' => output.push('/'), + _ => return None, + } + } + Some(output) +} + +fn render_test_source( + document: &Value, + dialect: Dialect, + cases: &[ModelCase], +) -> Result { + let document_literal = format!("{:?}", serde_json::to_string(document)?); + let mut calls = String::new(); + for case in cases { + let schema_name = format!("{:?}", case.schema_name); + let pointer = format!("{:?}", case.pointer); + let samples = format!("{:?}", serde_json::to_string(&case.samples)?); + calls.push_str(&format!( + " failures.extend(check_model::(&validators, {}, {}, {}));\n", + case.rust_type, schema_name, pointer, samples + )); + } + + Ok(format!( + r#"//! Generated by the schema-roundtrip conformance tool. Do not edit. + +use serde::{{Serialize, de::DeserializeOwned}}; +use serde_json::Value; + +fn errors(validator: &jsonschema::Validator, value: &Value) -> String {{ + validator + .iter_errors(value) + .map(|error| error.to_string()) + .collect::>() + .join("; ") +}} + +fn check_model( + validators: &jsonschema::ValidatorMap, + schema_name: &str, + pointer: &str, + samples_json: &str, +) -> Vec +where + T: DeserializeOwned + Serialize, +{{ + let mut failures = Vec::new(); + let Some(validator) = validators.get(pointer) else {{ + failures.push(format!("{{schema_name}}: missing validator {{pointer}}")); + return failures; + }}; + let samples: Vec = match serde_json::from_str(samples_json) {{ + Ok(samples) => samples, + Err(error) => {{ + failures.push(format!("{{schema_name}}: invalid embedded samples: {{error}}")); + return failures; + }} + }}; + for (sample_index, input) in samples.into_iter().enumerate() {{ + if !validator.is_valid(&input) {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} synthetic precondition failed: {{}}; input={{input}}", + errors(validator, &input), + )); + continue; + }} + let hydrated: T = match serde_json::from_value(input.clone()) {{ + Ok(hydrated) => hydrated, + Err(error) => {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} failed Rust hydration: {{error}}; input={{input}}" + )); + continue; + }} + }}; + let output = match serde_json::to_value(&hydrated) {{ + Ok(output) => output, + Err(error) => {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} failed Rust serialization: {{error}}; input={{input}}" + )); + continue; + }} + }}; + if !validator.is_valid(&output) {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} emitted schema-invalid JSON: {{}}; input={{input}}; output={{output}}", + errors(validator, &output), + )); + }} + let hydrated_again: T = match serde_json::from_value(output.clone()) {{ + Ok(hydrated) => hydrated, + Err(error) => {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} output could not hydrate again: {{error}}; input={{input}}; output={{output}}" + )); + continue; + }} + }}; + let stable = match serde_json::to_value(&hydrated_again) {{ + Ok(stable) => stable, + Err(error) => {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} second serialization failed: {{error}}; input={{input}}; output={{output}}" + )); + continue; + }} + }}; + if output != stable {{ + failures.push(format!( + "{{schema_name}} sample {{sample_index}} did not reach a stable wire representation; input={{input}}; output={{output}}; stable={{stable}}" + )); + }} + }} + failures +}} + +#[test] +fn generated_models_preserve_schema_valid_json() {{ + let outcome = std::thread::Builder::new() + .name("schema-round-trip".to_string()) + .stack_size(64 * 1024 * 1024) + .spawn(run_generated_models_preserve_schema_valid_json) + .unwrap_or_else(|error| panic!("could not start bounded-stack round-trip test: {{error}}")) + .join(); + if let Err(payload) = outcome {{ + std::panic::resume_unwind(payload); + }} +}} + +fn run_generated_models_preserve_schema_valid_json() {{ + let document: Value = serde_json::from_str({document_literal}) + .unwrap_or_else(|error| panic!("invalid embedded schema bundle: {{error}}")); + let validators = jsonschema::options() + .with_draft({draft}) + .should_validate_formats(true) + .with_pattern_options(jsonschema::PatternOptions::regex()) + .build_map(&document) + .unwrap_or_else(|error| panic!("schema bundle did not compile: {{error}}")); + let mut failures: Vec = Vec::new(); +{calls} if !failures.is_empty() {{ + panic!( + "{{}} schema round-trip failure(s):\n\n{{}}", + failures.len(), + failures.join("\n\n"), + ); + }} +}} +"#, + draft = dialect.rust_variant(), + )) +} + +fn normalize_component_schema(name: &str, value: &Value, dialect: Dialect) -> Value { + let mut normalized = normalize_schema(value, dialect); + if dialect == Dialect::Draft202012 { + migrate_safe_recursive_scope(name, &mut normalized); + } + normalized +} + +#[derive(Debug)] +struct RecursiveScopeIssue { + location: String, + message: String, +} + +#[derive(Debug, Default)] +struct LegacyRecursiveScopeAudit { + has_legacy_keyword: bool, + recursive_refs: usize, + issue: Option, +} + +impl LegacyRecursiveScopeAudit { + fn record_issue(&mut self, location: String, message: impl Into) { + if self.issue.is_none() { + self.issue = Some(RecursiveScopeIssue { + location, + message: message.into(), + }); + } + } +} + +fn migrate_safe_recursive_scope(component_name: &str, schema: &mut Value) { + let audit = audit_legacy_recursive_scope(schema); + if !audit.has_legacy_keyword || audit.issue.is_some() { + return; + } + + let anchor = component_dynamic_anchor(component_name); + rewrite_recursive_scope(schema, &anchor, true); +} + +fn audit_legacy_recursive_scope(schema: &Value) -> LegacyRecursiveScopeAudit { + let mut audit = LegacyRecursiveScopeAudit::default(); + inspect_legacy_recursive_scope(schema, "", true, &mut audit); + + if audit.has_legacy_keyword { + let root_anchor = schema + .as_object() + .and_then(|object| object.get("$recursiveAnchor")); + if root_anchor != Some(&Value::Bool(true)) { + audit.record_issue( + "/$recursiveAnchor".to_string(), + "migration requires `$recursiveAnchor: true` at the component root", + ); + } else if audit.recursive_refs == 0 { + audit.record_issue( + "/$recursiveAnchor".to_string(), + "component-root `$recursiveAnchor` has no descendant `$recursiveRef: \"#\"` pair", + ); + } + } + audit +} + +fn inspect_legacy_recursive_scope( + value: &Value, + location: &str, + is_root: bool, + audit: &mut LegacyRecursiveScopeAudit, +) { + match value { + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + inspect_legacy_recursive_scope(value, &format!("{location}/{index}"), false, audit); + } + } + Value::Object(object) => { + if let Some(anchor) = object.get("$recursiveAnchor") { + audit.has_legacy_keyword = true; + if !is_root { + audit.record_issue( + format!("{location}/$recursiveAnchor"), + "nested `$recursiveAnchor` changes the recursive scope", + ); + } else if anchor != &Value::Bool(true) { + audit.record_issue( + "/$recursiveAnchor".to_string(), + "component-root `$recursiveAnchor` must be `true`", + ); + } + } + if let Some(reference) = object.get("$recursiveRef") { + audit.has_legacy_keyword = true; + audit.recursive_refs += 1; + if reference.as_str() != Some("#") { + audit.record_issue( + format!("{location}/$recursiveRef"), + "only `$recursiveRef: \"#\"` can be migrated safely", + ); + } + } + if !is_root && object.contains_key("$id") { + audit.record_issue( + format!("{location}/$id"), + "nested `$id` creates a distinct resource scope", + ); + } + for keyword in ["$anchor", "$dynamicAnchor"] { + if object.contains_key(keyword) { + audit.record_issue( + format!("{location}/{keyword}"), + format!("existing `{keyword}` conflicts with recursive-scope migration"), + ); + } + } + for (key, value) in object { + inspect_legacy_recursive_scope( + value, + &format!("{location}/{}", escape_pointer_segment(key)), + false, + audit, + ); + } + } + _ => {} + } +} + +fn rewrite_recursive_scope(value: &mut Value, anchor: &str, is_root: bool) { + match value { + Value::Array(values) => { + for value in values { + rewrite_recursive_scope(value, anchor, false); + } + } + Value::Object(object) => { + if is_root { + object.remove("$recursiveAnchor"); + object.insert( + "$dynamicAnchor".to_string(), + Value::String(anchor.to_string()), + ); + } + if object.remove("$recursiveRef").is_some() { + object.insert( + "$dynamicRef".to_string(), + Value::String(format!("#{anchor}")), + ); + } + for value in object.values_mut() { + rewrite_recursive_scope(value, anchor, false); + } + } + _ => {} + } +} + +fn component_dynamic_anchor(component_name: &str) -> String { + let mut anchor = "roundtrip_".to_string(); + for byte in component_name.as_bytes() { + anchor.push_str(&format!("{byte:02x}")); + } + anchor +} + +fn legacy_recursive_scope_error( + component_name: &str, + schema: &Value, + dialect: Dialect, +) -> Option { + if dialect != Dialect::Draft202012 { + return None; + } + let audit = audit_legacy_recursive_scope(schema); + if !audit.has_legacy_keyword { + return None; + } + let issue = audit.issue.unwrap_or_else(|| RecursiveScopeIssue { + location: String::new(), + message: "legacy recursive keywords were not migrated".to_string(), + }); + Some(format!( + "source schema invalid at #/components/schemas/{}{}: unsafe legacy recursive scope: {}", + escape_pointer_segment(component_name), + issue.location, + issue.message + )) +} + +fn normalize_schema(value: &Value, dialect: Dialect) -> Value { + let Value::Object(source) = value else { + return value.clone(); + }; + let mut schema = source.clone(); + for child in schema.values_mut() { + normalize_schema_children(child, dialect); + } + if let Some(Value::String(reference)) = schema.get_mut("$ref") { + let prefix = "#/components/schemas/"; + if let Some(name) = reference.strip_prefix(prefix) { + *reference = format!("#/{}/{name}", dialect.definitions_key()); + } + } + if let Some(Value::String(format)) = schema.get_mut("format") { + *format = normalize_builtin_format(format).to_string(); + } + // OpenAPI tooling commonly omits `type: object` when `properties` is + // present, and the analyzer has always emitted a Rust struct for that + // shape. Pure JSON Schema would still admit every non-object value because + // `properties` is conditionally applied. Make the OpenAPI object-inference + // normalization explicit in the schema oracle so generated typing and + // validation enforce the same domain. + if !schema.contains_key("type") && schema.contains_key("properties") { + schema.insert("type".to_string(), Value::String("object".to_string())); + } + if dialect == Dialect::Draft4 + && schema.remove("nullable").and_then(|value| value.as_bool()) == Some(true) + { + return json!({ "anyOf": [Value::Object(schema), { "type": "null" }] }); + } + Value::Object(schema) +} + +fn normalize_schema_children(value: &mut Value, dialect: Dialect) { + match value { + Value::Array(values) => { + for value in values { + *value = normalize_schema(value, dialect); + } + } + Value::Object(_) => *value = normalize_schema(value, dialect), + _ => {} + } +} + +fn escape_pointer_segment(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn contains_required_binary_format(schema: &Value, root: &Value) -> bool { + contains_required_binary_format_inner(schema, root, 0, &mut BTreeSet::new()) +} + +fn contains_required_binary_format_inner( + schema: &Value, + root: &Value, + depth: usize, + visited_refs: &mut BTreeSet, +) -> bool { + if depth > MAX_SYNTHESIS_DEPTH { + return false; + } + let Value::Object(object) = schema else { + return false; + }; + if object.get("format").and_then(Value::as_str) == Some("binary") { + return true; + } + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + if !visited_refs.insert(reference.to_string()) { + return false; + } + let result = resolve_local_ref(root, reference).is_some_and(|target| { + contains_required_binary_format_inner(target, root, depth + 1, visited_refs) + }); + visited_refs.remove(reference); + if result { + return true; + } + } + for keyword in ["allOf", "anyOf", "oneOf"] { + if object + .get(keyword) + .and_then(Value::as_array) + .is_some_and(|branches| { + branches.iter().any(|branch| { + contains_required_binary_format_inner(branch, root, depth + 1, visited_refs) + }) + }) + { + return true; + } + } + + // Every produced array member is part of the model's wire value. A raw + // binary item therefore makes the array itself a non-JSON contract even + // when `minItems` permits an empty sample. + for keyword in ["items", "contains"] { + if object.get(keyword).is_some_and(|child| { + schema_contains_binary_format_inner(child, root, depth + 1, visited_refs) + }) { + return true; + } + } + if object + .get("prefixItems") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|child| { + schema_contains_binary_format_inner(child, root, depth + 1, visited_refs) + }) + }) + { + return true; + } + + let required: BTreeSet<&str> = object + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + object + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| { + properties.iter().any(|(name, child)| { + required.contains(name.as_str()) + && contains_required_binary_format_inner(child, root, depth + 1, visited_refs) + }) + }) +} + +fn schema_contains_binary_format(schema: &Value, root: &Value) -> bool { + schema_contains_binary_format_inner(schema, root, 0, &mut BTreeSet::new()) +} + +fn schema_contains_binary_format_inner( + schema: &Value, + root: &Value, + depth: usize, + visited_refs: &mut BTreeSet, +) -> bool { + if depth > MAX_SYNTHESIS_DEPTH { + return false; + } + let Value::Object(object) = schema else { + return false; + }; + if object.get("format").and_then(Value::as_str) == Some("binary") { + return true; + } + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + if !visited_refs.insert(reference.to_string()) { + return false; + } + let result = resolve_local_ref(root, reference).is_some_and(|target| { + schema_contains_binary_format_inner(target, root, depth + 1, visited_refs) + }); + visited_refs.remove(reference); + if result { + return true; + } + } + + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if object + .get(keyword) + .and_then(Value::as_array) + .is_some_and(|schemas| { + schemas.iter().any(|child| { + schema_contains_binary_format_inner(child, root, depth + 1, visited_refs) + }) + }) + { + return true; + } + } + for keyword in [ + "items", + "contains", + "additionalProperties", + "unevaluatedProperties", + "not", + "if", + "then", + "else", + "propertyNames", + ] { + if object.get(keyword).is_some_and(|child| { + schema_contains_binary_format_inner(child, root, depth + 1, visited_refs) + }) { + return true; + } + } + for keyword in [ + "properties", + "patternProperties", + "dependentSchemas", + "$defs", + "definitions", + ] { + if object + .get(keyword) + .and_then(Value::as_object) + .is_some_and(|schemas| { + schemas.values().any(|child| { + schema_contains_binary_format_inner(child, root, depth + 1, visited_refs) + }) + }) + { + return true; + } + } + false +} + +struct SyntheticGenerator<'a> { + root: &'a Value, + dynamic_anchors: BTreeMap, + validators: Option<&'a jsonschema::ValidatorMap>, +} + +impl<'a> SyntheticGenerator<'a> { + #[cfg(test)] + fn new(root: &'a Value) -> Self { + Self::new_inner(root, None) + } + + fn with_validators(root: &'a Value, validators: &'a jsonschema::ValidatorMap) -> Self { + Self::new_inner(root, Some(validators)) + } + + fn new_inner(root: &'a Value, validators: Option<&'a jsonschema::ValidatorMap>) -> Self { + let mut dynamic_anchors = BTreeMap::new(); + collect_dynamic_anchors(root, &mut dynamic_anchors); + Self { + root, + dynamic_anchors, + validators, + } + } + + fn generate( + &self, + schema: &Value, + seed: usize, + depth: usize, + refs: &mut Vec, + ) -> Option { + if depth > MAX_SYNTHESIS_DEPTH { + return None; + } + match schema { + Value::Bool(true) => return Some(any_value(seed, depth)), + Value::Bool(false) => return None, + Value::Object(_) => {} + _ => return Some(any_value(seed, depth)), + } + let object = schema.as_object()?; + let contains_raw_binary = schema_contains_binary_format(schema, self.root); + + if !contains_raw_binary + && seed == 0 + && let Some(default) = object.get("default") + { + return Some(default.clone()); + } + if !contains_raw_binary + && seed == 1 + && let Some(example) = object + .get("examples") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .or_else(|| object.get("example")) + { + return Some(example.clone()); + } + if let Some(constant) = object.get("const") { + return Some(constant.clone()); + } + if let Some(values) = object.get("enum").and_then(Value::as_array) { + if values.is_empty() { + return None; + } + return values.get(seed % values.len()).cloned(); + } + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + if refs.iter().any(|seen| seen == reference) { + return None; + } + let target = resolve_local_ref(self.root, reference)?; + refs.push(reference.to_string()); + let generated = self.generate(target, seed + 2, depth + 1, refs); + refs.pop(); + return generated; + } + if let Some(reference) = object.get("$dynamicRef").and_then(Value::as_str) { + let anchor = reference + .strip_prefix('#') + .filter(|anchor| !anchor.is_empty() && !anchor.contains('/'))?; + let target = *self.dynamic_anchors.get(anchor)?; + let recursion_key = format!("$dynamicRef:{reference}"); + if refs.iter().filter(|seen| *seen == &recursion_key).count() + >= MAX_DYNAMIC_REF_OCCURRENCES + { + return None; + } + refs.push(recursion_key); + let generated = self.generate(target, seed + 2, depth + 1, refs); + refs.pop(); + return generated; + } + if let Some(branches) = object.get("oneOf").and_then(Value::as_array) { + if let Some(discriminator) = object.get("discriminator").and_then(Value::as_object) { + return self.generate_discriminated_branch( + branches, + discriminator, + seed, + depth, + refs, + ); + } + return self.generate_branch(branches, seed, depth, refs); + } + if let Some(branches) = object.get("anyOf").and_then(Value::as_array) { + if let Some(discriminator) = object.get("discriminator").and_then(Value::as_object) { + return self.generate_discriminated_branch( + branches, + discriminator, + seed, + depth, + refs, + ); + } + return self.generate_branch(branches, seed, depth, refs); + } + if let Some(branches) = object.get("allOf").and_then(Value::as_array) { + let mut merged = Value::Null; + for (index, branch) in branches.iter().enumerate() { + let part = self.generate(branch, seed + index + 2, depth + 1, refs)?; + merged = merge_values(merged, part)?; + } + return Some(merged); + } + + let types = schema_types(object); + let selected = types.get(seed % types.len().max(1)).copied(); + match selected { + Some("null") => Some(Value::Null), + Some("boolean") => Some(Value::Bool(seed.is_multiple_of(2))), + Some("integer") => numeric_value(object, seed, true), + Some("number") => numeric_value(object, seed, false), + Some("string") => Some(Value::String(string_value(object, seed))), + Some("array") => self.array_value(object, seed, depth, refs), + Some("object") => self.object_value(object, seed, depth, refs), + _ => Some(any_value(seed, depth)), + } + } + + fn generate_branch( + &self, + branches: &[Value], + seed: usize, + depth: usize, + refs: &mut Vec, + ) -> Option { + if branches.is_empty() { + return None; + } + for offset in 0..branches.len() { + let index = (seed + offset) % branches.len(); + if schema_contains_binary_format(&branches[index], self.root) { + continue; + } + let branch = &branches[index]; + if let Some(value) = self.generate(branch, seed + offset + 2, depth + 1, refs) + && self.branch_accepts_candidate(branch, &value) + { + return Some(value); + } + } + None + } + + fn generate_discriminated_branch( + &self, + branches: &[Value], + discriminator: &Map, + seed: usize, + depth: usize, + refs: &mut Vec, + ) -> Option { + if branches.is_empty() { + return None; + } + let field = discriminator.get("propertyName").and_then(Value::as_str)?; + let mappings = discriminator.get("mapping").and_then(Value::as_object); + + for offset in 0..branches.len() { + let index = (seed + offset) % branches.len(); + let branch = &branches[index]; + if schema_contains_binary_format(branch, self.root) { + continue; + } + let Some(candidate) = self.generate(branch, seed + offset + 2, depth + 1, refs) else { + continue; + }; + let branch_tags = self.discriminator_tags_for_branch(branch, field); + let Some(mappings) = mappings else { + if let Some(tagged) = + self.tagged_branch_candidate(branch, &candidate, field, &branch_tags, seed) + { + return Some(tagged); + } + continue; + }; + let matching_keys = mappings + .iter() + .filter_map(|(key, target)| { + target + .as_str() + .filter(|target| self.mapping_target_matches_branch(target, branch)) + .map(|_| key.as_str()) + }) + .collect::>(); + if matching_keys.is_empty() { + if self.branch_accepts_candidate(branch, &candidate) + && !candidate + .get(field) + .and_then(Value::as_str) + .and_then(|tag| mappings.get(tag)) + .and_then(Value::as_str) + .is_some_and(|target| !self.mapping_target_matches_branch(target, branch)) + { + return Some(candidate); + } + continue; + } + let Value::Object(candidate_object) = &candidate else { + return Some(candidate); + }; + + for key_offset in 0..matching_keys.len() { + let key = matching_keys[(seed + key_offset) % matching_keys.len()]; + let mut tagged = candidate_object.clone(); + tagged.insert(field.to_string(), Value::String(key.to_string())); + let tagged = Value::Object(tagged); + if self.branch_accepts_candidate(branch, &tagged) { + return Some(tagged); + } + } + + if let Some(tagged) = + self.tagged_branch_candidate(branch, &candidate, field, &branch_tags, seed) + { + let redirects_elsewhere = tagged + .get(field) + .and_then(Value::as_str) + .and_then(|tag| mappings.get(tag)) + .and_then(Value::as_str) + .is_some_and(|target| !self.mapping_target_matches_branch(target, branch)); + if !redirects_elsewhere { + return Some(tagged); + } + } + + // A contradictory mapping is only a hint and must not fabricate + // validity. If the branch generated a value that satisfies its + // own constraints, keep that schema-faithful value and let the + // independent union validator decide whether it is usable. + if self.branch_accepts_candidate(branch, &candidate) { + return Some(candidate); + } + } + None + } + + fn tagged_branch_candidate( + &self, + branch: &Value, + candidate: &Value, + field: &str, + tags: &[String], + seed: usize, + ) -> Option { + let Value::Object(candidate_object) = candidate else { + return self + .branch_accepts_candidate(branch, candidate) + .then(|| candidate.clone()); + }; + if tags.is_empty() { + return self + .branch_accepts_candidate(branch, candidate) + .then(|| candidate.clone()); + } + for offset in 0..tags.len() { + let tag = &tags[(seed + offset) % tags.len()]; + let mut tagged = candidate_object.clone(); + tagged.insert(field.to_string(), Value::String(tag.clone())); + let tagged = Value::Object(tagged); + if self.branch_accepts_candidate(branch, &tagged) { + return Some(tagged); + } + } + None + } + + fn discriminator_tags_for_branch(&self, branch: &Value, field: &str) -> Vec { + if let Some(domain) = self.discriminator_field_domain(branch, field, &mut BTreeSet::new()) + && !domain.is_empty() + { + return domain; + } + branch + .get("$ref") + .and_then(Value::as_str) + .and_then(|reference| reference.rsplit('/').next()) + .map(Self::implicit_discriminator_tag) + .into_iter() + .collect() + } + + fn discriminator_field_domain( + &self, + schema: &Value, + field: &str, + visited_refs: &mut BTreeSet, + ) -> Option> { + let object = schema.as_object()?; + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + if !visited_refs.insert(reference.to_string()) { + return None; + } + let result = resolve_local_ref(self.root, reference) + .and_then(|target| self.discriminator_field_domain(target, field, visited_refs)); + visited_refs.remove(reference); + return result; + } + + let own_domain = object + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(field)) + .and_then(|property| self.string_constraint_domain(property, visited_refs)); + let composition_domain = + if let Some(branches) = object.get("allOf").and_then(Value::as_array) { + branches.iter().fold(None, |domain, branch| { + Self::intersect_string_domains( + domain, + self.discriminator_field_domain(branch, field, visited_refs), + ) + }) + } else if let Some(branches) = object + .get("anyOf") + .or_else(|| object.get("oneOf")) + .and_then(Value::as_array) + { + self.union_string_domains(branches, field, visited_refs) + } else { + None + }; + Self::intersect_string_domains(own_domain, composition_domain) + } + + fn string_constraint_domain( + &self, + schema: &Value, + visited_refs: &mut BTreeSet, + ) -> Option> { + let object = schema.as_object()?; + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + if !visited_refs.insert(reference.to_string()) { + return None; + } + let result = resolve_local_ref(self.root, reference) + .and_then(|target| self.string_constraint_domain(target, visited_refs)); + visited_refs.remove(reference); + return result; + } + let own_domain = object + .get("const") + .and_then(Value::as_str) + .map(|value| vec![value.to_string()]) + .or_else(|| { + object.get("enum").and_then(Value::as_array).map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + }); + let composition_domain = + if let Some(branches) = object.get("allOf").and_then(Value::as_array) { + branches.iter().fold(None, |domain, branch| { + Self::intersect_string_domains( + domain, + self.string_constraint_domain(branch, visited_refs), + ) + }) + } else if let Some(branches) = object + .get("anyOf") + .or_else(|| object.get("oneOf")) + .and_then(Value::as_array) + { + let mut values = Vec::new(); + for branch in branches { + for value in self.string_constraint_domain(branch, visited_refs)? { + if !values.contains(&value) { + values.push(value); + } + } + } + Some(values) + } else { + None + }; + Self::intersect_string_domains(own_domain, composition_domain) + } + + fn union_string_domains( + &self, + branches: &[Value], + field: &str, + visited_refs: &mut BTreeSet, + ) -> Option> { + let mut values = Vec::new(); + for branch in branches { + for value in self.discriminator_field_domain(branch, field, visited_refs)? { + if !values.contains(&value) { + values.push(value); + } + } + } + Some(values) + } + + fn intersect_string_domains( + left: Option>, + right: Option>, + ) -> Option> { + match (left, right) { + (None, other) | (other, None) => other, + (Some(left), Some(right)) => Some( + left.into_iter() + .filter(|value| right.contains(value)) + .collect(), + ), + } + } + + fn implicit_discriminator_tag(schema_name: &str) -> String { + let mut result = String::new(); + let mut chars = schema_name.chars().peekable(); + let mut first = true; + while let Some(character) = chars.next() { + if character.is_uppercase() + && !first + && chars.peek().is_some_and(|next| next.is_lowercase()) + { + result.push('.'); + } + result.push(character.to_ascii_lowercase()); + first = false; + } + if result.ends_with("event") { + result.truncate(result.len() - "event".len()); + } + if schema_name.starts_with("Response") && !result.starts_with("response.") { + result = format!("response.{}", result.trim_start_matches("response")); + } + result + } + + fn mapping_target_matches_branch(&self, target: &str, branch: &Value) -> bool { + let Some(branch_reference) = branch.get("$ref").and_then(Value::as_str) else { + return false; + }; + self.normalized_component_reference(target) == branch_reference + || self.normalized_component_reference(branch_reference) == target + } + + fn normalized_component_reference(&self, reference: &str) -> String { + let Some(name) = reference.strip_prefix("#/components/schemas/") else { + return reference.to_string(); + }; + let definitions_key = if self.root.get("$defs").is_some() { + "$defs" + } else { + "definitions" + }; + format!("#/{definitions_key}/{name}") + } + + fn branch_accepts_candidate(&self, branch: &Value, candidate: &Value) -> bool { + let Some(reference) = branch.get("$ref").and_then(Value::as_str) else { + // Inline branches still receive independent validation against the + // containing component before becoming round-trip samples. + return true; + }; + self.validators + .and_then(|validators| validators.get(reference)) + .is_none_or(|validator| validator.is_valid(candidate)) + } + + fn object_value( + &self, + object: &Map, + seed: usize, + depth: usize, + refs: &mut Vec, + ) -> Option { + let properties = object + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let required: BTreeSet = object + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + let mut output = Map::new(); + for (index, name) in required.iter().enumerate() { + let child = properties + .get(name) + .or_else(|| object.get("additionalProperties")) + .unwrap_or(&Value::Bool(true)); + let value = self.generate(child, seed + index + 2, depth + 1, refs)?; + output.insert(name.clone(), value); + } + + let optional: Vec<_> = properties + .iter() + .filter(|(name, child)| { + !required.contains(*name) && !schema_contains_binary_format(child, self.root) + }) + .collect(); + let optional_count = if optional.is_empty() { + 0 + } else { + seed % (optional.len() + 1) + }; + for (index, (name, child)) in optional.into_iter().take(optional_count).enumerate() { + if let Some(value) = self.generate(child, seed + index + 7, depth + 1, refs) { + output.insert(name.clone(), value); + } + } + + if seed % 4 == 3 + && let Some(additional) = object.get("additionalProperties") + && additional != &Value::Bool(false) + && !schema_contains_binary_format(additional, self.root) + && let Some(value) = self.generate(additional, seed + 13, depth + 1, refs) + { + output.insert(format!("synthetic_extra_{seed}"), value); + } + apply_dependent_required(self, object, &properties, seed, depth, refs, &mut output)?; + + let min_properties = object + .get("minProperties") + .and_then(json_count) + .unwrap_or(0) as usize; + if output.len() < min_properties { + return None; + } + Some(Value::Object(output)) + } + + fn array_value( + &self, + object: &Map, + seed: usize, + depth: usize, + refs: &mut Vec, + ) -> Option { + let min = object.get("minItems").and_then(json_count).unwrap_or(0) as usize; + let max = object + .get("maxItems") + .and_then(json_count) + .map(|value| value as usize) + .unwrap_or(min.saturating_add(4)); + if min > max { + return None; + } + let target_len = min.saturating_add(seed % 3).min(max); + let prefix = object + .get("prefixItems") + .and_then(Value::as_array) + .or_else(|| object.get("items").and_then(Value::as_array)); + let items = match object.get("items") { + Some(Value::Array(_)) => None, + other => other, + }; + let mut output = Vec::new(); + for index in 0..target_len { + let child = prefix + .and_then(|schemas| schemas.get(index)) + .or(items) + .unwrap_or(&Value::Bool(true)); + if child == &Value::Bool(false) { + return None; + } + let mut value = self.generate(child, seed + index + 2, depth + 1, refs)?; + if object.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + for retry in 0..8 { + if !output.contains(&value) { + break; + } + value = self.generate(child, seed + index + retry + 17, depth + 1, refs)?; + } + if output.contains(&value) { + return None; + } + } + output.push(value); + } + if let Some(contains) = object.get("contains") { + let needed = object.get("minContains").and_then(json_count).unwrap_or(1) as usize; + while output.len() < needed && output.len() < max { + output.push(self.generate(contains, seed + output.len() + 29, depth + 1, refs)?); + } + for index in 0..needed.min(output.len()) { + output[index] = self.generate(contains, seed + index + 31, depth + 1, refs)?; + } + } + Some(Value::Array(output)) + } +} + +fn collect_dynamic_anchors<'a>(value: &'a Value, anchors: &mut BTreeMap) { + match value { + Value::Array(values) => { + for value in values { + collect_dynamic_anchors(value, anchors); + } + } + Value::Object(object) => { + if let Some(anchor) = object.get("$dynamicAnchor").and_then(Value::as_str) { + anchors.entry(anchor.to_string()).or_insert(value); + } + for value in object.values() { + collect_dynamic_anchors(value, anchors); + } + } + _ => {} + } +} + +#[allow(clippy::too_many_arguments)] +fn apply_dependent_required( + generator: &SyntheticGenerator<'_>, + object: &Map, + properties: &Map, + seed: usize, + depth: usize, + refs: &mut Vec, + output: &mut Map, +) -> Option<()> { + let dependencies = object + .get("dependentRequired") + .or_else(|| object.get("dependencies")) + .and_then(Value::as_object); + let Some(dependencies) = dependencies else { + return Some(()); + }; + let triggers: Vec = output.keys().cloned().collect(); + for trigger in triggers { + let Some(names) = dependencies.get(&trigger).and_then(Value::as_array) else { + continue; + }; + for (index, name) in names.iter().filter_map(Value::as_str).enumerate() { + if output.contains_key(name) { + continue; + } + let child = properties.get(name).unwrap_or(&Value::Bool(true)); + if schema_contains_binary_format(child, generator.root) { + return None; + } + let value = generator.generate(child, seed + index + 41, depth + 1, refs)?; + output.insert(name.to_string(), value); + } + } + Some(()) +} + +fn schema_types(object: &Map) -> Vec<&str> { + match object.get("type") { + Some(Value::String(value)) => return vec![value.as_str()], + Some(Value::Array(values)) => { + let types: Vec<_> = values.iter().filter_map(Value::as_str).collect(); + if !types.is_empty() { + return types; + } + } + _ => {} + } + if object.contains_key("properties") + || object.contains_key("required") + || object.contains_key("additionalProperties") + || object.contains_key("minProperties") + { + vec!["object"] + } else if object.contains_key("items") + || object.contains_key("prefixItems") + || object.contains_key("minItems") + { + vec!["array"] + } else { + vec![ + "null", "boolean", "integer", "number", "string", "array", "object", + ] + } +} + +fn numeric_value(object: &Map, seed: usize, integer: bool) -> Option { + let minimum = object.get("minimum").and_then(Value::as_f64).unwrap_or(0.0); + let maximum = object + .get("maximum") + .and_then(Value::as_f64) + .unwrap_or(minimum + 100.0); + let mut value = match seed % 3 { + 0 => minimum, + 1 => maximum, + _ => (minimum + maximum) / 2.0, + }; + if let Some(exclusive) = object.get("exclusiveMinimum").and_then(Value::as_f64) { + value = value.max(exclusive + if integer { 1.0 } else { 0.5 }); + } else if object.get("exclusiveMinimum").and_then(Value::as_bool) == Some(true) { + value = value.max(minimum + if integer { 1.0 } else { 0.5 }); + } + if let Some(exclusive) = object.get("exclusiveMaximum").and_then(Value::as_f64) { + value = value.min(exclusive - if integer { 1.0 } else { 0.5 }); + } else if object.get("exclusiveMaximum").and_then(Value::as_bool) == Some(true) { + value = value.min(maximum - if integer { 1.0 } else { 0.5 }); + } + if let Some(multiple) = object.get("multipleOf").and_then(Value::as_f64) + && multiple > 0.0 + { + value = (value / multiple).ceil() * multiple; + } + if integer { + let integer_value = value.round(); + if integer_value >= 0.0 && integer_value <= u64::MAX as f64 { + return Some(Value::Number(Number::from(integer_value as u64))); + } + if integer_value >= i64::MIN as f64 && integer_value <= i64::MAX as f64 { + return Some(Value::Number(Number::from(integer_value as i64))); + } + None + } else { + Number::from_f64(value).map(Value::Number) + } +} + +fn string_value(object: &Map, seed: usize) -> String { + let min = object.get("minLength").and_then(json_count).unwrap_or(0) as usize; + let max = object + .get("maxLength") + .and_then(json_count) + .map(|value| value as usize) + .unwrap_or(usize::MAX); + let format = object.get("format").and_then(Value::as_str); + let mut value = match format { + Some("date-time") => "2024-01-02T03:04:05Z".to_string(), + Some("date") => "2024-01-02".to_string(), + Some("time") => "03:04:05Z".to_string(), + Some("duration") => "P1DT2H".to_string(), + Some("uuid") => "123e4567-e89b-42d3-a456-426614174000".to_string(), + Some("uri") | Some("url") => "https://example.com/resource".to_string(), + Some("uri-reference") => "/resource/1".to_string(), + Some("email") => "agent@example.com".to_string(), + Some("hostname") => "example.com".to_string(), + Some("ipv4") => "192.0.2.1".to_string(), + Some("ipv6") => "2001:db8::1".to_string(), + Some("byte") => "AQID".to_string(), + Some("json-pointer") => "/items/0".to_string(), + _ => object + .get("pattern") + .and_then(Value::as_str) + .map(|pattern| pattern_string(pattern, min, seed)) + .unwrap_or_else(|| format!("synthetic{seed}")), + }; + while value.chars().count() < min { + value.push('x'); + } + if value.chars().count() > max { + value = value.chars().take(max).collect(); + } + value +} + +fn pattern_string(pattern: &str, min: usize, seed: usize) -> String { + let fill = if pattern.contains("[A-Z]") { + 'A' + } else if pattern.contains("[0-9]") || pattern.contains("\\d") { + '1' + } else if pattern.contains("[a-zA-Z]") { + 'a' + } else { + 'x' + }; + let quantified_min = pattern + .split('{') + .nth(1) + .and_then(|tail| tail.split([',', '}']).next()) + .and_then(|number| number.parse::().ok()) + .unwrap_or_else(|| usize::from(pattern.contains('+'))); + let len = min.max(quantified_min).max(1).saturating_add(seed % 2); + std::iter::repeat_n(fill, len).collect() +} + +fn json_count(value: &Value) -> Option { + value.as_u64().or_else(|| { + value + .as_f64() + .filter(|v| v.fract() == 0.0 && *v >= 0.0) + .map(|v| v as u64) + }) +} + +fn any_value(seed: usize, depth: usize) -> Value { + match seed % 7 { + 0 => Value::Null, + 1 => Value::Bool(seed.is_multiple_of(2)), + 2 => json!(seed as i64), + 3 => json!(seed as f64 + 0.5), + 4 => Value::String(format!("synthetic_{seed}")), + 5 if depth < MAX_SYNTHESIS_DEPTH => Value::Array(vec![json!(seed)]), + 6 if depth < MAX_SYNTHESIS_DEPTH => { + Value::Object(Map::from_iter([("value".to_string(), json!(seed))])) + } + _ => Value::Null, + } +} + +fn merge_values(left: Value, right: Value) -> Option { + match (left, right) { + (Value::Null, value) | (value, Value::Null) => Some(value), + (Value::Object(mut left), Value::Object(right)) => { + for (key, value) in right { + if let Some(existing) = left.remove(&key) { + left.insert(key, merge_values(existing, value)?); + } else { + left.insert(key, value); + } + } + Some(Value::Object(left)) + } + (left, right) if left == right => Some(left), + _ => None, + } +} + +fn resolve_local_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> { + let pointer = reference.strip_prefix('#')?; + root.pointer(pointer) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn recursive_compound_filter_schema() -> Value { + json!({ + "$recursiveAnchor": true, + "type": "object", + "additionalProperties": false, + "required": ["type", "filters"], + "properties": { + "type": { "type": "string", "enum": ["and", "or"] }, + "filters": { + "type": "array", + "items": { + "anyOf": [ + { "$ref": "#/components/schemas/ComparisonFilter" }, + { "$recursiveRef": "#" } + ] + } + } + } + }) + } + + #[test] + fn selects_schema_dialect_from_canonical_openapi_versions() { + for version in ["3.0", "3.0.0", "3.0.4"] { + let spec = json!({ "openapi": version }); + assert_eq!(Dialect::from_spec(&spec).expect("draft 4"), Dialect::Draft4); + } + for version in ["3.1", "3.1.2", "3.2", "3.2.0"] { + let spec = json!({ "openapi": version }); + assert_eq!( + Dialect::from_spec(&spec).expect("draft 2020-12"), + Dialect::Draft202012 + ); + } + for version in ["3", "v3.0", "2.0", "3.3.0", "not-a-version"] { + let spec = json!({ "openapi": version }); + let error = Dialect::from_spec(&spec).expect_err("unsupported version"); + assert!(error.to_string().contains(version), "{error}"); + } + } + + #[test] + fn properties_without_type_are_normalized_to_the_analyzers_object_domain() { + let raw = json!({ + "properties": { + "status": {"type": "string"}, + "nested": {"properties": {"id": {"type": "integer"}}} + }, + "example": [{"status": "legacy-array-example"}] + }); + let normalized = normalize_schema(&raw, Dialect::Draft202012); + assert_eq!(normalized["type"], "object"); + assert_eq!(normalized["properties"]["nested"]["type"], "object"); + + let validator = validator_options(Dialect::Draft202012) + .build(&normalized) + .expect("normalized properties-only schema"); + assert!(validator.is_valid(&json!({"status": "delivered"}))); + assert!(!validator.is_valid(&json!([{"status": "delivered"}]))); + + let explicit_non_object = normalize_schema( + &json!({"type": "string", "properties": {"ignored": {"type": "string"}}}), + Dialect::Draft202012, + ); + assert_eq!(explicit_non_object["type"], "string"); + let unconstrained = normalize_schema(&json!({"description": "any"}), Dialect::Draft202012); + assert!(unconstrained.get("type").is_none()); + } + + #[test] + fn raw_binary_detection_follows_refs_compositions_and_avoids_cycles() { + let document = json!({ + "$defs": { + "Binary": { "type": "string", "format": "binary" }, + "BinaryAlias": { + "allOf": [{ "$ref": "#/$defs/Binary" }] + }, + "RequiredUpload": { + "type": "object", + "required": ["file", "name"], + "properties": { + "file": { + "oneOf": [ + { "$ref": "#/$defs/BinaryAlias" }, + { "type": "string", "format": "uri" } + ] + }, + "name": { "type": "string" } + } + }, + "OptionalUpload": { + "type": "object", + "required": ["name"], + "default": { "name": "default", "file": "raw-default" }, + "examples": [{ "name": "example", "file": "raw-example" }], + "properties": { + "file": { "$ref": "#/$defs/BinaryAlias" }, + "name": { "type": "string" } + } + }, + "CycleA": { "$ref": "#/$defs/CycleB" }, + "CycleB": { + "anyOf": [ + { "$ref": "#/$defs/CycleA" }, + { "type": "string" } + ] + } + } + }); + + assert!(schema_contains_binary_format( + &document["$defs"]["BinaryAlias"], + &document + )); + assert!(contains_required_binary_format( + &document["$defs"]["RequiredUpload"], + &document + )); + assert!(!contains_required_binary_format( + &document["$defs"]["OptionalUpload"], + &document + )); + assert!(!schema_contains_binary_format( + &document["$defs"]["CycleA"], + &document + )); + + let generator = SyntheticGenerator::new(&document); + for seed in 0..4 { + let generated = generator + .generate( + &document["$defs"]["OptionalUpload"], + seed, + 0, + &mut Vec::new(), + ) + .expect("optional binary fields must not block safe synthesis"); + assert!(generated.get("name").is_some(), "{generated}"); + assert!(generated.get("file").is_none(), "{generated}"); + } + } + + #[test] + fn round_trip_plan_skips_required_binary_but_keeps_optional_siblings() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "raw binary planning", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "Binary": { "type": "string", "format": "binary" }, + "RequiredUpload": { + "type": "object", + "required": ["file", "name"], + "properties": { + "file": { + "oneOf": [ + { "$ref": "#/components/schemas/Binary" }, + { "type": "string", "format": "uri" } + ] + }, + "name": { "type": "string" } + } + }, + "OptionalUpload": { + "type": "object", + "required": ["name"], + "examples": [{ "name": "example", "file": "raw-example" }], + "properties": { + "file": { "$ref": "#/components/schemas/Binary" }, + "name": { "type": "string" } + } + } + } } + }); + + let plan = build_round_trip_plan(&spec, 4).expect("binary-aware plan"); + let skipped = plan + .skipped + .iter() + .map(|entry| entry.schema.as_str()) + .collect::>(); + assert_eq!(skipped, BTreeSet::from(["Binary", "RequiredUpload"])); + assert_eq!(plan.stats.tested_schemas, 1); + assert_eq!(plan.stats.synthesis_skipped_schemas, 2); + assert!( + plan.source + .contains("crate::generated::types::OptionalUpload") + ); + } + + #[test] + fn normalizes_uuid_aliases_for_validation_and_v4_synthesis() { + for format in ["uuid", "uuid4", "uuid_v4", "UUID"] { + let normalized = normalize_schema( + &json!({ "type": "string", "format": format }), + Dialect::Draft202012, + ); + assert_eq!(normalized.get("format"), Some(&json!("uuid"))); + + let validator = validator_options(Dialect::Draft202012) + .build(&normalized) + .expect("UUID schema"); + let candidate = SyntheticGenerator::new(&normalized) + .generate(&normalized, 2, 0, &mut Vec::new()) + .expect("UUID candidate"); + assert_eq!(candidate, json!("123e4567-e89b-42d3-a456-426614174000")); + assert!( + validator.is_valid(&candidate), + "format={format}: {candidate}" + ); + } + } + + #[test] + fn rejects_malformed_uuid_alias_examples_and_preserves_unknown_formats() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "AliasedUuid": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "format": "uuid4" } + }, + "examples": [{ + "id": "e3c6ee77-48cb-416b-b204-11b492cc776e3" + }] + } + }} + }); + let plan = build_round_trip_plan(&spec, 2).expect("plan"); + assert_eq!(plan.stats.tested_schemas, 1, "skipped: {:?}", plan.skipped); + assert_eq!(plan.stats.samples, 1, "malformed example must be rejected"); + + let unknown = normalize_schema( + &json!({ "type": "string", "format": "vendor-id" }), + Dialect::Draft202012, + ); + assert_eq!(unknown.get("format"), Some(&json!("vendor-id"))); + let candidate = SyntheticGenerator::new(&unknown) + .generate(&unknown, 7, 0, &mut Vec::new()) + .expect("unknown-format candidate"); + assert_eq!(candidate, json!("synthetic7")); + } + + #[test] + fn atomically_migrates_safe_component_recursive_scope_to_dynamic_keywords() { + let normalized = normalize_component_schema( + "CompoundFilter", + &recursive_compound_filter_schema(), + Dialect::Draft202012, + ); + let anchor = "roundtrip_436f6d706f756e6446696c746572"; + + assert_eq!(normalized.get("$dynamicAnchor"), Some(&json!(anchor))); + assert!(normalized.get("$recursiveAnchor").is_none()); + assert_eq!( + normalized.pointer("/properties/filters/items/anyOf/1/$dynamicRef"), + Some(&json!(format!("#{anchor}"))) + ); + assert!( + normalized + .pointer("/properties/filters/items/anyOf/1/$recursiveRef") + .is_none() + ); + jsonschema::draft202012::meta::validate(&normalized) + .expect("migrated component must satisfy the Draft 2020-12 meta-schema"); + } + + #[test] + fn leaves_unsafe_recursive_scopes_unchanged_and_quarantines_them() { + let cases = [ + ( + "NestedId", + json!({ + "$recursiveAnchor": true, + "properties": { + "child": { "$id": "nested", "$recursiveRef": "#" } + } + }), + "/properties/child/$id", + ), + ( + "AnchorConflict", + json!({ + "$recursiveAnchor": true, + "$anchor": "existing", + "properties": { "child": { "$recursiveRef": "#" } } + }), + "/$anchor", + ), + ( + "NonLocalRef", + json!({ + "$recursiveAnchor": true, + "properties": { + "child": { "$recursiveRef": "#/other" } + } + }), + "/properties/child/$recursiveRef", + ), + ]; + + for (name, source, expected_pointer) in cases { + let normalized = normalize_component_schema(name, &source, Dialect::Draft202012); + assert_eq!(normalized.get("$recursiveAnchor"), Some(&json!(true))); + assert!(normalized.get("$dynamicAnchor").is_none()); + + let components = [(name.to_string(), normalized)] + .into_iter() + .collect::>(); + let quarantine = quarantine_invalid_components(&components, Dialect::Draft202012); + let reason = &quarantine.source_invalid[name]; + assert!(reason.contains("unsafe legacy recursive scope"), "{reason}"); + assert!(reason.contains(expected_pointer), "{reason}"); + } + } + + #[test] + fn synthesizes_and_validates_a_bounded_nested_dynamic_filter() { + let comparison = normalize_component_schema( + "ComparisonFilter", + &json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "value"], + "properties": { + "type": { "type": "string", "enum": ["eq"] }, + "value": { "type": "string" } + } + }), + Dialect::Draft202012, + ); + let compound = normalize_component_schema( + "CompoundFilter", + &recursive_compound_filter_schema(), + Dialect::Draft202012, + ); + let document = json!({ + "$schema": Dialect::Draft202012.schema_uri(), + "$defs": { + "ComparisonFilter": comparison, + "CompoundFilter": compound + } + }); + let validators = validator_options(Dialect::Draft202012) + .build_map(&document) + .expect("recursive validator bundle"); + let validator = validators + .get("#/$defs/CompoundFilter") + .expect("CompoundFilter validator"); + let target = document + .pointer("/$defs/CompoundFilter") + .expect("CompoundFilter schema"); + let generator = SyntheticGenerator::new(&document); + let nested = (0..MAX_ATTEMPTS_PER_SCHEMA) + .filter_map(|seed| generator.generate(target, seed, 0, &mut Vec::new())) + .find(|candidate| { + candidate + .get("filters") + .and_then(Value::as_array) + .is_some_and(|filters| { + filters.iter().any(|filter| filter.get("filters").is_some()) + }) + }) + .expect("a bounded nested CompoundFilter sample"); + assert!( + validator.is_valid(&nested), + "nested sample {nested}; errors: {:?}", + validator + .iter_errors(&nested) + .map(|error| error.to_string()) + .collect::>() + ); + + let source = render_test_source( + &document, + Dialect::Draft202012, + &[ModelCase { + schema_name: "CompoundFilter".to_string(), + rust_type: "CompoundFilter".to_string(), + pointer: "#/$defs/CompoundFilter".to_string(), + samples: vec![nested], + }], + ) + .expect("rendered recursive test"); + assert!(source.contains("$dynamicAnchor")); + assert!(source.contains("$dynamicRef")); + assert!(source.contains("crate::generated::types::CompoundFilter")); + } + + #[test] + fn builds_valid_varied_samples_and_compiled_test_source() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "State": { "type": "string", "enum": ["new", "done"] }, + "Item": { + "type": "object", + "additionalProperties": false, + "required": ["id", "state"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "state": { "$ref": "#/components/schemas/State" }, + "note": { "type": ["string", "null"], "minLength": 1 } + } + } + }} + }); + let dialect = Dialect::from_spec(&spec).expect("dialect"); + let raw = spec + .pointer("/components/schemas") + .and_then(Value::as_object) + .expect("components"); + let normalized = raw + .iter() + .map(|(name, schema)| (name.clone(), normalize_schema(schema, dialect))) + .collect::>(); + let document = json!({ + "$schema": dialect.schema_uri(), + "$defs": Value::Object(normalized), + }); + let validators = validator_options(dialect) + .build_map(&document) + .expect("validators"); + let validator = validators.get("#/$defs/Item").expect("item validator"); + let target = document.pointer("/$defs/Item").expect("item target"); + let candidate = SyntheticGenerator::new(&document) + .generate(target, 0, 0, &mut Vec::new()) + .expect("item candidate"); + assert!( + validator.is_valid(&candidate), + "candidate {candidate}; errors: {:?}", + validator + .iter_errors(&candidate) + .map(|error| error.to_string()) + .collect::>() + ); + + let plan = build_round_trip_plan(&spec, 4).expect("plan"); + assert_eq!(plan.stats.component_schemas, 2); + assert_eq!(plan.stats.tested_schemas, 2, "skipped: {:?}", plan.skipped); + assert!(plan.stats.samples >= 4); + assert!(plan.source.contains("crate::generated::types::Item")); + assert!(plan.source.contains("crate::generated::types::State")); + assert!(plan.source.contains("validator.is_valid(&output)")); + } + + #[test] + fn rendered_test_collects_every_model_failure_before_panicking() { + let document = json!({ + "$schema": Dialect::Draft202012.schema_uri(), + "$defs": { + "First": { "type": "string" }, + "Second": { "type": "integer" } + } + }); + let cases = vec![ + ModelCase { + schema_name: "First".to_string(), + rust_type: "First".to_string(), + pointer: "#/$defs/First".to_string(), + samples: vec![json!("one"), json!("two")], + }, + ModelCase { + schema_name: "Second".to_string(), + rust_type: "Second".to_string(), + pointer: "#/$defs/Second".to_string(), + samples: vec![json!(1), json!(2)], + }, + ]; + + let source = render_test_source(&document, Dialect::Draft202012, &cases) + .expect("rendered test source"); + let model_check = source.split("#[test]").next().expect("model check helper"); + assert!(model_check.contains("fn check_model(")); + assert!(model_check.contains(") -> Vec")); + assert!(model_check.contains("missing validator {pointer}")); + assert!(model_check.contains("invalid embedded samples: {error}")); + assert!(!model_check.contains("panic!(")); + assert!(!model_check.contains("assert!(")); + assert!(!model_check.contains("assert_eq!(")); + + let first_call = source + .find("failures.extend(check_model::") + .expect("first model call"); + let second_call = source + .find("failures.extend(check_model::") + .expect("second model call"); + let aggregate_panic = source + .rfind("if !failures.is_empty()") + .expect("aggregate failure guard"); + assert!(first_call < second_call); + assert!(second_call < aggregate_panic); + assert!(source[aggregate_panic..].contains("failures.join(\"\\n\\n\")")); + assert!(source.contains(".stack_size(64 * 1024 * 1024)")); + assert!(source.contains("std::panic::resume_unwind(payload)")); + assert!(!source.contains("assert_model")); + } + + #[test] + fn quarantines_invalid_type_and_still_plans_independent_components() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "Bad": { "type": "any" }, + "Independent": { "type": "string", "enum": ["ready"] } + }} + }); + + let plan = build_round_trip_plan(&spec, 2).expect("plan independent component"); + assert_eq!(plan.stats.component_schemas, 2); + assert_eq!(plan.stats.tested_schemas, 1, "skipped: {:?}", plan.skipped); + assert_eq!(plan.stats.source_invalid_schemas, 1); + assert_eq!(plan.stats.dependent_schemas, 0); + assert_eq!(plan.stats.synthesis_skipped_schemas, 0); + assert_eq!(plan.stats.skipped_schemas, 1); + assert!(plan.source.contains("crate::generated::types::Independent")); + assert!(!plan.source.contains("crate::generated::types::Bad")); + assert_eq!(plan.skipped[0].schema, "Bad"); + assert!( + plan.skipped[0] + .reason + .contains("#/components/schemas/Bad/type"), + "{}", + plan.skipped[0].reason + ); + assert!(plan.skipped[0].reason.contains("meta-schema path")); + } + + #[test] + fn quarantines_oas30_empty_required_and_one_of_independently() { + let spec = json!({ + "openapi": "3.0.3", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "EmptyOneOf": { "oneOf": [] }, + "EmptyRequired": { "type": "object", "required": [] }, + "Independent": { "type": "integer", "enum": [7] } + }} + }); + + let plan = build_round_trip_plan(&spec, 2).expect("plan independent component"); + assert_eq!(plan.stats.component_schemas, 3); + assert_eq!(plan.stats.tested_schemas, 1, "skipped: {:?}", plan.skipped); + assert_eq!(plan.stats.source_invalid_schemas, 2); + assert_eq!(plan.stats.dependent_schemas, 0); + let reasons = plan + .skipped + .iter() + .map(|skipped| skipped.reason.as_str()) + .collect::>(); + assert!(reasons.iter().any(|reason| reason.contains("/oneOf"))); + assert!(reasons.iter().any(|reason| reason.contains("/required"))); + assert!(plan.source.contains("crate::generated::types::Independent")); + } + + #[test] + fn transitively_quarantines_multi_hop_component_dependents() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "Bad": { "type": "any" }, + "Middle": { "$ref": "#/components/schemas/Bad" }, + "Outer": { + "type": "object", + "required": ["middle"], + "properties": { + "middle": { "$ref": "#/components/schemas/Middle" } + } + }, + "Independent": { "type": "string", "enum": ["valid"] } + }} + }); + + let plan = build_round_trip_plan(&spec, 2).expect("plan independent component"); + assert_eq!(plan.stats.component_schemas, 4); + assert_eq!(plan.stats.tested_schemas, 1, "skipped: {:?}", plan.skipped); + assert_eq!(plan.stats.source_invalid_schemas, 1); + assert_eq!(plan.stats.dependent_schemas, 2); + assert_eq!(plan.stats.synthesis_skipped_schemas, 0); + assert_eq!(plan.stats.skipped_schemas, 3); + let skipped = plan + .skipped + .iter() + .map(|skipped| (skipped.schema.as_str(), skipped.reason.as_str())) + .collect::>(); + assert!(skipped["Middle"].contains("#/components/schemas/Bad")); + assert!(skipped["Outer"].contains("#/components/schemas/Middle")); + assert!(plan.source.contains("crate::generated::types::Independent")); + assert!(!plan.source.contains("crate::generated::types::Middle")); + assert!(!plan.source.contains("crate::generated::types::Outer")); + } + + #[test] + fn serializes_partitioned_round_trip_stats_for_corpus_aggregation() { + let stats = RoundTripStats { + component_schemas: 10, + tested_schemas: 4, + skipped_schemas: 6, + source_invalid_schemas: 1, + dependent_schemas: 2, + synthesis_skipped_schemas: 3, + samples: 16, + }; + assert_eq!( + stats.to_shell(), + concat!( + "component_schemas=10\n", + "tested_schemas=4\n", + "skipped_schemas=6\n", + "source_invalid_schemas=1\n", + "dependent_schemas=2\n", + "synthesis_skipped_schemas=3\n", + "samples=16\n" + ) + ); + } + + #[test] + fn discriminated_synthesis_aligns_mapping_tags_with_branch_shapes() { + let document = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "BroadKind": { "type": "string", "enum": ["alpha", "beta"] }, + "BroadBase": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "$ref": "#/$defs/BroadKind" }, + "id": { "type": "string" } + } + }, + "Alpha": { + "allOf": [ + { "$ref": "#/$defs/BroadBase" }, + { + "type": "object", + "required": ["alpha"], + "properties": { "alpha": { "type": "string" } } + } + ] + }, + "Beta": { + "allOf": [ + { "$ref": "#/$defs/BroadBase" }, + { + "type": "object", + "required": ["beta"], + "properties": { "beta": { "type": "string" } } + } + ] + }, + "Mapped": { + "oneOf": [ + { "$ref": "#/$defs/Alpha" }, + { "$ref": "#/$defs/Beta" } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "alpha": "#/components/schemas/Alpha", + "beta": "#/components/schemas/Beta" + } + } + }, + "RoundRobin": { + "type": "object", + "properties": { + "manager_type": { "type": "string", "const": "round_robin" } + } + }, + "Supervisor": { + "type": "object", + "properties": { + "manager_type": { "type": "string", "const": "supervisor" } + } + }, + "OptionalManager": { + "oneOf": [ + { "$ref": "#/$defs/RoundRobin" }, + { "$ref": "#/$defs/Supervisor" } + ], + "discriminator": { + "propertyName": "manager_type", + "mapping": { + "round_robin": "#/components/schemas/RoundRobin", + "supervisor": "#/components/schemas/Supervisor" + } + } + }, + "StrictAlpha": { + "type": "object", + "required": ["kind", "alpha"], + "properties": { + "kind": { "type": "string", "const": "alpha" }, + "alpha": { "type": "string" } + } + }, + "StrictBeta": { + "type": "object", + "required": ["kind", "beta"], + "properties": { + "kind": { "type": "string", "const": "beta" }, + "beta": { "type": "string" } + } + }, + "ContradictoryMapping": { + "oneOf": [ + { "$ref": "#/$defs/StrictAlpha" }, + { "$ref": "#/$defs/StrictBeta" } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "alpha": "#/components/schemas/StrictBeta", + "beta": "#/components/schemas/StrictAlpha" + } + } + }, + "ClosedBase": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "common"], + "properties": { + "kind": { "type": "string", "enum": ["button", "text", "other"] }, + "common": { "type": "string" } + } + }, + "ImpossibleButton": { + "allOf": [ + { "$ref": "#/$defs/ClosedBase" }, + { + "type": "object", + "additionalProperties": false, + "properties": { "label": { "type": "string" } } + } + ] + }, + "ClosedMapped": { + "oneOf": [ + { "$ref": "#/$defs/ImpossibleButton" }, + { "$ref": "#/$defs/ClosedBase" } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "button": "#/components/schemas/ImpossibleButton", + "text": "#/components/schemas/ClosedBase", + "other": "#/components/schemas/ClosedBase" + } + } + }, + "ImplicitAlpha": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "implicit.alpha" }, + "alpha": { "type": "string" } + } + }, + "ImplicitBeta": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["implicit.beta"] }, + "beta": { "type": "string" } + } + }, + "ImplicitOptional": { + "anyOf": [ + { "$ref": "#/$defs/ImplicitAlpha" }, + { "$ref": "#/$defs/ImplicitBeta" } + ], + "discriminator": { "propertyName": "kind" } + }, + "mcn_string_item": { + "type": "object", + "required": ["item_type", "text"], + "properties": { + "item_type": { "type": "string" }, + "text": { "type": "string" } + } + }, + "mcn_yaml_item": { + "type": "object", + "required": ["item_type", "yaml"], + "properties": { + "item_type": { "type": "string" }, + "yaml": { "type": "string" } + } + }, + "ImplicitNames": { + "anyOf": [ + { "$ref": "#/$defs/mcn_string_item" }, + { "$ref": "#/$defs/mcn_yaml_item" } + ], + "discriminator": { "propertyName": "item_type" } + } + } + }); + let validators = validator_options(Dialect::Draft202012) + .build_map(&document) + .expect("validator map"); + let generator = SyntheticGenerator::with_validators(&document, &validators); + + for schema_name in [ + "Mapped", + "OptionalManager", + "ContradictoryMapping", + "ClosedMapped", + "ImplicitOptional", + "ImplicitNames", + ] { + let schema = &document["$defs"][schema_name]; + let validator = validators + .get(&format!("#/$defs/{schema_name}")) + .expect("component validator"); + for seed in 0..8 { + let candidate = generator + .generate(schema, seed, 0, &mut Vec::new()) + .expect("discriminated candidate"); + assert!( + validator.is_valid(&candidate), + "{schema_name} seed {seed}: {candidate}" + ); + let tag = candidate + .as_object() + .and_then(|object| { + object + .get("kind") + .or_else(|| object.get("manager_type")) + .or_else(|| object.get("item_type")) + }) + .and_then(Value::as_str) + .expect("mapped candidate tag"); + match tag { + "alpha" => assert!(candidate.get("alpha").is_some()), + "beta" => assert!(candidate.get("beta").is_some()), + "round_robin" | "supervisor" => {} + "implicit.alpha" | "implicit.beta" => {} + "mcn_string_item" | "mcn_yaml_item" => {} + "text" | "other" => { + assert!(candidate.get("common").is_some()); + } + other => panic!("unexpected tag {other}"), + } + } + } + } + + #[test] + fn simple_union_candidates_validate_against_the_selected_ref_branch() { + let document = json!({ + "$schema": Dialect::Draft202012.schema_uri(), + "$defs": { + "Base": { + "type": "object", + "properties": { "user": { "type": "string" } } + }, + "Composed": { + "allOf": [ + { "$ref": "#/$defs/Base" }, + { "type": "object", "required": ["user"] } + ] + }, + "OpenFallback": { "type": "object" }, + "Choice": { + "anyOf": [ + { "$ref": "#/$defs/Composed" }, + { "$ref": "#/$defs/OpenFallback" } + ] + } + } + }); + let validators = validator_options(Dialect::Draft202012) + .build_map(&document) + .expect("validator map"); + let generator = SyntheticGenerator::with_validators(&document, &validators); + let branches = document["$defs"]["Choice"]["anyOf"] + .as_array() + .expect("anyOf branches"); + + // This seed makes the allOf synthesizer produce `user: false`: valid + // through the open fallback, but invalid for the referenced branch + // that produced it. The union generator must reject that incidental + // cross-branch match and move to a branch-valid candidate. + let candidate = generator + .generate_branch(branches, 6, 0, &mut Vec::new()) + .expect("branch-valid fallback candidate"); + assert_ne!(candidate.get("user"), Some(&Value::Bool(false))); + assert!( + validators["#/$defs/OpenFallback"].is_valid(&candidate) + || validators["#/$defs/Composed"].is_valid(&candidate), + "candidate must fit the branch that generated it: {candidate}" + ); + } + + #[test] + fn coda_column_format_synthesis_avoids_uninhabitable_mapped_branches() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("specs/coda.yaml"); + let body = std::fs::read_to_string(&path).expect("Coda fixture"); + let spec = crate::spec_source::parse_spec(&body, "specs/coda.yaml").expect("Coda spec"); + let dialect = Dialect::from_spec(&spec).expect("Coda dialect"); + let components = spec + .pointer("/components/schemas") + .and_then(Value::as_object) + .expect("Coda components"); + let normalized = components + .iter() + .map(|(name, schema)| { + ( + name.clone(), + normalize_component_schema(name, schema, dialect), + ) + }) + .collect::>(); + let document = json!({ + "$schema": dialect.schema_uri(), + dialect.definitions_key(): Value::Object(normalized), + }); + let validators = validator_options(dialect) + .build_map(&document) + .expect("Coda validator map"); + let generator = SyntheticGenerator::with_validators(&document, &validators); + let pointer = format!("#/{}/ColumnFormat", dialect.definitions_key()); + let schema = document + .pointer(pointer.trim_start_matches('#')) + .expect("ColumnFormat schema"); + let validator = validators.get(&pointer).expect("ColumnFormat validator"); + + for seed in 0..32 { + let candidate = generator + .generate(schema, seed, 0, &mut Vec::new()) + .expect("ColumnFormat candidate"); + assert!(validator.is_valid(&candidate), "seed {seed}: {candidate}"); + let tag = candidate["type"].as_str().expect("ColumnFormat tag"); + assert_ne!(tag, "checkbox", "seed {seed}: {candidate}"); + assert_ne!(tag, "button", "seed {seed}: {candidate}"); + } + } + + #[test] + fn classifies_false_schema_as_uninhabited() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { "Never": false } } + }); + let plan = build_round_trip_plan(&spec, 2).expect("plan"); + assert_eq!(plan.stats.tested_schemas, 0); + assert_eq!(plan.stats.skipped_schemas, 1); + assert_eq!(plan.stats.synthesis_skipped_schemas, 1); + assert!(plan.skipped[0].reason.contains("uninhabited")); + } + + #[test] + fn uses_emitted_names_for_colliding_component_keys() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "round trip", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "ItemStatus": { "type": "string", "enum": ["ready"] }, + "item.status": { "type": "string", "enum": ["waiting"] } + }} + }); + + let plan = build_round_trip_plan(&spec, 2).expect("plan"); + assert_eq!(plan.stats.tested_schemas, 2, "skipped: {:?}", plan.skipped); + assert!(plan.source.contains("crate::generated::types::ItemStatus")); + assert!(plan.source.contains("crate::generated::types::ItemStatus2")); + } +} diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 6517ec9..4c47f3f 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -184,12 +184,13 @@ fn collect_schema_type_refs( seed(&variant.type_name, queue, keep); } } - SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + SchemaType::Union { variants, .. } | SchemaType::Composition { schemas: variants } => { for variant in variants { seed(&variant.target, queue, keep); } } SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep), + SchemaType::Nullable { inner_type } => collect_schema_type_refs(inner_type, queue, keep), SchemaType::Tuple { element_types } => { for element_type in element_types { collect_schema_type_refs(element_type, queue, keep); diff --git a/src/server/validation.rs b/src/server/validation.rs index 2f1c1f0..eb8a617 100644 --- a/src/server/validation.rs +++ b/src/server/validation.rs @@ -315,6 +315,13 @@ fn normalize_schema(value: &Value, draft: ValidationDraft) -> Value { } } } + // Match the analyzer's long-standing OpenAPI object inference. Under pure + // JSON Schema, `properties` alone also admits arrays and scalars; a Rust + // struct cannot represent those values. Promoting the implicit object type + // here keeps generated request validation and model hydration aligned. + if !schema.contains_key("type") && schema.contains_key("properties") { + schema.insert("type".to_string(), Value::String("object".to_string())); + } // AWS-authored specs widely carry the constraint as `x-pattern` (an // OpenAPI extension) rather than the JSON Schema `pattern` keyword. The // embedded validator compiles a pure JSON Schema document, where unknown @@ -568,14 +575,23 @@ fn rewrite_references( Value::Object(map) => { if let Some(Value::String(reference)) = map.get_mut("$ref") { if let Some(token) = reference.strip_prefix("#/components/schemas/") { - let name = unescape_pointer_token(token); + // Only the first pointer token names the component. Keep a + // deeper suffix verbatim so `Tag/allOf/0` still targets + // that member after the component root is embedded. + let (component_token, suffix) = token.split_once('/').unwrap_or((token, "")); + let name = unescape_pointer_token(component_token); if queued_components.insert(name.clone()) { component_queue.push_back(name.clone()); } - *reference = format!( + let mut rewritten = format!( "{BUNDLE_ID}#/{definitions_key}/components/{}", escape_pointer_token(&component_bundle_key(&name)) ); + if !suffix.is_empty() { + rewritten.push('/'); + rewritten.push_str(suffix); + } + *reference = rewritten; } else if reference.starts_with(BUNDLE_ID) || !reference.starts_with('#') { return Err(ValidationPreparationError::UnsupportedReference { context: context.to_string(), @@ -1320,6 +1336,27 @@ mod tests { assert!(normalized["anyOf"][0].get("nullable").is_none()); } + #[test] + fn properties_without_type_use_the_same_object_normalization_as_models() { + let normalized = normalize_schema( + &json!({ + "properties": { + "status": {"type": "string"}, + "nested": {"properties": {"id": {"type": "integer"}}} + } + }), + ValidationDraft::Draft202012, + ); + assert_eq!(normalized["type"], "object"); + assert_eq!(normalized["properties"]["nested"]["type"], "object"); + + let explicit = normalize_schema( + &json!({"type": "array", "properties": {"ignored": {"type": "string"}}}), + ValidationDraft::Draft202012, + ); + assert_eq!(explicit["type"], "array"); + } + #[test] fn local_refs_are_embedded_and_external_refs_are_rejected() { let context = ValidationContext { @@ -1375,6 +1412,59 @@ mod tests { )); } + #[test] + fn deep_component_ref_keeps_its_suffix_in_the_validation_bundle() + -> Result<(), Box> { + let context = ValidationContext { + openapi_version: "3.1.0".to_string(), + component_schemas: BTreeMap::from([( + "Tag/Kind".to_string(), + json!({"allOf": [ + {"type": "string", "minLength": 3}, + {"type": "string", "maxLength": 12} + ]}), + )]), + ..Default::default() + }; + let operation = OperationInfo { + operation_id: "deepTag".to_string(), + parameters: vec![ParameterInfo { + name: "tag".to_string(), + location: "query".to_string(), + required: true, + schema_ref: None, + rust_type: "String".to_string(), + description: None, + enum_values: None, + enum_varnames: None, + rust_ident: None, + query_serialization: None, + validation_schema: Some(json!({ + "$ref": "#/components/schemas/Tag~1Kind/allOf/0" + })), + }], + ..Default::default() + }; + + let bundle = prepare_validation_bundle(&context, &[&operation])?; + let document: Value = serde_json::from_str(&bundle.document_json)?; + let target = bundle + .target_for("deepTag", "query", Some("tag")) + .ok_or_else(|| std::io::Error::other("missing validation target"))?; + let rewritten = document + .pointer(target.pointer.trim_start_matches('#')) + .ok_or_else(|| std::io::Error::other("missing exported target schema"))?; + assert_eq!( + rewritten["$ref"], + format!("{BUNDLE_ID}#/$defs/components/component_Tag~1Kind/allOf/0") + ); + let embedded = document + .pointer("/$defs/components/component_Tag~1Kind/allOf/0") + .ok_or_else(|| std::io::Error::other("missing embedded deep component target"))?; + assert_eq!(embedded["minLength"], 3,); + Ok(()) + } + #[test] fn reference_like_data_is_not_rewritten() { let context = ValidationContext { diff --git a/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap b/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap index a500811..fc38e8c 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap @@ -13,10 +13,7 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Mixed { - #[serde(skip_serializing_if = "Option::is_none")] pub any_of: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub legacy: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub type_array: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__allof_wrapper_in_oneof.snap b/src/snapshots/openapi_to_rust__test_helpers__allof_wrapper_in_oneof.snap index 2976f54..04de26a 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__allof_wrapper_in_oneof.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__allof_wrapper_in_oneof.snap @@ -11,19 +11,356 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ContentBlock { - #[serde(rename = "text")] TextBlock(TextBlock), - #[serde(rename = "image")] ImageBlock(ImageBlock), - #[serde(rename = "document")] DocumentBlock(DocumentBlock), } +impl serde::Serialize for ContentBlock { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::TextBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(TextBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(TextBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::ImageBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(ImageBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "image") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ImageBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("image".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::DocumentBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(DocumentBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "document") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(DocumentBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("document".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ContentBlock { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text" => { + let primary_error = match serde_json::from_value::< + TextBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::TextBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ImageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(ImageBlock), + ), + ), + ); + } + structural_match = Some(( + Self::ImageBlock(payload), + stringify!(ImageBlock), + )); + } + if let Ok(payload) = serde_json::from_value::< + DocumentBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(DocumentBlock), + ), + ), + ); + } + structural_match = Some(( + Self::DocumentBlock(payload), + stringify!(DocumentBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "image" => { + let primary_error = match serde_json::from_value::< + ImageBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::ImageBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + TextBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "image", first_name, stringify!(TextBlock), + ), + ), + ); + } + structural_match = Some(( + Self::TextBlock(payload), + stringify!(TextBlock), + )); + } + if let Ok(payload) = serde_json::from_value::< + DocumentBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "image", first_name, stringify!(DocumentBlock), + ), + ), + ); + } + structural_match = Some(( + Self::DocumentBlock(payload), + stringify!(DocumentBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "document" => { + let primary_error = match serde_json::from_value::< + DocumentBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::DocumentBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + TextBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "document", first_name, stringify!(TextBlock), + ), + ), + ); + } + structural_match = Some(( + Self::TextBlock(payload), + stringify!(TextBlock), + )); + } + if let Ok(payload) = serde_json::from_value::< + ImageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "document", first_name, stringify!(ImageBlock), + ), + ), + ); + } + structural_match = Some(( + Self::ImageBlock(payload), + stringify!(ImageBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TextBlock { pub text: String, + pub r#type: TextBlockType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum TextBlockType { @@ -50,6 +387,7 @@ impl AsRef for TextBlockType { } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ImageBlock { + pub r#type: ImageBlockType, pub url: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -78,6 +416,7 @@ impl AsRef for ImageBlockType { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DocumentBlock { pub content: String, + pub r#type: DocumentBlockType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum DocumentBlockType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap b/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap index 9ea94be..f9d4c3b 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap @@ -16,17 +16,17 @@ pub struct Outer { #[serde(skip_serializing_if = "Option::is_none")] pub nested: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, + pub tags: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] -pub enum OuterTagsItemRED { +pub enum OuterTagsItem { #[default] #[serde(rename = "RED")] Red, #[serde(rename = "BLUE")] Blue, } -impl OuterTagsItemRED { +impl OuterTagsItem { pub fn as_str(&self) -> &'static str { match self { Self::Red => "RED", @@ -34,12 +34,12 @@ impl OuterTagsItemRED { } } } -impl ::std::fmt::Display for OuterTagsItemRED { +impl ::std::fmt::Display for OuterTagsItem { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { f.write_str(self.as_str()) } } -impl AsRef for OuterTagsItemRED { +impl AsRef for OuterTagsItem { fn as_ref(&self) -> &str { self.as_str() } @@ -47,17 +47,17 @@ impl AsRef for OuterTagsItemRED { #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct OuterNested { #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, + pub tags: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] -pub enum OuterTagsItem { +pub enum OuterNestedTagsItem { #[default] #[serde(rename = "HOT")] Hot, #[serde(rename = "COLD")] Cold, } -impl OuterTagsItem { +impl OuterNestedTagsItem { pub fn as_str(&self) -> &'static str { match self { Self::Hot => "HOT", @@ -65,12 +65,12 @@ impl OuterTagsItem { } } } -impl ::std::fmt::Display for OuterTagsItem { +impl ::std::fmt::Display for OuterNestedTagsItem { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { f.write_str(self.as_str()) } } -impl AsRef for OuterTagsItem { +impl AsRef for OuterNestedTagsItem { fn as_ref(&self) -> &str { self.as_str() } diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap b/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap index 2c34df3..b1e7ac4 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap @@ -15,17 +15,223 @@ use serde::{Deserialize, Serialize}; pub struct ToolsRequest { pub tools: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ToolsRequestToolsItemUnion { - #[serde(rename = "text")] TextTool(TextTool), - #[serde(rename = "code")] CodeTool(CodeTool), } +impl serde::Serialize for ToolsRequestToolsItemUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::TextTool(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(TextTool), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(TextTool), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::CodeTool(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(CodeTool), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "code") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(CodeTool), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("code".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ToolsRequestToolsItemUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text" => { + let primary_error = match serde_json::from_value::< + TextTool, + >(value.clone()) { + Ok(payload) => return Ok(Self::TextTool(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + CodeTool, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(CodeTool), + ), + ), + ); + } + structural_match = Some(( + Self::CodeTool(payload), + stringify!(CodeTool), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "code" => { + let primary_error = match serde_json::from_value::< + CodeTool, + >(value.clone()) { + Ok(payload) => return Ok(Self::CodeTool(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + TextTool, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "code", first_name, stringify!(TextTool), + ), + ), + ); + } + structural_match = Some(( + Self::TextTool(payload), + stringify!(TextTool), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TextTool { pub name: String, + pub r#type: TextToolType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum TextToolType { @@ -53,6 +259,7 @@ impl AsRef for TextToolType { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CodeTool { pub language: String, + pub r#type: CodeToolType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum CodeToolType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__array_union_test.snap index 65640a4..71a6493 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__array_union_test.snap @@ -12,21 +12,228 @@ expression: "&generated_code" #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; pub type ToolList = Vec; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum Tool { - #[serde(rename = "function")] FunctionTool(FunctionTool), - #[serde(rename = "retrieval")] RetrievalTool(RetrievalTool), } +impl serde::Serialize for Tool { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::FunctionTool(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(FunctionTool), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "function") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(FunctionTool), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("function".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::RetrievalTool(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(RetrievalTool), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "retrieval") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RetrievalTool), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("retrieval".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for Tool { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "function" => { + let primary_error = match serde_json::from_value::< + FunctionTool, + >(value.clone()) { + Ok(payload) => return Ok(Self::FunctionTool(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RetrievalTool, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "function", first_name, stringify!(RetrievalTool), + ), + ), + ); + } + structural_match = Some(( + Self::RetrievalTool(payload), + stringify!(RetrievalTool), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "retrieval" => { + let primary_error = match serde_json::from_value::< + RetrievalTool, + >(value.clone()) { + Ok(payload) => return Ok(Self::RetrievalTool(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + FunctionTool, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "retrieval", first_name, stringify!(FunctionTool), + ), + ), + ); + } + structural_match = Some(( + Self::FunctionTool(payload), + stringify!(FunctionTool), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RetrievalTool { pub query: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FunctionTool { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub name: String, + pub r#type: serde_json::Value, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap index fb3d9c1..0df04ee 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap @@ -24,12 +24,109 @@ pub struct Message { pub content: String, pub role: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] +#[derive(Debug, Clone)] pub enum BetaCreateMessageParamsToolsItemUnion { BetaTool(BetaTool), BetaComputerUseTool(BetaComputerUseTool), } +impl Serialize for BetaCreateMessageParamsToolsItemUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::BetaTool(value) => serde::Serialize::serialize(value, serializer), + Self::BetaComputerUseTool(value) => { + serde::Serialize::serialize(value, serializer) + } + } + } +} +impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = ::deserialize(deserializer)?; + let mut matched = None; + if input + .as_object() + .is_some_and(|object| { + true + && object + .get("type") + .is_none_or(|value| { + value.is_null() + || matches!(value.to_string().as_str(), "\"custom\"") + }) + }) + { + if let Ok(candidate) = serde_json::from_value::(input.clone()) { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| encoded == input) + .unwrap_or(false); + if preserves_complete_input { + if matched.is_some() { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(BetaCreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), + ), + ); + } + matched = Some(Self::BetaTool(candidate)); + } + } + } + if input + .as_object() + .is_some_and(|object| { + true + && object + .get("type") + .is_none_or(|value| { + value.is_null() + || matches!( + value.to_string().as_str(), "\"computer_20241022\"" + ) + }) + }) + { + if let Ok(candidate) = serde_json::from_value::< + BetaComputerUseTool, + >(input.clone()) { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| encoded == input) + .unwrap_or(false); + if preserves_complete_input { + if matched.is_some() { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(BetaCreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), + ), + ); + } + matched = Some(Self::BetaComputerUseTool(candidate)); + } + } + } + matched + .ok_or_else(|| serde::de::Error::custom( + concat!( + "no oneOf branch for ", + stringify!(BetaCreateMessageParamsToolsItemUnion), + " preserved the complete input", + ), + )) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BetaTool { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/snapshots/openapi_to_rust__test_helpers__closed_empty_object_test.snap b/src/snapshots/openapi_to_rust__test_helpers__closed_empty_object_test.snap new file mode 100644 index 0000000..657083c --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__closed_empty_object_test.snap @@ -0,0 +1,15 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ClosedEmpty {} diff --git a/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap b/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap index 540fbd5..5ee40c4 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap @@ -11,12 +11,29 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct BetaListResponseMessageBatch { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_id: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub last_id: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageBatch { diff --git a/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap b/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap index a7de80e..389ad0f 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap @@ -12,4 +12,8 @@ expression: "&generated_code" #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ConstrainedObject {} +pub struct ConstrainedObject { + /// Additional properties not explicitly defined in the schema + #[serde(flatten)] + pub additional_properties: std::collections::BTreeMap, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__content_block_delta_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__content_block_delta_union_test.snap index e41a674..956d34c 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__content_block_delta_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__content_block_delta_union_test.snap @@ -11,19 +11,228 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ContentBlockDelta { - #[serde(rename = "text_delta")] TextDelta(TextDelta), - #[serde(rename = "input_json_delta")] InputJsonDelta(InputJsonDelta), } +impl serde::Serialize for ContentBlockDelta { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::TextDelta(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(TextDelta), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text_delta") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(TextDelta), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text_delta".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::InputJsonDelta(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(InputJsonDelta), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "input_json_delta") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(InputJsonDelta), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("input_json_delta".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ContentBlockDelta { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text_delta" => { + let primary_error = match serde_json::from_value::< + TextDelta, + >(value.clone()) { + Ok(payload) => return Ok(Self::TextDelta(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + InputJsonDelta, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text_delta", first_name, + stringify!(InputJsonDelta), + ), + ), + ); + } + structural_match = Some(( + Self::InputJsonDelta(payload), + stringify!(InputJsonDelta), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "input_json_delta" => { + let primary_error = match serde_json::from_value::< + InputJsonDelta, + >(value.clone()) { + Ok(payload) => return Ok(Self::InputJsonDelta(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + TextDelta, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "input_json_delta", first_name, + stringify!(TextDelta), + ), + ), + ); + } + structural_match = Some(( + Self::TextDelta(payload), + stringify!(TextDelta), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TextDelta { pub text: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct InputJsonDelta { pub partial_json: String, + pub r#type: serde_json::Value, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap b/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap index 40e2b4e..fe6b26e 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap @@ -11,6 +11,19 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} ///Represents a streamed chunk of a chat completion response #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreateChatCompletionStreamResponse { @@ -53,25 +66,26 @@ impl AsRef for CreateChatCompletionStreamResponseObject { pub struct CreateChatCompletionStreamResponseChoicesItem { pub delta: ChatCompletionStreamResponseDelta, ///The reason the model stopped generating tokens - #[serde(skip_serializing_if = "Option::is_none")] - pub finish_reason: Option, + pub finish_reason: Option, ///The index of the choice in the list of choices pub index: i64, ///Log probability information for the choice - #[serde(skip_serializing_if = "Option::is_none")] - pub logprobs: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub logprobs: Option>, } ///Log probability information for the choice #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateChatCompletionStreamResponseLogprobs { - #[serde(skip_serializing_if = "Option::is_none")] +pub struct CreateChatCompletionStreamResponseChoicesItemLogprobs { pub content: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub refusal: Option>, } ///The reason the model stopped generating tokens #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] -pub enum CreateChatCompletionStreamResponseFinishReason { +pub enum CreateChatCompletionStreamResponseChoicesItemFinishReason { #[default] #[serde(rename = "stop")] Stop, @@ -84,7 +98,7 @@ pub enum CreateChatCompletionStreamResponseFinishReason { #[serde(rename = "function_call")] FunctionCall, } -impl CreateChatCompletionStreamResponseFinishReason { +impl CreateChatCompletionStreamResponseChoicesItemFinishReason { pub fn as_str(&self) -> &'static str { match self { Self::Stop => "stop", @@ -95,27 +109,30 @@ impl CreateChatCompletionStreamResponseFinishReason { } } } -impl ::std::fmt::Display for CreateChatCompletionStreamResponseFinishReason { +impl ::std::fmt::Display for CreateChatCompletionStreamResponseChoicesItemFinishReason { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { f.write_str(self.as_str()) } } -impl AsRef for CreateChatCompletionStreamResponseFinishReason { +impl AsRef for CreateChatCompletionStreamResponseChoicesItemFinishReason { fn as_ref(&self) -> &str { self.as_str() } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ChatCompletionTokenLogprob { - #[serde(skip_serializing_if = "Option::is_none")] pub bytes: Option>, pub logprob: f64, pub token: String, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChatCompletionStreamResponseDelta { - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub content: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub role: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__debug_empty_object.snap b/src/snapshots/openapi_to_rust__test_helpers__debug_empty_object.snap index 164ea9b..6e71349 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__debug_empty_object.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__debug_empty_object.snap @@ -13,6 +13,5 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct InputSchema { - #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap b/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap index 716d256..e325279 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap @@ -16,26 +16,439 @@ pub struct ToolResult { pub caller: Option, pub content: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ToolResultCaller { - #[serde(rename = "direct")] DirectCaller(DirectCaller), - #[serde(rename = "server")] ServerCaller(ServerCaller), } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +impl serde::Serialize for ToolResultCaller { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::DirectCaller(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(DirectCaller), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "direct") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(DirectCaller), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("direct".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::ServerCaller(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(ServerCaller), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "server") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ServerCaller), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("server".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ToolResultCaller { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "direct" => { + let primary_error = match serde_json::from_value::< + DirectCaller, + >(value.clone()) { + Ok(payload) => return Ok(Self::DirectCaller(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ServerCaller, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "direct", first_name, stringify!(ServerCaller), + ), + ), + ); + } + structural_match = Some(( + Self::ServerCaller(payload), + stringify!(ServerCaller), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "server" => { + let primary_error = match serde_json::from_value::< + ServerCaller, + >(value.clone()) { + Ok(payload) => return Ok(Self::ServerCaller(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + DirectCaller, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "server", first_name, stringify!(DirectCaller), + ), + ), + ); + } + structural_match = Some(( + Self::DirectCaller(payload), + stringify!(DirectCaller), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} +#[derive(Debug, Clone)] pub enum CallerType { - #[serde(rename = "direct")] DirectCaller(DirectCaller), - #[serde(rename = "server")] ServerCaller(ServerCaller), } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl serde::Serialize for CallerType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::DirectCaller(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(DirectCaller), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "direct") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(DirectCaller), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("direct".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::ServerCaller(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(ServerCaller), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "server") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ServerCaller), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("server".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for CallerType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "direct" => { + let primary_error = match serde_json::from_value::< + DirectCaller, + >(value.clone()) { + Ok(payload) => return Ok(Self::DirectCaller(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ServerCaller, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "direct", first_name, stringify!(ServerCaller), + ), + ), + ); + } + structural_match = Some(( + Self::ServerCaller(payload), + stringify!(ServerCaller), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "server" => { + let primary_error = match serde_json::from_value::< + ServerCaller, + >(value.clone()) { + Ok(payload) => return Ok(Self::ServerCaller(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + DirectCaller, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "server", first_name, stringify!(DirectCaller), + ), + ), + ); + } + structural_match = Some(( + Self::DirectCaller(payload), + stringify!(DirectCaller), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerCaller { + pub r#type: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DirectCaller {} +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DirectCaller { + pub r#type: serde_json::Value, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap index 660f52d..5353847 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap @@ -15,17 +15,261 @@ use serde::{Deserialize, Serialize}; pub struct DataBlock { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageBlock { #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum Response { - #[serde(rename = "success")] Success(DataBlock), - #[serde(rename = "error")] Error(MessageBlock), } +impl serde::Serialize for Response { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Success(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Success), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "success") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Success), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("success".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::Error(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Error), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "error") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Error), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("error".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for Response { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "success" => { + let primary_error = match serde_json::from_value::< + DataBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::Success(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + MessageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "success", first_name, stringify!(Error), + ), + ), + ); + } + structural_match = Some(( + Self::Error(payload), + stringify!(Error), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "error" => { + let primary_error = match serde_json::from_value::< + MessageBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::Error(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + DataBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "error", first_name, stringify!(Success), + ), + ), + ); + } + structural_match = Some(( + Self::Success(payload), + stringify!(Success), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "missing discriminator `{}` structurally matched both `{}` and `{}`", + "type", first_name, stringify!(Success), + ), + ), + ); + } + structural_match = Some(( + Self::Success(payload), + stringify!(Success), + )); + } + if let Ok(payload) = serde_json::from_value::< + MessageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "missing discriminator `{}` structurally matched both `{}` and `{}`", + "type", first_name, stringify!(Error), + ), + ), + ); + } + structural_match = Some((Self::Error(payload), stringify!(Error))); + } + structural_match + .map(|(payload, _)| payload) + .ok_or_else(|| serde::de::Error::custom( + concat!( + "missing string discriminator `", "type", + "` and no tagless branch matched", + ), + )) + } + } + } +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_test.snap index b5072a8..dc01426 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_test.snap @@ -11,19 +11,357 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum MessageResult { - #[serde(rename = "succeeded")] SucceededResult(SucceededResult), - #[serde(rename = "errored")] ErroredResult(ErroredResult), - #[serde(rename = "canceled")] CanceledResult(CanceledResult), } +impl serde::Serialize for MessageResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::SucceededResult(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(SucceededResult), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "succeeded") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(SucceededResult), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("succeeded".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::ErroredResult(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(ErroredResult), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "errored") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ErroredResult), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("errored".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::CanceledResult(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(CanceledResult), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "canceled") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(CanceledResult), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("canceled".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for MessageResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "succeeded" => { + let primary_error = match serde_json::from_value::< + SucceededResult, + >(value.clone()) { + Ok(payload) => return Ok(Self::SucceededResult(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ErroredResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "succeeded", first_name, stringify!(ErroredResult), + ), + ), + ); + } + structural_match = Some(( + Self::ErroredResult(payload), + stringify!(ErroredResult), + )); + } + if let Ok(payload) = serde_json::from_value::< + CanceledResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "succeeded", first_name, stringify!(CanceledResult), + ), + ), + ); + } + structural_match = Some(( + Self::CanceledResult(payload), + stringify!(CanceledResult), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "errored" => { + let primary_error = match serde_json::from_value::< + ErroredResult, + >(value.clone()) { + Ok(payload) => return Ok(Self::ErroredResult(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + SucceededResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "errored", first_name, stringify!(SucceededResult), + ), + ), + ); + } + structural_match = Some(( + Self::SucceededResult(payload), + stringify!(SucceededResult), + )); + } + if let Ok(payload) = serde_json::from_value::< + CanceledResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "errored", first_name, stringify!(CanceledResult), + ), + ), + ); + } + structural_match = Some(( + Self::CanceledResult(payload), + stringify!(CanceledResult), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "canceled" => { + let primary_error = match serde_json::from_value::< + CanceledResult, + >(value.clone()) { + Ok(payload) => return Ok(Self::CanceledResult(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + SucceededResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "canceled", first_name, stringify!(SucceededResult), + ), + ), + ); + } + structural_match = Some(( + Self::SucceededResult(payload), + stringify!(SucceededResult), + )); + } + if let Ok(payload) = serde_json::from_value::< + ErroredResult, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "canceled", first_name, stringify!(ErroredResult), + ), + ), + ); + } + structural_match = Some(( + Self::ErroredResult(payload), + stringify!(ErroredResult), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SucceededResult { pub message: Message, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Message { @@ -33,8 +371,10 @@ pub struct Message { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ErroredResult { pub error: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CanceledResult { pub reason: String, + pub r#type: serde_json::Value, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap index 3eafa93..d4df46e 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap @@ -17,19 +17,227 @@ pub struct CreateMessageParams { #[serde(skip_serializing_if = "Option::is_none")] pub system: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum InputContentBlock { - #[serde(rename = "text")] RequestTextBlock(RequestTextBlock), - #[serde(rename = "image")] RequestImageBlock(RequestImageBlock), } +impl serde::Serialize for InputContentBlock { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::RequestTextBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(RequestTextBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RequestTextBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::RequestImageBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(RequestImageBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "image") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RequestImageBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("image".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for InputContentBlock { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text" => { + let primary_error = match serde_json::from_value::< + RequestTextBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::RequestTextBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RequestImageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(RequestImageBlock), + ), + ), + ); + } + structural_match = Some(( + Self::RequestImageBlock(payload), + stringify!(RequestImageBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "image" => { + let primary_error = match serde_json::from_value::< + RequestImageBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::RequestImageBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RequestTextBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "image", first_name, stringify!(RequestTextBlock), + ), + ), + ); + } + structural_match = Some(( + Self::RequestTextBlock(payload), + stringify!(RequestTextBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RequestTextBlock { #[serde(skip_serializing_if = "Option::is_none")] pub cache_control: Option, pub text: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RequestTextBlockCacheControl { @@ -41,6 +249,7 @@ pub struct RequestTextBlockCacheControl { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RequestImageBlock { pub source: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] @@ -48,14 +257,5 @@ pub enum CreateMessageParamsSystem { String(String), RequestTextBlockArray(RequestTextBlockArray), } -/// Wrapper enum that re-adds the discriminator tag -/// for array contexts where the inner struct had its -/// discriminator field stripped for tagged enum use. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] -pub enum RequestTextBlockArrayItem { - #[serde(rename = "text")] - RequestTextBlock(RequestTextBlock), -} ///Array variant in union -pub type RequestTextBlockArray = Vec; +pub type RequestTextBlockArray = Vec; diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminator_no_mapping.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminator_no_mapping.snap index d4b9d61..e6f994e 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminator_no_mapping.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminator_no_mapping.snap @@ -11,17 +11,223 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "species")] +#[derive(Debug, Clone)] pub enum Animal { - #[serde(rename = "cat")] Cat(Cat), - #[serde(rename = "dog")] Dog(Dog), } +impl serde::Serialize for Animal { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Cat(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Cat), + "` did not serialize as an object", + ), + ) + })?; + match object.get("species") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "cat") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "species", stringify!(Cat), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "species", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "species".to_string(), + serde_json::Value::String("cat".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::Dog(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Dog), + "` did not serialize as an object", + ), + ) + })?; + match object.get("species") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "dog") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "species", stringify!(Dog), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "species", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "species".to_string(), + serde_json::Value::String("dog".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for Animal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("species") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "species", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "cat" => { + let primary_error = match serde_json::from_value::< + Cat, + >(value.clone()) { + Ok(payload) => return Ok(Self::Cat(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + Dog, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "species", "cat", first_name, stringify!(Dog), + ), + ), + ); + } + structural_match = Some(( + Self::Dog(payload), + stringify!(Dog), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "dog" => { + let primary_error = match serde_json::from_value::< + Dog, + >(value.clone()) { + Ok(payload) => return Ok(Self::Dog(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + Cat, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "species", "dog", first_name, stringify!(Cat), + ), + ), + ); + } + structural_match = Some(( + Self::Cat(payload), + stringify!(Cat), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "species", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "species", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Dog { pub bark: bool, + pub species: DogSpecies, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum DogSpecies { @@ -49,6 +255,7 @@ impl AsRef for DogSpecies { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Cat { pub meow: bool, + pub species: CatSpecies, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum CatSpecies { diff --git a/src/snapshots/openapi_to_rust__test_helpers__duplicate_discriminator_values.snap b/src/snapshots/openapi_to_rust__test_helpers__duplicate_discriminator_values.snap index ef67193..aa4ee13 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__duplicate_discriminator_values.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__duplicate_discriminator_values.snap @@ -11,16 +11,222 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum Content { - #[serde(rename = "text")] SimpleText(SimpleText), - #[serde(rename = "rich_text")] RichText(RichText), } +impl serde::Serialize for Content { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::SimpleText(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(SimpleText), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(SimpleText), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::RichText(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(RichText), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RichText), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for Content { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text" => { + let primary_error = match serde_json::from_value::< + SimpleText, + >(value.clone()) { + Ok(payload) => return Ok(Self::SimpleText(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RichText, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(RichText), + ), + ), + ); + } + structural_match = Some(( + Self::RichText(payload), + stringify!(RichText), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "text" => { + let primary_error = match serde_json::from_value::< + RichText, + >(value.clone()) { + Ok(payload) => return Ok(Self::RichText(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + SimpleText, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(SimpleText), + ), + ), + ); + } + structural_match = Some(( + Self::SimpleText(payload), + stringify!(SimpleText), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SimpleText { + pub r#type: SimpleTextType, pub value: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -51,6 +257,7 @@ pub struct RichText { pub html: String, #[serde(skip_serializing_if = "Option::is_none")] pub markdown: Option, + pub r#type: RichTextType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum RichTextType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__empty_object_json_value_test.snap b/src/snapshots/openapi_to_rust__test_helpers__empty_object_json_value_test.snap index 164ea9b..6e71349 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__empty_object_json_value_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__empty_object_json_value_test.snap @@ -13,6 +13,5 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct InputSchema { - #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__error_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__error_union_test.snap index c19ae4d..6675862 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__error_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__error_union_test.snap @@ -11,21 +11,231 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum StreamError { - #[serde(rename = "api_error")] ApiError(ApiError), - #[serde(rename = "validation_error")] ValidationError(ValidationError), } +impl serde::Serialize for StreamError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::ApiError(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(ApiError), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "api_error") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ApiError), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("api_error".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::ValidationError(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(ValidationError), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "validation_error") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ValidationError), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("validation_error".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for StreamError { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "api_error" => { + let primary_error = match serde_json::from_value::< + ApiError, + >(value.clone()) { + Ok(payload) => return Ok(Self::ApiError(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ValidationError, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "api_error", first_name, + stringify!(ValidationError), + ), + ), + ); + } + structural_match = Some(( + Self::ValidationError(payload), + stringify!(ValidationError), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "validation_error" => { + let primary_error = match serde_json::from_value::< + ValidationError, + >(value.clone()) { + Ok(payload) => return Ok(Self::ValidationError(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ApiError, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "validation_error", first_name, + stringify!(ApiError), + ), + ), + ); + } + structural_match = Some(( + Self::ApiError(payload), + stringify!(ApiError), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ValidationError { pub field: String, pub reason: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApiError { pub code: i64, pub message: String, + pub r#type: serde_json::Value, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap index e0e68e8..45ee376 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap @@ -18,27 +18,27 @@ pub struct PlanData { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, + pub r#type: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] -pub enum PlanDataTypePlans { +pub enum PlanDataType { #[default] #[serde(rename = "plans")] Plans, } -impl PlanDataTypePlans { +impl PlanDataType { pub fn as_str(&self) -> &'static str { match self { Self::Plans => "plans", } } } -impl ::std::fmt::Display for PlanDataTypePlans { +impl ::std::fmt::Display for PlanDataType { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { f.write_str(self.as_str()) } } -impl AsRef for PlanDataTypePlans { +impl AsRef for PlanDataType { fn as_ref(&self) -> &str { self.as_str() } @@ -46,20 +46,20 @@ impl AsRef for PlanDataTypePlans { #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PlanDataAttributes { #[serde(skip_serializing_if = "Option::is_none")] - pub specs: Option, + pub specs: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct PlanDataSpecs { +pub struct PlanDataAttributesSpecs { #[serde(skip_serializing_if = "Option::is_none")] - pub drives: Option>, + pub drives: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct PlanDataDrivesItem { +pub struct PlanDataAttributesSpecsDrivesItem { #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, + pub r#type: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] -pub enum PlanDataType { +pub enum PlanDataAttributesSpecsDrivesItemType { #[default] #[serde(rename = "SSD")] Ssd, @@ -68,7 +68,7 @@ pub enum PlanDataType { #[serde(rename = "NVME")] Nvme, } -impl PlanDataType { +impl PlanDataAttributesSpecsDrivesItemType { pub fn as_str(&self) -> &'static str { match self { Self::Ssd => "SSD", @@ -77,12 +77,12 @@ impl PlanDataType { } } } -impl ::std::fmt::Display for PlanDataType { +impl ::std::fmt::Display for PlanDataAttributesSpecsDrivesItemType { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { f.write_str(self.as_str()) } } -impl AsRef for PlanDataType { +impl AsRef for PlanDataAttributesSpecsDrivesItemType { fn as_ref(&self) -> &str { self.as_str() } diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_object_nullable_test.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_object_nullable_test.snap index a73d9af..f1696bb 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_object_nullable_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_object_nullable_test.snap @@ -11,6 +11,19 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseWithNullable { pub data: Vec, @@ -18,7 +31,11 @@ pub struct ResponseWithNullable { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseWithNullableDataItem { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub optional_field: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub optional_field: Option>, pub required_field: String, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__mixed_inline_ref_oneof.snap b/src/snapshots/openapi_to_rust__test_helpers__mixed_inline_ref_oneof.snap index e6a6c44..855dec3 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__mixed_inline_ref_oneof.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__mixed_inline_ref_oneof.snap @@ -13,18 +13,355 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct UrlBlock { + pub r#type: UrlBlockType, pub url: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum Response { - #[serde(rename = "success")] Success(DataBlock), - #[serde(rename = "error")] NamedError(NamedError), - #[serde(rename = "redirect")] Redirect(UrlBlock), } +impl serde::Serialize for Response { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Success(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Success), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "success") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Success), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("success".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::NamedError(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(NamedError), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "error") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(NamedError), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("error".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::Redirect(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Redirect), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "redirect") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Redirect), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("redirect".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for Response { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "success" => { + let primary_error = match serde_json::from_value::< + DataBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::Success(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + NamedError, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "success", first_name, stringify!(NamedError), + ), + ), + ); + } + structural_match = Some(( + Self::NamedError(payload), + stringify!(NamedError), + )); + } + if let Ok(payload) = serde_json::from_value::< + UrlBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "success", first_name, stringify!(Redirect), + ), + ), + ); + } + structural_match = Some(( + Self::Redirect(payload), + stringify!(Redirect), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "error" => { + let primary_error = match serde_json::from_value::< + NamedError, + >(value.clone()) { + Ok(payload) => return Ok(Self::NamedError(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + DataBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "error", first_name, stringify!(Success), + ), + ), + ); + } + structural_match = Some(( + Self::Success(payload), + stringify!(Success), + )); + } + if let Ok(payload) = serde_json::from_value::< + UrlBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "error", first_name, stringify!(Redirect), + ), + ), + ); + } + structural_match = Some(( + Self::Redirect(payload), + stringify!(Redirect), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "redirect" => { + let primary_error = match serde_json::from_value::< + UrlBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::Redirect(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + DataBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "redirect", first_name, stringify!(Success), + ), + ), + ); + } + structural_match = Some(( + Self::Success(payload), + stringify!(Success), + )); + } + if let Ok(payload) = serde_json::from_value::< + NamedError, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "redirect", first_name, stringify!(NamedError), + ), + ), + ); + } + structural_match = Some(( + Self::NamedError(payload), + stringify!(NamedError), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum UrlBlockType { #[default] @@ -52,6 +389,7 @@ impl AsRef for UrlBlockType { pub struct NamedError { pub code: String, pub message: String, + pub r#type: NamedErrorType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum NamedErrorType { @@ -79,6 +417,7 @@ impl AsRef for NamedErrorType { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DataBlock { pub data: serde_json::Value, + pub r#type: DataBlockType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum DataBlockType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap index c892c82..573f646 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap @@ -11,13 +11,30 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreateResponse { ///Text, image, or file inputs to the model pub input: CreateResponseInput, pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub temperature: Option>, } ///Text, image, or file inputs to the model #[derive(Debug, Clone, Deserialize, Serialize)] @@ -28,8 +45,12 @@ pub enum CreateResponseInput { } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ResponseProperties { - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub temperature: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct InputItem { diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap index d4e3025..f773ac9 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap @@ -11,10 +11,27 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Container { - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub data: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap index 5e2e682..3ae1ad0 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap @@ -19,17 +19,17 @@ pub struct NestedResponse { pub struct NestedResponseResultsItem { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, + pub metadata: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct NestedResponseMetadata { +pub struct NestedResponseResultsItemMetadata { #[serde(skip_serializing_if = "Option::is_none")] - pub attributes: Option, + pub attributes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct NestedResponseAttributes { +pub struct NestedResponseResultsItemMetadataAttributes { /// Additional properties matching the spec's /// `additionalProperties` value schema. #[serde(flatten)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap index 9ff082d..c021a81 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap @@ -11,10 +11,27 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct GetProfileResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub languages: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub languages: Option>>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum GetProfileResponseLanguagesItem { diff --git a/src/snapshots/openapi_to_rust__test_helpers__nullable_component_reference.snap b/src/snapshots/openapi_to_rust__test_helpers__nullable_component_reference.snap new file mode 100644 index 0000000..1a263d1 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__nullable_component_reference.snap @@ -0,0 +1,18 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Envelope { + pub items: Option, +} +pub type NullableItems = Vec; diff --git a/src/snapshots/openapi_to_rust__test_helpers__nullable_fields.snap b/src/snapshots/openapi_to_rust__test_helpers__nullable_fields.snap index 81ed49b..3708eb6 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nullable_fields.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nullable_fields.snap @@ -11,12 +11,33 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct User { - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub email: Option>, pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub metadata: Option>, pub name: String, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__nullable_shorthand_collapses.snap b/src/snapshots/openapi_to_rust__test_helpers__nullable_shorthand_collapses.snap index cb9b54b..bfda161 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nullable_shorthand_collapses.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nullable_shorthand_collapses.snap @@ -11,8 +11,25 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Widget { - #[serde(skip_serializing_if = "Option::is_none")] - pub maybe_name: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub maybe_name: Option>, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap b/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap index 9814e7a..ebf3934 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap @@ -18,6 +18,7 @@ pub struct ImageBlock { } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct URLImageSource { + pub r#type: URLImageSourceType, pub url: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -66,17 +67,224 @@ impl AsRef for ImageBlockType { self.as_str() } } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ImageBlockSource { - #[serde(rename = "base64")] Base64ImageSource(Base64ImageSource), - #[serde(rename = "url")] URLImageSource(URLImageSource), } +impl serde::Serialize for ImageBlockSource { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Base64ImageSource(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(Base64ImageSource), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "base64") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Base64ImageSource), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("base64".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::URLImageSource(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(URLImageSource), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "url") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(URLImageSource), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("url".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ImageBlockSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "base64" => { + let primary_error = match serde_json::from_value::< + Base64ImageSource, + >(value.clone()) { + Ok(payload) => return Ok(Self::Base64ImageSource(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + URLImageSource, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "base64", first_name, stringify!(URLImageSource), + ), + ), + ); + } + structural_match = Some(( + Self::URLImageSource(payload), + stringify!(URLImageSource), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "url" => { + let primary_error = match serde_json::from_value::< + URLImageSource, + >(value.clone()) { + Ok(payload) => return Ok(Self::URLImageSource(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + Base64ImageSource, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "url", first_name, stringify!(Base64ImageSource), + ), + ), + ); + } + structural_match = Some(( + Self::Base64ImageSource(payload), + stringify!(Base64ImageSource), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Base64ImageSource { pub data: String, + pub r#type: Base64ImageSourceType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum Base64ImageSourceType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap index be4bb77..627a55a 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap @@ -11,10 +11,27 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ConfigObject { - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub cache_control: Option>, pub display_settings: ConfigObjectDisplaySettings, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap index 0ea6ae8..8fef775 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap @@ -14,6 +14,5 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GpuType { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] pub pool: Option, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap index 54f015f..6503a32 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap @@ -16,9 +16,8 @@ pub struct Pod { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, - #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")] + #[serde(rename = "startedAt")] pub started_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub template: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap index 3b75dbc..457901c 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap @@ -24,12 +24,103 @@ pub struct Message { pub content: String, pub role: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] +#[derive(Debug, Clone)] pub enum CreateMessageParamsToolsItemUnion { Tool(Tool), BashTool(BashTool), } +impl Serialize for CreateMessageParamsToolsItemUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Tool(value) => serde::Serialize::serialize(value, serializer), + Self::BashTool(value) => serde::Serialize::serialize(value, serializer), + } + } +} +impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = ::deserialize(deserializer)?; + let mut matched = None; + if input + .as_object() + .is_some_and(|object| { + true + && object + .get("type") + .is_none_or(|value| { + value.is_null() + || matches!(value.to_string().as_str(), "\"custom\"") + }) + }) + { + if let Ok(candidate) = serde_json::from_value::(input.clone()) { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| encoded == input) + .unwrap_or(false); + if preserves_complete_input { + if matched.is_some() { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(CreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), + ), + ); + } + matched = Some(Self::Tool(candidate)); + } + } + } + if input + .as_object() + .is_some_and(|object| { + true + && object + .get("type") + .is_none_or(|value| { + value.is_null() + || matches!(value.to_string().as_str(), "\"bash\"") + }) + }) + { + if let Ok(candidate) = serde_json::from_value::(input.clone()) { + let preserves_complete_input = serde_json::to_value(&candidate) + .map(|encoded| encoded == input) + .unwrap_or(false); + if preserves_complete_input { + if matched.is_some() { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(CreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), + ), + ); + } + matched = Some(Self::BashTool(candidate)); + } + } + } + matched + .ok_or_else(|| serde::de::Error::custom( + concat!( + "no oneOf branch for ", + stringify!(CreateMessageParamsToolsItemUnion), + " preserved the complete input", + ), + )) + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap b/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap index 0fd9a45..ae6ddd8 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap @@ -11,10 +11,31 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; +/// Serde normally maps both a missing `Option` field and an +/// explicit JSON null to `None`. Wrapping the decoded value in +/// `Some` retains the field-presence bit for `Option>`. +mod tri_state_serde { + use serde::{Deserialize, Deserializer}; + pub fn deserialize<'de, D, T>(de: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + T::deserialize(de).map(Some) + } +} #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ConfigSchema { - #[serde(skip_serializing_if = "Option::is_none")] - pub allowed_tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub allowed_tools: Option>>, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "tri_state_serde::deserialize" + )] + pub cache_control: Option>, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__underscore_type_names.snap b/src/snapshots/openapi_to_rust__test_helpers__underscore_type_names.snap index 173450f..3898f04 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__underscore_type_names.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__underscore_type_names.snap @@ -11,17 +11,223 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum ResponseStreamEvent { - #[serde(rename = "created")] Created(ResponseCreatedEvent), - #[serde(rename = "completed")] Completed(ResponseCompletedEvent), } +impl serde::Serialize for ResponseStreamEvent { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Created(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Created), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "created") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Created), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("created".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::Completed(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", stringify!(Completed), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "completed") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(Completed), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("completed".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for ResponseStreamEvent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "created" => { + let primary_error = match serde_json::from_value::< + ResponseCreatedEvent, + >(value.clone()) { + Ok(payload) => return Ok(Self::Created(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseCompletedEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "created", first_name, stringify!(Completed), + ), + ), + ); + } + structural_match = Some(( + Self::Completed(payload), + stringify!(Completed), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "completed" => { + let primary_error = match serde_json::from_value::< + ResponseCompletedEvent, + >(value.clone()) { + Ok(payload) => return Ok(Self::Completed(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseCreatedEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "completed", first_name, stringify!(Created), + ), + ), + ); + } + structural_match = Some(( + Self::Created(payload), + stringify!(Created), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseCreatedEvent { pub id: String, + pub r#type: ResponseCreatedEventType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum ResponseCreatedEventType { @@ -49,6 +255,7 @@ impl AsRef for ResponseCreatedEventType { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseCompletedEvent { pub id: String, + pub r#type: ResponseCompletedEventType, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum ResponseCompletedEventType { diff --git a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap index 734ef42..6bf5d89 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap @@ -18,17 +18,225 @@ pub struct RequestToolResultBlock { ///Constraint: pattern=`^[a-zA-Z0-9_-]+$` pub tool_use_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum RequestToolResultBlockContentItemUnion { - #[serde(rename = "text")] RequestTextBlock(RequestTextBlock), - #[serde(rename = "image")] RequestImageBlock(RequestImageBlock), } +impl serde::Serialize for RequestToolResultBlockContentItemUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::RequestTextBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(RequestTextBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "text") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RequestTextBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("text".to_string()), + ); + } + } + value.serialize(serializer) + } + Self::RequestImageBlock(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(RequestImageBlock), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!(tag.as_str(), "image") => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(RequestImageBlock), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String("image".to_string()), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for RequestToolResultBlockContentItemUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "text" => { + let primary_error = match serde_json::from_value::< + RequestTextBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::RequestTextBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RequestImageBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "text", first_name, stringify!(RequestImageBlock), + ), + ), + ); + } + structural_match = Some(( + Self::RequestImageBlock(payload), + stringify!(RequestImageBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "image" => { + let primary_error = match serde_json::from_value::< + RequestImageBlock, + >(value.clone()) { + Ok(payload) => return Ok(Self::RequestImageBlock(payload)), + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + RequestTextBlock, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "image", first_name, stringify!(RequestTextBlock), + ), + ), + ); + } + structural_match = Some(( + Self::RequestTextBlock(payload), + stringify!(RequestTextBlock), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RequestTextBlock { pub text: String, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] @@ -43,6 +251,7 @@ pub type RequestToolResultBlockContentArray = Vec< #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RequestImageBlock { pub source: RequestImageBlockSource, + pub r#type: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RequestImageBlockSource { diff --git a/src/snapshots/openapi_to_rust__test_helpers__x_stainless_const_bug.snap b/src/snapshots/openapi_to_rust__test_helpers__x_stainless_const_bug.snap index df0fbe7..710ee91 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__x_stainless_const_bug.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__x_stainless_const_bug.snap @@ -11,19 +11,242 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum EventUnion { - #[serde(rename = "response.reasoning_summary_part.done")] ReasoningSummaryPartDone(ResponseReasoningSummaryPartDoneEvent), - #[serde(rename = "response.reasoning_summary_part.added")] ReasoningSummaryPartAdded(ResponseReasoningSummaryPartAddedEvent), } +impl serde::Serialize for EventUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::ReasoningSummaryPartDone(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(ReasoningSummaryPartDone), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!( + tag.as_str(), "response.reasoning_summary_part.done" + ) => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ReasoningSummaryPartDone), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String( + "response.reasoning_summary_part.done".to_string(), + ), + ); + } + } + value.serialize(serializer) + } + Self::ReasoningSummaryPartAdded(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(ReasoningSummaryPartAdded), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!( + tag.as_str(), "response.reasoning_summary_part.added" + ) => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ReasoningSummaryPartAdded), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String( + "response.reasoning_summary_part.added".to_string(), + ), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for EventUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "response.reasoning_summary_part.done" => { + let primary_error = match serde_json::from_value::< + ResponseReasoningSummaryPartDoneEvent, + >(value.clone()) { + Ok(payload) => { + return Ok(Self::ReasoningSummaryPartDone(payload)); + } + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseReasoningSummaryPartAddedEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "response.reasoning_summary_part.done", first_name, + stringify!(ReasoningSummaryPartAdded), + ), + ), + ); + } + structural_match = Some(( + Self::ReasoningSummaryPartAdded(payload), + stringify!(ReasoningSummaryPartAdded), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "response.reasoning_summary_part.added" => { + let primary_error = match serde_json::from_value::< + ResponseReasoningSummaryPartAddedEvent, + >(value.clone()) { + Ok(payload) => { + return Ok(Self::ReasoningSummaryPartAdded(payload)); + } + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseReasoningSummaryPartDoneEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "response.reasoning_summary_part.added", first_name, + stringify!(ReasoningSummaryPartDone), + ), + ), + ); + } + structural_match = Some(( + Self::ReasoningSummaryPartDone(payload), + stringify!(ReasoningSummaryPartDone), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} ///Emitted when a reasoning summary part is completed. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseReasoningSummaryPartDoneEvent { pub item_id: String, pub part: ReasoningItem, + ///The type of the event. Always `response.reasoning_summary_part.done`. + pub r#type: ResponseReasoningSummaryPartDoneEventType, } ///The type of the event. Always `response.reasoning_summary_part.done`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -56,6 +279,8 @@ impl AsRef for ResponseReasoningSummaryPartDoneEventType { pub struct ResponseReasoningSummaryPartAddedEvent { pub item_id: String, pub part: ReasoningItem, + ///The type of the event. Always `response.reasoning_summary_part.added`. + pub r#type: ResponseReasoningSummaryPartAddedEventType, } ///The type of the event. Always `response.reasoning_summary_part.added`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__x_stainless_discriminated.snap b/src/snapshots/openapi_to_rust__test_helpers__x_stainless_discriminated.snap index 44ff5cf..ecb48a8 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__x_stainless_discriminated.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__x_stainless_discriminated.snap @@ -11,17 +11,240 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] +#[derive(Debug, Clone)] pub enum EventUnion { - #[serde(rename = "response.reasoning_summary_part.added")] ReasoningSummaryPartAdded(ResponseReasoningSummaryPartAddedEvent), - #[serde(rename = "response.reasoning_summary_part.done")] ReasoningSummaryPartDone(ResponseReasoningSummaryPartDoneEvent), } +impl serde::Serialize for EventUnion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::ReasoningSummaryPartAdded(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(ReasoningSummaryPartAdded), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!( + tag.as_str(), "response.reasoning_summary_part.added" + ) => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ReasoningSummaryPartAdded), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String( + "response.reasoning_summary_part.added".to_string(), + ), + ); + } + } + value.serialize(serializer) + } + Self::ReasoningSummaryPartDone(payload) => { + let mut value = serde_json::to_value(payload) + .map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| { + serde::ser::Error::custom( + concat!( + "discriminated union variant `", + stringify!(ReasoningSummaryPartDone), + "` did not serialize as an object", + ), + ) + })?; + match object.get("type") { + Some( + serde_json::Value::String(tag), + ) if matches!( + tag.as_str(), "response.reasoning_summary_part.done" + ) => {} + Some(serde_json::Value::String(tag)) => { + return Err( + serde::ser::Error::custom( + format!( + "discriminator `{}` value `{tag}` is not valid for variant `{}`", + "type", stringify!(ReasoningSummaryPartDone), + ), + ), + ); + } + Some(_) => { + return Err( + serde::ser::Error::custom( + concat!( + "discriminator `", "type", + "` did not serialize as a string", + ), + ), + ); + } + None => { + object + .insert( + "type".to_string(), + serde_json::Value::String( + "response.reasoning_summary_part.done".to_string(), + ), + ); + } + } + value.serialize(serializer) + } + } + } +} +impl<'de> serde::Deserialize<'de> for EventUnion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let discriminator = match value.get("type") { + Some(serde_json::Value::String(discriminator)) => { + Some(discriminator.as_str()) + } + Some(_) => { + return Err( + serde::de::Error::custom( + concat!("non-string discriminator `", "type", "`",), + ), + ); + } + None => None, + }; + match discriminator { + Some(discriminator) => { + match discriminator { + "response.reasoning_summary_part.added" => { + let primary_error = match serde_json::from_value::< + ResponseReasoningSummaryPartAddedEvent, + >(value.clone()) { + Ok(payload) => { + return Ok(Self::ReasoningSummaryPartAdded(payload)); + } + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseReasoningSummaryPartDoneEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "response.reasoning_summary_part.added", first_name, + stringify!(ReasoningSummaryPartDone), + ), + ), + ); + } + structural_match = Some(( + Self::ReasoningSummaryPartDone(payload), + stringify!(ReasoningSummaryPartDone), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + "response.reasoning_summary_part.done" => { + let primary_error = match serde_json::from_value::< + ResponseReasoningSummaryPartDoneEvent, + >(value.clone()) { + Ok(payload) => { + return Ok(Self::ReasoningSummaryPartDone(payload)); + } + Err(error) => error, + }; + let mut structural_match: Option<(Self, &'static str)> = None; + if let Ok(payload) = serde_json::from_value::< + ResponseReasoningSummaryPartAddedEvent, + >(value.clone()) { + if let Some((_, first_name)) = &structural_match { + return Err( + serde::de::Error::custom( + format!( + "discriminator `{}` value `{}` did not fit its mapped branch and structurally matched both `{}` and `{}`", + "type", "response.reasoning_summary_part.done", first_name, + stringify!(ReasoningSummaryPartAdded), + ), + ), + ); + } + structural_match = Some(( + Self::ReasoningSummaryPartAdded(payload), + stringify!(ReasoningSummaryPartAdded), + )); + } + match structural_match { + Some((payload, _)) => Ok(payload), + None => Err(serde::de::Error::custom(primary_error)), + } + } + other => { + Err( + serde::de::Error::custom( + format!( + "unknown discriminator value `{other}` for `{}`", "type", + ), + ), + ) + } + } + } + None => { + Err( + serde::de::Error::custom( + concat!("missing string discriminator `", "type", "`",), + ), + ) + } + } + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseReasoningSummaryPartDoneEvent { pub part: ReasoningPart, + ///The type of the event. Always `response.reasoning_summary_part.done`. + pub r#type: ResponseReasoningSummaryPartDoneEventType, } ///The type of the event. Always `response.reasoning_summary_part.done`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -52,6 +275,8 @@ impl AsRef for ResponseReasoningSummaryPartDoneEventType { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ResponseReasoningSummaryPartAddedEvent { pub part: ReasoningPart, + ///The type of the event. Always `response.reasoning_summary_part.added`. + pub r#type: ResponseReasoningSummaryPartAddedEventType, } ///The type of the event. Always `response.reasoning_summary_part.added`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] diff --git a/src/type_mapping.rs b/src/type_mapping.rs index fe7226e..4486f6a 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -79,6 +79,15 @@ impl MappedType { feature: Some(feature), } } + + /// Mapping with an inlined generated codec and no crate dependency. + pub fn with_inline_codec(rust_type: impl Into, codec_path: impl Into) -> Self { + Self { + rust_type: rust_type.into(), + serde_with: Some(codec_path.into()), + feature: None, + } + } } /// Identifies an optional crate a mapping introduced. @@ -476,10 +485,13 @@ impl UsedFeatures { pub enum DateStrategy { /// Plain `String`. Pre-Q2 behavior; pick this to opt out. String, - /// `chrono::DateTime` / `NaiveDate` / `NaiveTime` (default). + /// `chrono::DateTime` / `NaiveDate` (default). JSON Schema + /// `format: time` remains a String because RFC 3339 full-time carries an + /// offset that `NaiveTime` cannot represent. #[default] Chrono, - /// `time::OffsetDateTime` / `Date` / `Time`. + /// `time::OffsetDateTime` / `Date`. JSON Schema `format: time` remains a + /// String because `time::Time` cannot represent its required offset. Time, } @@ -684,6 +696,17 @@ fn builtin_format_aliases() -> &'static [(&'static str, &'static str)] { ] } +/// Normalize a built-in format alias without applying user configuration. +/// +/// Internal schema tooling uses this alongside [`TypeMapper`] so synthesis, +/// validation, and Rust type selection agree on vendor spellings. +pub(crate) fn normalize_builtin_format(format: &str) -> &str { + builtin_format_aliases() + .iter() + .find_map(|(from, to)| (*from == format).then_some(*to)) + .unwrap_or(format) +} + impl TypeMappingConfig { /// Q2.4: constraint-doc emission mode. Defaults to /// [`ConstraintMode::Doc`] when the @@ -891,12 +914,7 @@ impl TypeMapper { if let Some(target) = self.config.format_aliases.get(raw) { return Some(target.clone()); } - for (from, to) in builtin_format_aliases() { - if *from == raw { - return Some((*to).to_string()); - } - } - Some(raw.to_string()) + Some(normalize_builtin_format(raw).to_string()) } fn map_date_time(&self, strat: DateStrategy) -> MappedType { @@ -941,20 +959,14 @@ impl TypeMapper { } } - fn map_time(&self, strat: DateStrategy) -> MappedType { - match strat { - DateStrategy::String => MappedType::plain("String"), - DateStrategy::Chrono => { - self.record(TypeFeature::Chrono); - MappedType::with_feature("chrono::NaiveTime", TypeFeature::Chrono) - } - DateStrategy::Time => { - self.record(TypeFeature::TimeTime); - // Same story as `time::Date`: no built-in codec, so - // the generator emits `time_time_format`. - MappedType::with_codec("time::Time", "time_time_format", TypeFeature::TimeTime) - } - } + fn map_time(&self, _strat: DateStrategy) -> MappedType { + // JSON Schema's `time` format is RFC 3339 `full-time`, not a local + // wall-clock time: `03:04:05Z` and `03:04:05.123-07:00` are canonical + // values. Neither chrono::NaiveTime nor time::Time has a UTC-offset + // component, so mapping to either type rejects or loses valid wire + // data. Preserve the exact string for every strategy; `date` and + // `date-time` continue to honor their configured typed mappings. + MappedType::plain("String") } fn map_duration(&self, strat: DurationStrategy) -> MappedType { @@ -1004,10 +1016,10 @@ impl TypeMapper { fn map_binary(&self, strat: BinaryStrategy) -> MappedType { match strat { BinaryStrategy::String => MappedType::plain("String"), - BinaryStrategy::VecU8 => MappedType::plain("Vec"), + BinaryStrategy::VecU8 => MappedType::with_inline_codec("Vec", "binary_vec_serde"), BinaryStrategy::Bytes => { self.record(TypeFeature::Bytes); - MappedType::with_feature("bytes::Bytes", TypeFeature::Bytes) + MappedType::with_codec("bytes::Bytes", "binary_bytes_serde", TypeFeature::Bytes) } } } @@ -1141,6 +1153,7 @@ mod tests { "chrono::DateTime" ); assert_eq!(m.string_format(Some("date")).rust_type, "chrono::NaiveDate"); + assert_eq!(m.string_format(Some("time")).rust_type, "String"); assert_eq!(m.string_format(Some("uuid")).rust_type, "uuid::Uuid"); assert_eq!(m.string_format(Some("uri")).rust_type, "url::Url"); assert_eq!( @@ -1254,9 +1267,11 @@ mod tests { fn builtin_aliases_normalize_uuid_variants_to_uuid() { let m = TypeMapper::default(); for fmt in ["uuid4", "uuid_v4", "UUID"] { + assert_eq!(normalize_builtin_format(fmt), "uuid", "format = {fmt}"); let mt = m.string_format(Some(fmt)); assert_eq!(mt.rust_type, "uuid::Uuid", "format = {fmt}"); } + assert_eq!(normalize_builtin_format("vendor-id"), "vendor-id"); } #[test] diff --git a/tests/additional_properties_typed_test.rs b/tests/additional_properties_typed_test.rs index 3008cb3..1665dc6 100644 --- a/tests/additional_properties_typed_test.rs +++ b/tests/additional_properties_typed_test.rs @@ -7,10 +7,11 @@ use openapi_to_rust::{ type_mapping::TypeShapeConfig, }; use serde_json::json; +use std::process::Command; fn ap_spec(value_schema: serde_json::Value) -> serde_json::Value { json!({ - "openapi": "3.1.0", + "openapi": "3.0.3", "info": { "title": "ap", "version": "1.0.0" }, "paths": {}, "components": { @@ -36,6 +37,39 @@ fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { .expect("generate") } +fn analyze_error(spec: serde_json::Value) -> String { + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::new(TypeMappingConfig::default())) + .expect("analyzer"); + match analyzer.analyze() { + Ok(_) => panic!("schema analysis unexpectedly succeeded"), + Err(error) => error.to_string(), + } +} + +fn struct_header<'a>(code: &'a str, name: &str) -> &'a str { + let marker = format!("pub struct {name}"); + let struct_start = code + .find(&marker) + .unwrap_or_else(|| panic!("generated code did not contain `{marker}`:\n{code}")); + let derive_start = code[..struct_start] + .rfind("#[derive(") + .unwrap_or_else(|| panic!("generated `{name}` did not have a derive attribute:\n{code}")); + &code[derive_start..struct_start] +} + +fn struct_source<'a>(code: &'a str, name: &str) -> &'a str { + let marker = format!("pub struct {name}"); + let start = code + .find(&marker) + .unwrap_or_else(|| panic!("generated code did not contain `{marker}`:\n{code}")); + let end = code[start..] + .find("\n}") + .map(|offset| start + offset + 2) + .unwrap_or_else(|| panic!("generated `{name}` was not closed:\n{code}")); + &code[start..end] +} + #[test] fn ap_string_schema_default_emits_typed_btreemap() { let code = generate( @@ -167,3 +201,316 @@ fn ap_schema_ref_emits_btreemap_of_named_type() { "additionalProperties: $ref should produce BTreeMap. Code:\n{code}" ); } + +#[test] +fn required_undeclared_member_with_omitted_ap_is_a_value_and_keeps_extras() { + let spec = json!({ + "openapi": "3.0.3", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "properties": { + "label": { "type": "string" } + }, + "required": ["payload"] + } + } + } + }); + + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains("pub payload: serde_json::Value"), + "an undeclared required member with omitted additionalProperties must be retained as a required JSON value. Code:\n{code}" + ); + assert!( + code.contains( + "pub additional_properties: std::collections::BTreeMap" + ), + "omitted additionalProperties permits and must retain other unknown members. Code:\n{code}" + ); + assert!( + !struct_header(&code, "Bag").contains("Default"), + "a model with a synthesized required member must not derive Default. Code:\n{code}" + ); +} + +#[test] +fn required_undeclared_member_with_true_ap_is_a_value_and_keeps_extras() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "required": ["payload"], + "additionalProperties": true + } + } + } + }); + + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains("pub payload: serde_json::Value"), + "additionalProperties: true must supply serde_json::Value for an undeclared required member. Code:\n{code}" + ); + assert!( + code.contains( + "pub additional_properties: std::collections::BTreeMap" + ), + "additionalProperties: true must continue to retain arbitrary extra members. Code:\n{code}" + ); + assert!( + !struct_header(&code, "Bag").contains("Default"), + "a model with a synthesized required member must not derive Default. Code:\n{code}" + ); +} + +#[test] +fn required_undeclared_member_uses_typed_ap_value_schema() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "required": ["slug"], + "additionalProperties": { "type": "string" } + } + } + } + }); + + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains("pub slug: String"), + "a required undeclared member must use the additionalProperties value type. Code:\n{code}" + ); + assert!( + code.contains("pub additional_properties: std::collections::BTreeMap"), + "other additional members must retain the same declared value type. Code:\n{code}" + ); + assert!( + !struct_header(&code, "Bag").contains("Default"), + "a model with a synthesized required member must not derive Default. Code:\n{code}" + ); +} + +#[test] +fn required_undeclared_member_with_false_ap_is_reported_unsatisfiable() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "required": ["ghost"], + "additionalProperties": false + } + } + } + }); + + let error = analyze_error(spec); + assert!( + error.contains("Invalid schema"), + "unexpected error: {error}" + ); + assert!( + error.contains("Bag"), + "error must identify the schema: {error}" + ); + assert!( + error.contains("ghost"), + "error must identify the impossible required member: {error}" + ); + assert!( + error.contains("additionalProperties: false"), + "error must identify the conflicting constraint: {error}" + ); + assert!( + error.to_ascii_lowercase().contains("unsatisfiable"), + "error must clearly report that the object schema is unsatisfiable: {error}" + ); +} + +#[test] +fn allof_required_member_declared_by_sibling_keeps_its_declared_type() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "RequiredOnly": { + "type": "object", + "required": ["id"] + }, + "Bag": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + { "$ref": "#/components/schemas/RequiredOnly" } + ] + }, + "OnlyRequiredBag": { + "allOf": [ + { + "type": "object", + "required": ["payload"] + } + ] + } + } + } + }); + + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + let bag = struct_source(&code, "Bag"); + assert!( + bag.contains("pub id: String"), + "required names must be reconciled after allOf sibling properties are merged. Code:\n{code}" + ); + assert!( + !bag.contains("pub id: serde_json::Value"), + "the required-only allOf branch must not prematurely synthesize an untyped field. Code:\n{code}" + ); + assert!( + !struct_header(&code, "Bag").contains("Default"), + "the merged required field must prevent Default. Code:\n{code}" + ); + let only_required = struct_source(&code, "OnlyRequiredBag"); + assert!( + only_required.contains("pub payload: serde_json::Value"), + "an allOf with only an undeclared required member must still materialize an object field. Code:\n{code}" + ); +} + +#[test] +fn required_undeclared_members_round_trip_untyped_and_typed_values() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap round trip", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "ValueBag": { + "type": "object", + "required": ["payload"] + }, + "StringBag": { + "type": "object", + "required": ["slug"], + "additionalProperties": { "type": "string" } + } + } } + }); + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + let output_dir = temp.path().join("src/generated"); + let mut analyzer = SchemaAnalyzer::new(spec).expect("valid round-trip spec"); + let mut analysis = analyzer.analyze().expect("analyze round-trip spec"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir, + module_name: "required_unknown".into(), + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let result = generator + .generate_all(&mut analysis) + .expect("generate round-trip models"); + generator + .write_files(&result) + .expect("write generated round-trip models"); + + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "required-unknown-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { +use super::generated; + +#[test] +fn required_unknown_members_survive_serde_round_trips() { + let payloads = [ + serde_json::Value::Null, + serde_json::json!(true), + serde_json::json!(42), + serde_json::json!("text"), + serde_json::json!([1, 2]), + serde_json::json!({"nested": "value"}), + ]; + for payload in payloads { + let input = serde_json::json!({ + "payload": payload, + "another": {"preserved": true} + }); + let hydrated: generated::ValueBag = + serde_json::from_value(input.clone()).expect("hydrate untyped bag"); + assert_eq!(hydrated.payload, input["payload"]); + assert_eq!(hydrated.additional_properties["another"], input["another"]); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + assert!(serde_json::from_value::(serde_json::json!({})).is_err()); + + let typed_input = serde_json::json!({"slug": "known", "another": "also typed"}); + let typed: generated::StringBag = + serde_json::from_value(typed_input.clone()).expect("hydrate typed bag"); + assert_eq!(typed.slug, "known"); + assert_eq!(typed.additional_properties["another"], "also typed"); + assert_eq!(serde_json::to_value(typed).unwrap(), typed_input); + assert!(serde_json::from_value::( + serde_json::json!({"slug": 7}) + ).is_err()); +} +} +"#, + ) + .expect("write scratch source"); + + let output = Command::new("cargo") + .arg("test") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/required-unknown-roundtrip-smoke"), + ) + .output() + .expect("run generated round-trip tests"); + assert!( + output.status.success(), + "generated required-member round-trip tests failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/allof_edge_cases_tests.rs b/tests/allof_edge_cases_tests.rs index 6a4b09d..422a768 100644 --- a/tests/allof_edge_cases_tests.rs +++ b/tests/allof_edge_cases_tests.rs @@ -214,8 +214,8 @@ fn test_property_name_with_underscores_creates_types() { // Verify display_settings field uses proper enum type assert!(result.contains("pub display_settings: ConfigObjectDisplaySettings")); - // Verify cache_control is optional enum type - assert!(result.contains("pub cache_control: Option")); + // Optional + nullable keeps absence distinct from an explicit JSON null. + assert!(result.contains("pub cache_control: Option>")); } #[test] diff --git a/tests/allof_object_union_preservation_test.rs b/tests/allof_object_union_preservation_test.rs new file mode 100644 index 0000000..395ec94 --- /dev/null +++ b/tests/allof_object_union_preservation_test.rs @@ -0,0 +1,363 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn object_union_allof_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "object union allOf", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "RuleFeature": { + "allOf": [ + { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + }, + { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "string", "const": "CARD" } + } + }, + { + "type": "object", + "required": ["type", "scope"], + "properties": { + "type": { "type": "string", "const": "VELOCITY" }, + "scope": { "type": "string" } + } + } + ], + "discriminator": { "propertyName": "type" } + } + ] + }, + "StringFilter": { + "type": "object", + "required": ["type", "op", "value"], + "properties": { + "type": { "type": "string", "enum": ["string"] }, + "op": { "type": "string" }, + "value": { "type": "string" } + } + }, + "NumberFilter": { + "type": "object", + "required": ["type", "op", "value"], + "properties": { + "type": { "type": "string", "enum": ["number"] }, + "op": { "type": "string" }, + "value": { "type": "number" } + } + }, + "ValueFilter": { + "oneOf": [ + { "$ref": "#/components/schemas/StringFilter" }, + { "$ref": "#/components/schemas/NumberFilter" } + ] + }, + "CustomFieldFilter": { + "allOf": [ + { "$ref": "#/components/schemas/ValueFilter" }, + { + "type": "object", + "required": ["key"], + "properties": { "key": { "type": "string" } } + } + ] + }, + "SignerBase": { + "type": "object", + "properties": { + "email": { "type": "string", "nullable": true }, + "language": { "type": "string" } + } + }, + "Signer": { + "type": "object", + "required": ["email"], + "allOf": [ + { "$ref": "#/components/schemas/SignerBase" }, + { + "type": "object", + "properties": { "viewed": { "type": "boolean" } } + } + ] + }, + "RootSibling": { + "type": "object", + "required": ["root_value"], + "properties": { "root_value": { "type": "string" } }, + "allOf": [ + { + "type": "object", + "required": ["child_value"], + "properties": { "child_value": { "type": "integer" } } + } + ] + }, + "GitpodOutputSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { "type": "string" }, + "command": { "type": "string" }, + "prompt": { "type": "string" }, + "boolean": { "type": "object", "properties": {} }, + "string": { "type": "object", "properties": {} } + }, + "allOf": [ + { + "anyOf": [ + { "required": ["command"] }, + { "required": ["prompt"] }, + { + "not": { + "anyOf": [ + { "required": ["command"] }, + { "required": ["prompt"] } + ] + } + } + ] + }, + { + "anyOf": [ + { "required": ["boolean"] }, + { "required": ["string"] }, + { + "not": { + "anyOf": [ + { "required": ["boolean"] }, + { "required": ["string"] } + ] + } + } + ] + } + ] + } + } + } + }) +} + +fn analyze() -> openapi_to_rust::SchemaAnalysis { + SchemaAnalyzer::new(object_union_allof_spec()) + .expect("object union allOf spec should parse") + .analyze() + .expect("object union allOf spec should analyze") +} + +#[test] +fn allof_objects_retain_inline_referenced_and_root_sibling_shapes() { + let analysis = analyze(); + for name in ["RuleFeature", "CustomFieldFilter"] { + let SchemaType::Object { + properties, + variant, + .. + } = &analysis.schemas[name].schema_type + else { + panic!("{name} should be an object with a flattened union"); + }; + assert!(variant.is_some(), "{name} must retain its union member"); + assert!( + properties.contains_key(if name == "RuleFeature" { "name" } else { "key" }), + "{name} must retain its sibling property" + ); + } + + let SchemaType::Object { + properties, + required, + .. + } = &analysis.schemas["Signer"].schema_type + else { + panic!("Signer should be a merged object"); + }; + assert!(properties["email"].nullable); + assert!(required.contains("email")); + + let SchemaType::Object { + properties, + required, + .. + } = &analysis.schemas["RootSibling"].schema_type + else { + panic!("RootSibling should be a merged object"); + }; + assert!(properties.contains_key("root_value")); + assert!(properties.contains_key("child_value")); + assert!(required.contains("root_value")); + assert!(required.contains("child_value")); + + let SchemaType::Object { + properties: output_spec, + .. + } = &analysis.schemas["GitpodOutputSpec"].schema_type + else { + panic!("GitpodOutputSpec should retain its root object shape"); + }; + for field in ["key", "command", "prompt", "boolean", "string"] { + assert!( + output_spec.contains_key(field), + "Gitpod root sibling field {field} was dropped" + ); + } +} + +#[test] +fn generated_allof_objects_round_trip_union_fields_and_required_nulls_exactly() { + let mut analysis = analyze(); + let code = CodeGenerator::new(GeneratorConfig { + module_name: "object_union_allof".into(), + enable_async_client: false, + ..Default::default() + }) + .generate(&mut analysis) + .expect("object union allOf types should generate"); + assert!( + code.contains("struct __CustomFieldFilterBase") + && code.contains("let mut variant_input = value"), + "allOf unions need complete-object base/variant decoding. Code:\n{code}" + ); + let signer_start = code.find("pub struct Signer {").expect("Signer struct"); + let signer_end = code[signer_start..] + .find("\n}") + .map(|offset| signer_start + offset) + .expect("Signer end"); + let signer = &code[signer_start..signer_end]; + assert!( + signer.starts_with("pub struct Signer {\n pub email: Option,"), + "required nullable email must serialize explicit null: {signer}" + ); + + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "object-union-allof-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn round_trip(input: serde_json::Value) +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + let hydrated: T = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(hydrated).unwrap(); + assert_eq!(output, input); + let hydrated_again: T = serde_json::from_value(output.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), output); +} + +fn main() { + round_trip::(serde_json::json!({ + "name": "card", + "type": "CARD" + })); + round_trip::(serde_json::json!({ + "name": "velocity", + "type": "VELOCITY", + "scope": "card" + })); + round_trip::(serde_json::json!({ + "key": "status", + "type": "string", + "op": "eq", + "value": "ready" + })); + round_trip::(serde_json::json!({ + "key": "amount", + "type": "number", + "op": "gt", + "value": 10.5 + })); + round_trip::(serde_json::json!({ + "email": null, + "language": "en", + "viewed": true + })); + round_trip::(serde_json::json!({ + "root_value": "root", + "child_value": 7 + })); + round_trip::(serde_json::json!({ + "key": "coverage", + "command": "collect-coverage", + "boolean": {} + })); + round_trip::(serde_json::json!({})); +} +"##, + ) + .expect("scratch main"); + + let output = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/object-union-allof-smoke"), + ) + .output() + .expect("cargo run"); + assert!( + output.status.success(), + "generated object/union allOf round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn multiple_union_intersections_are_reported_instead_of_silently_dropped() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "multiple unions", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "ImpossibleToProject": { + "allOf": [ + { "oneOf": [{ "type": "string" }, { "type": "integer" }] }, + { "anyOf": [{ "type": "boolean" }, { "type": "array" }] }, + { "type": "object", "properties": { "id": { "type": "string" } } } + ] + } + } + } + }); + let error = SchemaAnalyzer::new(spec) + .expect("multiple union spec should parse") + .analyze() + .expect_err("multiple allOf unions need an explicit diagnostic"); + assert!( + error + .to_string() + .contains("intersects multiple union members"), + "unexpected error: {error}" + ); +} diff --git a/tests/allof_scalar_carrier_test.rs b/tests/allof_scalar_carrier_test.rs new file mode 100644 index 0000000..8762853 --- /dev/null +++ b/tests/allof_scalar_carrier_test.rs @@ -0,0 +1,230 @@ +use openapi_to_rust::analysis::{SchemaType, UntypedReason}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn scalar_allof_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "scalar allOf carriers", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "ScalarAlias": { + "allOf": [ + { "type": "string" }, + { "description": "alias annotation", "x-doc-source": "test" } + ] + }, + "Envelope": { + "type": "object", + "required": [ + "required_nullable", + "enum_value", + "flag", + "items", + "scalar_alias" + ], + "properties": { + "required_nullable": { + "allOf": [ + { + "type": "string", + "format": "uuid", + "nullable": true + }, + { + "nullable": false, + "description": "neutral sibling" + } + ] + }, + "enum_value": { + "allOf": [ + { "type": "string", "enum": ["ready", "done"] }, + { "description": "enum annotation" } + ] + }, + "flag": { + "allOf": [ + { "type": "boolean" }, + { "default": true, "x-generated": true } + ] + }, + "items": { + "allOf": [ + { + "type": "array", + "items": { "type": "integer", "format": "int32" } + }, + { "description": "array annotation" } + ] + }, + "scalar_alias": { "$ref": "#/components/schemas/ScalarAlias" } + } + }, + "UnsafeIntersection": { + "allOf": [ + { "type": "string" }, + { "maxLength": 5 } + ] + }, + "Base": { + "type": "object", + "required": ["base"], + "properties": { "base": { "type": "string" } } + }, + "Derived": { + "allOf": [ + { "$ref": "#/components/schemas/Base" }, + { + "type": "object", + "required": ["child"], + "properties": { "child": { "type": "integer" } } + } + ] + } + } + } + }) +} + +fn analyze() -> openapi_to_rust::SchemaAnalysis { + SchemaAnalyzer::new(scalar_allof_spec()) + .expect("scalar allOf spec should parse") + .analyze() + .expect("scalar allOf spec should analyze") +} + +#[test] +fn scalar_array_and_enum_allof_carriers_keep_their_types_and_nullability() { + let analysis = analyze(); + let SchemaType::Object { properties, .. } = &analysis.schemas["Envelope"].schema_type else { + panic!("Envelope should be an object"); + }; + assert!(properties["required_nullable"].nullable); + assert!(matches!( + properties["required_nullable"].schema_type, + SchemaType::Primitive { ref rust_type, .. } if rust_type == "uuid::Uuid" + )); + assert!(matches!( + properties["flag"].schema_type, + SchemaType::Primitive { ref rust_type, .. } if rust_type == "bool" + )); + assert!(matches!( + properties["items"].schema_type, + SchemaType::Array { .. } + )); + assert!(matches!( + analysis.schemas["ScalarAlias"].schema_type, + SchemaType::Primitive { ref rust_type, .. } if rust_type == "String" + )); + + assert!(matches!( + analysis.schemas["UnsafeIntersection"].schema_type, + SchemaType::Untyped { + reason: UntypedReason::UnrepresentableComposition, + .. + } + )); + let SchemaType::Object { + properties: derived, + .. + } = &analysis.schemas["Derived"].schema_type + else { + panic!("object inheritance must remain an object"); + }; + assert!(derived.contains_key("base")); + assert!(derived.contains_key("child")); +} + +#[test] +fn generated_scalar_allof_carriers_round_trip_exact_wire_values() { + let mut analysis = analyze(); + let code = CodeGenerator::new(GeneratorConfig { + module_name: "scalar_allof_carriers".into(), + enable_async_client: false, + ..Default::default() + }) + .generate(&mut analysis) + .expect("scalar allOf carriers should generate"); + assert!( + code.contains("pub required_nullable: Option"), + "nullable UUID carrier should not become a struct. Code:\n{code}" + ); + assert!( + code.contains("pub items: Vec"), + "array carrier should retain its item type. Code:\n{code}" + ); + assert!( + code.contains("pub type UnsafeIntersection = serde_json::Value"), + "unsafe intersections should stay opaque. Code:\n{code}" + ); + + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "scalar-allof-carrier-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["serde"] } +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn check(input: serde_json::Value) { + let hydrated: generated::Envelope = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(hydrated).unwrap(); + assert_eq!(output, input); + let hydrated_again: generated::Envelope = serde_json::from_value(output.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), output); +} + +fn main() { + check(serde_json::json!({ + "required_nullable": null, + "enum_value": "ready", + "flag": true, + "items": [1, 2], + "scalar_alias": "alias" + })); + check(serde_json::json!({ + "required_nullable": "123e4567-e89b-12d3-a456-426614174000", + "enum_value": "done", + "flag": false, + "items": [], + "scalar_alias": "other" + })); +} +"##, + ) + .expect("scratch main"); + + let output = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/scalar-allof-carrier-smoke"), + ) + .output() + .expect("cargo run"); + assert!( + output.status.success(), + "generated scalar allOf round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/array_item_enum_test.rs b/tests/array_item_enum_test.rs index 76cc757..b35ee14 100644 --- a/tests/array_item_enum_test.rs +++ b/tests/array_item_enum_test.rs @@ -98,8 +98,8 @@ fn test_nullable_anyof_array_item_enum_is_hoisted() { "Should generate a named enum for nullable array items, got:\n{result}" ); assert!( - result.contains("pub languages: Option>"), - "Nullable array field should use the generated enum type, got:\n{result}" + result.contains("pub languages: Option>>"), + "Optional nullable array field should preserve presence and use the generated enum type, got:\n{result}" ); assert!( result.contains("#[serde(rename = \"EN\")]") diff --git a/tests/boolean_discriminator_fixture_regression.rs b/tests/boolean_discriminator_fixture_regression.rs new file mode 100644 index 0000000..e79b98e --- /dev/null +++ b/tests/boolean_discriminator_fixture_regression.rs @@ -0,0 +1,138 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf}; + +fn load_fixture(relative_path: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => serde_json::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {} as JSON: {error}", path.display())), + Some("yaml" | "yml") => serde_yaml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {} as YAML: {error}", path.display())), + extension => panic!( + "unsupported fixture extension {extension:?}: {}", + path.display() + ), + } +} + +fn component_schema<'a>(fixture: &'a Value, name: &str) -> &'a Value { + fixture + .pointer(&format!("/components/schemas/{name}")) + .unwrap_or_else(|| panic!("missing #/components/schemas/{name}")) +} + +fn assert_exact_any_of_refs(schema: &Value, expected: &[&str]) { + let actual = schema["anyOf"] + .as_array() + .expect("anyOf should be an array") + .iter() + .map(|branch| { + branch["$ref"] + .as_str() + .expect("every anyOf branch should be a reference") + }) + .collect::>(); + assert_eq!(actual, expected); +} + +fn assert_boolean_literal(property: &Value, expected: bool) { + assert_eq!(property["type"], json!("boolean")); + assert_eq!(property["const"], json!(expected)); + assert_eq!(property["enum"], json!([expected])); + assert_eq!(property["examples"], json!([expected])); +} + +#[test] +fn gcore_slurm_union_uses_boolean_branches_without_a_string_discriminator() { + let fixture = load_fixture("specs/gcore.yaml"); + let union = component_schema(&fixture, "K8sClusterSlurmAddonV2Serializers"); + + assert_eq!(union.get("discriminator"), None); + assert_exact_any_of_refs( + union, + &[ + "#/components/schemas/K8sClusterSlurmAddonEnableV2Serializer", + "#/components/schemas/K8sClusterSlurmAddonDisableV2Serializer", + ], + ); + + let enabled = component_schema(&fixture, "K8sClusterSlurmAddonEnableV2Serializer"); + let disabled = component_schema(&fixture, "K8sClusterSlurmAddonDisableV2Serializer"); + assert_boolean_literal(&enabled["properties"]["enabled"], true); + assert_boolean_literal(&disabled["properties"]["enabled"], false); +} + +#[test] +fn cloudflare_hyperdrive_union_keeps_its_boolean_typed_branches() { + let fixture = load_fixture("specs/cloudflare.yaml"); + let union = component_schema(&fixture, "hyperdrive_hyperdrive-caching"); + + assert_eq!(union.get("discriminator"), None); + assert_exact_any_of_refs( + union, + &[ + "#/components/schemas/hyperdrive_hyperdrive-caching-disabled", + "#/components/schemas/hyperdrive_hyperdrive-caching-enabled", + ], + ); + + let common = component_schema(&fixture, "hyperdrive_hyperdrive-caching-common"); + let enabled = component_schema(&fixture, "hyperdrive_hyperdrive-caching-enabled"); + assert_eq!(common["properties"]["disabled"]["type"], "boolean"); + assert_eq!(enabled["properties"]["disabled"]["type"], "boolean"); +} + +#[test] +fn boolean_branch_union_uses_lossless_anyof_without_manual_string_dispatch() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "boolean caching", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Caching": { + "type": "object", + "anyOf": [ + { "$ref": "#/components/schemas/CachingDisabled" }, + { "$ref": "#/components/schemas/CachingEnabled" } + ] + }, + "CachingDisabled": { + "type": "object", + "properties": { "disabled": { "type": "boolean" } } + }, + "CachingEnabled": { + "type": "object", + "properties": { + "disabled": { "type": "boolean" }, + "max_age": { "type": "integer" } + } + } + } + } + }); + + let mut analysis = SchemaAnalyzer::new(spec) + .expect("minimal spec should parse") + .analyze() + .expect("minimal spec should analyze"); + assert!(matches!( + analysis.schemas["Caching"].schema_type, + SchemaType::Union { .. } + )); + + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("discriminator-free boolean union should generate valid Rust"); + assert!(generated.contains("pub enum Caching")); + assert!(generated.contains("impl<'de> Deserialize<'de> for Caching")); + assert!(generated.contains("preserves_complete_json_input")); + assert!(!generated.contains("missing string discriminator")); + assert!(!generated.contains("Value::String(\"false\"")); + assert!(!generated.contains("\"false\" =>")); + assert!(!generated.contains("r#false")); +} diff --git a/tests/boolean_literal_field_names_test.rs b/tests/boolean_literal_field_names_test.rs new file mode 100644 index 0000000..b9c72c7 --- /dev/null +++ b/tests/boolean_literal_field_names_test.rs @@ -0,0 +1,150 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::fs; +use std::process::Command; + +fn literal_key_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "Boolean literal fields", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "LiteralKeys": { + "type": "object", + "additionalProperties": false, + "properties": { + "true": { "type": "boolean" }, + "false": { "type": "boolean" }, + "true_field": { "type": "string" }, + "false_field": { "type": "string" }, + "type": { "type": "string" } + }, + "required": ["true", "false", "true_field", "false_field", "type"] + } + } + } + }) +} + +fn analyze_and_generate() -> (openapi_to_rust::analysis::SchemaAnalysis, String) { + let mut analysis = SchemaAnalyzer::new(literal_key_spec()) + .expect("literal-key spec should parse") + .analyze() + .expect("literal-key spec should analyze"); + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("literal-key model should generate legal Rust"); + (analysis, generated) +} + +#[test] +fn analyzer_and_generator_emit_stable_boolean_literal_field_names() { + let (analysis, generated) = analyze_and_generate(); + let SchemaType::Object { + properties, + required, + .. + } = &analysis.schemas["LiteralKeys"].schema_type + else { + panic!("LiteralKeys should analyze as an object"); + }; + for wire_name in ["true", "false", "true_field", "false_field", "type"] { + assert!(properties.contains_key(wire_name)); + assert!(required.contains(wire_name)); + } + + let compact = generated.split_whitespace().collect::(); + for expected in [ + r#"#[serde(rename="false")]pubfalse_field:bool"#, + r#"#[serde(rename="false_field")]pubfalse_field_2:String"#, + r#"#[serde(rename="true")]pubtrue_field:bool"#, + r#"#[serde(rename="true_field")]pubtrue_field_2:String"#, + "pubr#type:String", + ] { + assert!( + compact.contains(expected), + "missing generated fragment {expected:?}:\n{generated}" + ); + } + assert!(!compact.contains("pubtrue:")); + assert!(!compact.contains("pubfalse:")); + assert!(!compact.contains("pubr#true:")); + assert!(!compact.contains("pubr#false:")); +} + +#[test] +fn generated_boolean_literal_fields_compile_and_round_trip_exact_wire_keys() { + let (_, generated) = analyze_and_generate(); + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "boolean-literal-field-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + + let mut crate_source = generated; + crate_source.push_str( + r#" +#[cfg(test)] +mod tests { + use super::LiteralKeys; + + #[test] + fn required_boolean_literal_keys_round_trip() { + let input = serde_json::json!({ + "true": true, + "false": false, + "true_field": "true collision", + "false_field": "false collision", + "type": "ordinary keyword" + }); + let hydrated: LiteralKeys = + serde_json::from_value(input.clone()).expect("hydrate literal keys"); + assert!(hydrated.true_field); + assert!(!hydrated.false_field); + assert_eq!(hydrated.true_field_2, "true collision"); + assert_eq!(hydrated.false_field_2, "false collision"); + assert_eq!(hydrated.r#type, "ordinary keyword"); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + assert!(serde_json::from_value::(serde_json::json!({ + "true": true, + "true_field": "missing false", + "false_field": "present", + "type": "present" + })) + .is_err()); + } +} +"#, + ); + fs::write(temp.path().join("src/lib.rs"), crate_source).expect("write scratch source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/boolean-literal-field-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated literal-key round-trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/boolean_subschema_test.rs b/tests/boolean_subschema_test.rs new file mode 100644 index 0000000..04283d9 --- /dev/null +++ b/tests/boolean_subschema_test.rs @@ -0,0 +1,192 @@ +//! Boolean subschemas (issue #63). +//! +//! JSON Schema 2020-12 allows `true` and `false` wherever a schema is allowed: +//! `true` accepts every value, `false` accepts none. `properties: {extra: true}` +//! is how a spec says "this key exists, any value". The parser modeled schemas +//! as objects only, so one boolean anywhere in a document failed the whole +//! thing with "data did not match any variant of untagged enum Schema" — the +//! same failure #60 was about, and the reason the vendored 2020-12 suite had 38 +//! parse failures. +//! +//! Generated code cannot say more than `serde_json::Value` for either, so what +//! these tests pin is that the document parses, the surrounding fields keep +//! their types, and the census reports the boolean honestly rather than as a +//! defect. + +use openapi_to_rust::analysis::{SchemaAnalysis, UntypedReason, UntypedVerdict}; +use openapi_to_rust::openapi::Schema; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn analyze(spec: Value) -> SchemaAnalysis { + SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes") +} + +fn generate(spec: Value) -> String { + let mut analysis = analyze(spec); + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("code generates") +} + +fn spec_with_schemas(schemas: Value) -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "boolean subschemas", "version": "1.0.0" }, + "components": { "schemas": schemas } + }) +} + +#[test] +fn a_boolean_parses_in_every_subschema_position() { + // One document exercising each position a boolean is legal in. Before this, + // any one of them took the whole document down. + let spec = spec_with_schemas(json!({ + "Everything": { + "type": "object", + "properties": { "anything": true, "forbidden": false }, + "patternProperties": { "^x-": true }, + "propertyNames": true, + "additionalProperties": true, + "contains": true, + "not": false, + "if": true, + "then": true, + "else": false, + "unevaluatedProperties": true, + "dependentSchemas": { "anything": true }, + "$defs": { "wide": true, "narrow": false } + }, + "Items": { "type": "array", "items": true }, + "Tuple": { + "type": "array", + "prefixItems": [{ "type": "string" }, true], + "items": false, + "minItems": 2 + } + })); + + let analysis = analyze(spec); + assert!(analysis.schemas.contains_key("Everything")); + assert!(analysis.schemas.contains_key("Tuple")); +} + +#[test] +fn a_true_property_is_any_value_and_its_neighbours_keep_their_types() { + let generated = generate(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { "extra": true, "name": { "type": "string" } } + } + }))); + + assert!( + generated.contains("pub extra: Option"), + "`true` accepts any value:\n{generated}" + ); + assert!( + generated.contains("pub name: Option"), + "a boolean neighbour must not cost the other fields their types:\n{generated}" + ); +} + +#[test] +fn a_boolean_is_reported_faithfully_rather_than_as_a_defect() { + // Neither spelling loses type information: `true` declares an + // unconstrained value and `false` declares one that cannot occur. + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { "wide": true, "narrow": false } + } + }))); + + let findings = analysis.untyped_fields(); + let reason_for = |context: &str| { + findings + .iter() + .find(|finding| finding.context == context) + .map(|finding| finding.reason) + }; + assert_eq!(reason_for("Thing.wide"), Some(UntypedReason::AnySchema)); + assert_eq!( + reason_for("Thing.narrow"), + Some(UntypedReason::NeverMatches) + ); + assert!( + findings + .iter() + .all(|finding| finding.reason.verdict() == UntypedVerdict::Faithful), + "a boolean schema is not a dropped type: {findings:?}" + ); +} + +#[test] +fn a_true_branch_makes_a_union_unconstrained() { + // `oneOf: [string, true]` admits everything, so there is no narrower type + // than `serde_json::Value` — and no reason to emit a union. + let generated = generate(spec_with_schemas(json!({ + "Loose": { "oneOf": [{ "type": "string" }, true] }, + "Holder": { "type": "object", "additionalProperties": false, + "properties": { "value": { "$ref": "#/components/schemas/Loose" } } } + }))); + + assert!( + generated.contains("pub type Loose = serde_json::Value"), + "a union containing `true` accepts anything:\n{generated}" + ); +} + +#[test] +fn a_false_branch_can_never_be_taken_and_is_dropped() { + let generated = generate(spec_with_schemas(json!({ + "Tight": { "oneOf": [{ "type": "string" }, false] }, + "Holder": { "type": "object", "additionalProperties": false, + "properties": { "value": { "$ref": "#/components/schemas/Tight" } } } + }))); + + assert!( + generated.contains("pub type Tight = String"), + "`oneOf: [A, false]` is `A`:\n{generated}" + ); +} + +#[test] +fn booleans_round_trip_through_the_schema_model() { + // The parse layer must not quietly rewrite them: a boolean schema + // serializes back to the boolean it was. + for value in [json!(true), json!(false)] { + let schema: Schema = serde_json::from_value(value.clone()).expect("boolean parses"); + assert!(matches!(schema, Schema::Bool(_))); + assert_eq!(serde_json::to_value(&schema).expect("serializes"), value); + } +} + +#[test] +fn count_keywords_accept_a_decimal_spelling() { + // JSON Schema requires these to be non-negative integers but says nothing + // about their spelling, so `maxItems: 2.0` is valid and appears in the + // 2020-12 suite. Reading them as `u64` alone rejected the document. + let schema: Schema = serde_json::from_value(json!({ + "type": "array", + "minItems": 1.0, + "maxItems": 2.0 + })) + .expect("decimal counts parse"); + assert_eq!(schema.details().min_items, Some(1)); + assert_eq!(schema.details().max_items, Some(2)); +} + +#[test] +fn a_fractional_count_is_still_rejected() { + // `maxItems: 2.5` is not a count in any spelling; accepting it would round + // silently. + let parsed: Result = + serde_json::from_value(json!({ "type": "array", "maxItems": 2.5 })); + assert!(parsed.is_err(), "a fractional count must not be accepted"); +} diff --git a/tests/corpus_fixture_schema_validity.rs b/tests/corpus_fixture_schema_validity.rs new file mode 100644 index 0000000..db6f282 --- /dev/null +++ b/tests/corpus_fixture_schema_validity.rs @@ -0,0 +1,559 @@ +use serde_json::{Value, json}; +use std::{fs, path::PathBuf}; + +fn load_fixture(relative_path: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => serde_json::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {} as JSON: {error}", path.display())), + Some("yaml" | "yml") => serde_yaml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {} as YAML: {error}", path.display())), + extension => panic!( + "unsupported fixture extension {extension:?}: {}", + path.display() + ), + } +} + +fn component_schema<'a>(fixture: &'a Value, name: &str) -> &'a Value { + fixture + .pointer(&format!("/components/schemas/{name}")) + .unwrap_or_else(|| panic!("missing #/components/schemas/{name}")) +} + +fn assert_draft4_meta_valid(pointer: &str, schema: &Value) { + if let Err(error) = jsonschema::draft4::meta::validate(schema) { + panic!("{pointer} is not Draft 4 meta-schema valid: {error}"); + } +} + +fn assert_draft202012_meta_valid(pointer: &str, schema: &Value) { + if let Err(error) = jsonschema::draft202012::meta::validate(schema) { + panic!("{pointer} is not Draft 2020-12 meta-schema valid: {error}"); + } +} + +fn assert_draft202012_instances( + pointer: &str, + schema: &Value, + accepted: &[Value], + rejected: &[Value], +) { + let validator = jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .build(schema) + .unwrap_or_else(|error| panic!("failed to compile {pointer}: {error}")); + for instance in accepted { + assert!( + validator.is_valid(instance), + "{pointer} unexpectedly rejected {instance}" + ); + } + for instance in rejected { + assert!( + !validator.is_valid(instance), + "{pointer} unexpectedly accepted {instance}" + ); + } +} + +fn assert_discriminator_mapping_matches_tag(fixture: &Value, union_name: &str) { + let union = component_schema(fixture, union_name); + let property_name = union["discriminator"]["propertyName"] + .as_str() + .unwrap_or_else(|| panic!("{union_name} has no string discriminator propertyName")); + let mapping = union["discriminator"]["mapping"] + .as_object() + .unwrap_or_else(|| panic!("{union_name} has no discriminator mapping")); + let branches = union["oneOf"] + .as_array() + .unwrap_or_else(|| panic!("{union_name} has no oneOf branches")); + + for (wire_value, target) in mapping { + let target = target + .as_str() + .unwrap_or_else(|| panic!("{union_name} mapping {wire_value:?} is not a string")); + assert!( + branches + .iter() + .any(|branch| branch.get("$ref").and_then(Value::as_str) == Some(target)), + "{union_name} mapping {wire_value:?} targets {target}, which is not a oneOf branch" + ); + let target_name = target + .strip_prefix("#/components/schemas/") + .unwrap_or_else(|| { + panic!("{union_name} mapping target is not a component ref: {target}") + }); + let allowed = component_schema(fixture, target_name)["properties"][property_name]["enum"] + .as_array() + .unwrap_or_else(|| { + panic!( + "{union_name} mapping target {target_name} has no enum-constrained {property_name}" + ) + }); + assert!( + allowed + .iter() + .any(|value| value.as_str() == Some(wire_value)), + "{union_name} maps {wire_value:?} to {target_name}, whose {property_name} enum is {allowed:?}" + ); + } +} + +fn assert_discriminator_branches_require_constrained_tags( + fixture: &Value, + node: &Value, + pointer: &str, +) { + let Some(object) = node.as_object() else { + return; + }; + if let Some(discriminator) = object.get("discriminator").and_then(Value::as_object) { + let field = discriminator["propertyName"] + .as_str() + .unwrap_or_else(|| panic!("{pointer}/discriminator/propertyName is not a string")); + let mappings = discriminator.get("mapping").and_then(Value::as_object); + let branches = object + .get("oneOf") + .or_else(|| object.get("anyOf")) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{pointer} discriminator has no union branches")); + + for (index, branch) in branches.iter().enumerate() { + if branch.get("type").and_then(Value::as_str) == Some("null") { + continue; + } + let reference = branch.get("$ref").and_then(Value::as_str); + let (target, target_label) = if let Some(reference) = reference { + let name = reference + .strip_prefix("#/components/schemas/") + .unwrap_or_else(|| panic!("{pointer} branch ref is not local: {reference}")); + (component_schema(fixture, name), name.to_string()) + } else { + (branch, format!("{pointer}/branch/{index}")) + }; + let required = target["required"] + .as_array() + .unwrap_or_else(|| panic!("{target_label} has no required array")); + assert!( + required.iter().any(|value| value.as_str() == Some(field)), + "{target_label} does not require discriminator {field:?}" + ); + let property = &target["properties"][field]; + let mut allowed = property["enum"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if let Some(constant) = property.get("const").and_then(Value::as_str) { + allowed.push(constant); + } + assert!( + !allowed.is_empty(), + "{target_label} does not constrain discriminator {field:?}" + ); + + if let (Some(reference), Some(mappings)) = (reference, mappings) { + let branch_mappings = mappings + .iter() + .filter(|(_, target)| target.as_str() == Some(reference)) + .collect::>(); + assert!( + !branch_mappings.is_empty(), + "{pointer} has no mapping for branch {reference}" + ); + for (wire_value, _) in branch_mappings { + assert!( + allowed.contains(&wire_value.as_str()), + "{pointer} maps {wire_value:?} to {target_label}, whose {field} allows {allowed:?}" + ); + } + } + } + } + + for (key, child) in object { + assert_discriminator_branches_require_constrained_tags( + fixture, + child, + &format!("{pointer}/{}", key.replace('~', "~0").replace('/', "~1")), + ); + } +} + +#[test] +fn cloudflare_empty_required_repair_is_draft4_valid() { + let fixture = load_fixture("specs/cloudflare.yaml"); + for name in ["ErrorData", "pagination_info"] { + let pointer = format!("#/components/schemas/{name}"); + let schema = component_schema(&fixture, name); + assert_eq!( + schema.get("required"), + None, + "{pointer} properties are intentionally optional" + ); + assert_draft4_meta_valid(&pointer, schema); + } +} + +#[test] +fn coda_required_members_are_declared_and_draft4_valid() { + let fixture = load_fixture("specs/coda.yaml"); + let cases = [ + ("Table", &["viewId"][..], &[][..]), + ( + "DocAnalyticsMetrics", + &[ + "aiCreditsChat,", + "aiCreditsBlock,", + "aiCreditsColumn,", + "aiCreditsAssistant,", + "aiCreditsReviewer,", + "aiCredits,", + ][..], + &[ + "aiCreditsChat", + "aiCreditsBlock", + "aiCreditsColumn", + "aiCreditsAssistant", + "aiCreditsReviewer", + "aiCredits", + ][..], + ), + ( + "IngestionBatchExecution", + &["errorMessage", "ingestionStatuses"][..], + &["ingestionStatusCounts"][..], + ), + ("IngestionExecutionAttempt", &["message"][..], &[][..]), + ("IngestionParentItem", &["creationTimestamp"][..], &[][..]), + ]; + + for (name, absent_required, present_required) in cases { + let pointer = format!("#/components/schemas/{name}"); + let schema = component_schema(&fixture, name); + let required = schema["required"] + .as_array() + .unwrap_or_else(|| panic!("{pointer}/required is not an array")); + let properties = schema["properties"] + .as_object() + .unwrap_or_else(|| panic!("{pointer}/properties is not an object")); + + for member in absent_required { + assert!( + !required.iter().any(|value| value.as_str() == Some(member)), + "{pointer}/required still contains stale member {member:?}" + ); + } + for member in present_required { + assert!( + required.iter().any(|value| value.as_str() == Some(member)), + "{pointer}/required lost intended member {member:?}" + ); + } + for member in required { + let member = member + .as_str() + .unwrap_or_else(|| panic!("{pointer}/required contains a non-string")); + assert!( + properties.contains_key(member), + "{pointer}/required member {member:?} has no declared property while additionalProperties is false" + ); + } + assert_draft4_meta_valid(&pointer, schema); + } +} + +#[test] +fn letta_closed_objects_do_not_require_undeclared_organization_ids() { + let fixture = load_fixture("specs/letta.yaml"); + for name in ["Archive", "UserCreate"] { + let pointer = format!("#/components/schemas/{name}"); + let schema = component_schema(&fixture, name); + let required = schema["required"] + .as_array() + .unwrap_or_else(|| panic!("{pointer}/required is not an array")); + let properties = schema["properties"] + .as_object() + .unwrap_or_else(|| panic!("{pointer}/properties is not an object")); + + assert!( + !required + .iter() + .any(|value| value.as_str() == Some("organization_id")), + "{pointer}/required retains stale organization_id" + ); + for member in required { + let member = member + .as_str() + .unwrap_or_else(|| panic!("{pointer}/required contains a non-string")); + assert!( + properties.contains_key(member), + "{pointer}/required member {member:?} is undeclared" + ); + } + assert_draft202012_meta_valid(&pointer, schema); + } +} + +#[test] +fn letta_discriminator_branches_require_and_constrain_their_tags() { + let fixture = load_fixture("specs/letta.yaml"); + assert_discriminator_branches_require_constrained_tags(&fixture, &fixture, "#"); +} + +#[test] +fn corpus_discriminator_mappings_match_target_tag_constraints() { + let coda = load_fixture("specs/coda.yaml"); + assert_discriminator_mapping_matches_tag(&coda, "PackPrincipal"); + assert_discriminator_mapping_matches_tag(&coda, "PackLog"); + + let meta_llama = load_fixture("specs/meta-llama.yaml"); + assert_discriminator_mapping_matches_tag(&meta_llama, "UserMessageContentItem"); + + let storyden = load_fixture("specs/storyden.yaml"); + for union in [ + "AuditEventTypeProps", + "DatagraphItem", + "PluginModeUnion", + "PluginInitialProps", + ] { + assert_discriminator_mapping_matches_tag(&storyden, union); + } +} + +#[test] +fn discord_enum_components_match_upstream_and_are_2020_12_valid() { + let fixture = load_fixture("specs/discord.json"); + + // Synchronized on 2026-08-27 from Discord's generated preview specification: + // https://github.com/discord/discord-api-spec/blob/main/specs/openapi_preview.json + // No immutable upstream revision was recorded with the original corpus import. + let cases = [ + ( + "ApplicationCommandHandler", + json!([ + { + "title": "APP_HANDLER", + "description": "The app handles the interaction using an interaction token", + "const": 1 + }, + { + "title": "DISCORD_LAUNCH_ACTIVITY", + "description": "Discord handles the interaction by launching an Activity and sending a follow-up message without coordinating with the app", + "const": 2 + } + ]), + vec![json!(1), json!(2)], + vec![json!(0), json!("1")], + ), + ( + "EntitlementOwnerTypes", + json!([ + { + "title": "GUILD", + "description": "A guild subscription", + "const": 1 + }, + { + "title": "USER", + "description": "A user subscription", + "const": 2 + } + ]), + vec![json!(1), json!(2)], + vec![json!(0), json!(3)], + ), + ( + "NameplatePalette", + json!([ + { + "title": "CRIMSON", + "description": "Crimson color palette", + "const": "crimson" + }, + { + "title": "BERRY", + "description": "Berry color palette", + "const": "berry" + }, + { + "title": "SKY", + "description": "Sky color palette", + "const": "sky" + }, + { + "title": "TEAL", + "description": "Teal color palette", + "const": "teal" + }, + { + "title": "FOREST", + "description": "Forest color palette", + "const": "forest" + }, + { + "title": "BUBBLE_GUM", + "description": "Bubble gum color palette", + "const": "bubble_gum" + }, + { + "title": "VIOLET", + "description": "Violet color palette", + "const": "violet" + }, + { + "title": "COBALT", + "description": "Cobalt color palette", + "const": "cobalt" + }, + { + "title": "CLOVER", + "description": "Clover color palette", + "const": "clover" + }, + { + "title": "LEMON", + "description": "Lemon color palette", + "const": "lemon" + }, + { + "title": "WHITE", + "description": "White color palette", + "const": "white" + }, + { + "title": "BLACK", + "description": "Black color palette", + "const": "black" + } + ]), + [ + "crimson", + "berry", + "sky", + "teal", + "forest", + "bubble_gum", + "violet", + "cobalt", + "clover", + "lemon", + "white", + "black", + ] + .into_iter() + .map(Value::from) + .collect(), + vec![json!("orange"), json!(1)], + ), + ( + "PollLayoutTypes", + json!([ + { + "title": "DEFAULT", + "description": "The, uhm, default layout type.", + "const": 1 + } + ]), + vec![json!(1)], + vec![json!(0), json!(2)], + ), + ]; + + for (name, expected_one_of, accepted, rejected) in cases { + let pointer = format!("#/components/schemas/{name}"); + let schema = component_schema(&fixture, name); + assert_eq!( + schema.get("oneOf"), + Some(&expected_one_of), + "{pointer}/oneOf differs from the upstream definition" + ); + assert_draft202012_meta_valid(&pointer, schema); + assert_draft202012_instances(&pointer, schema, &accepted, &rejected); + } +} + +fn collect_null_type_arrays(value: &Value, pointer: &str, found: &mut Vec) { + match value { + Value::Array(values) => { + for (index, child) in values.iter().enumerate() { + collect_null_type_arrays(child, &format!("{pointer}/{index}"), found); + } + } + Value::Object(object) => { + if object + .get("type") + .and_then(Value::as_array) + .is_some_and(|types| types.iter().any(Value::is_null)) + { + found.push(format!("{pointer}/type")); + } + for (name, child) in object { + collect_null_type_arrays(child, &format!("{pointer}/{name}"), found); + } + } + _ => {} + } +} + +#[test] +fn imagekit_nullable_type_arrays_are_strings_and_2020_12_valid() { + let fixture = load_fixture("specs/imagekit.yaml"); + let expected = [ + ( + "/components/schemas/UpdateFileRequest/oneOf/0/properties/tags/type", + json!(["array", "null"]), + ), + ( + "/components/schemas/UpdateFileRequest/oneOf/0/properties/customCoordinates/type", + json!(["string", "null"]), + ), + ( + "/components/schemas/FileDetails/properties/tags/type", + json!(["array", "null"]), + ), + ( + "/components/schemas/FileDetails/properties/AITags/type", + json!(["array", "null"]), + ), + ( + "/components/schemas/FileDetails/properties/customCoordinates/type", + json!(["string", "null"]), + ), + ( + "/components/schemas/Upload/properties/tags/type", + json!(["array", "null"]), + ), + ( + "/components/schemas/Upload/properties/AITags/type", + json!(["array", "null"]), + ), + ( + "/components/schemas/Upload/properties/customCoordinates/type", + json!(["string", "null"]), + ), + ]; + for (pointer, expected_types) in expected { + assert_eq!( + fixture.pointer(pointer), + Some(&expected_types), + "#{pointer} must contain JSON Schema type-name strings" + ); + } + + let mut null_type_arrays = Vec::new(); + collect_null_type_arrays(&fixture, "", &mut null_type_arrays); + assert!( + null_type_arrays.is_empty(), + "ImageKit type arrays still contain YAML null scalars: {null_type_arrays:?}" + ); + + for name in ["UpdateFileRequest", "FileDetails", "Upload"] { + let pointer = format!("#/components/schemas/{name}"); + assert_draft202012_meta_valid(&pointer, component_schema(&fixture, name)); + } +} diff --git a/tests/cu5_22_explicit_null_union_tests.rs b/tests/cu5_22_explicit_null_union_tests.rs new file mode 100644 index 0000000..ec378ee --- /dev/null +++ b/tests/cu5_22_explicit_null_union_tests.rs @@ -0,0 +1,272 @@ +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::process::Command; + +fn union_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "explicit null union", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "Known": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "string" } } + }, + "AnyRequired": { + "type": "object", + "required": ["value"], + "properties": { "value": { "anyOf": [ + { "$ref": "#/components/schemas/Known" }, + { "nullable": true } + ] } } + }, + "AnyOptionalObject": { + "type": "object", + "properties": { "value": { "anyOf": [ + { "type": "object", "nullable": true }, + { "$ref": "#/components/schemas/Known" } + ] } } + }, + "OneRequired": { + "type": "object", + "required": ["value"], + "properties": { "value": { "oneOf": [ + { "$ref": "#/components/schemas/Known" }, + { "nullable": true } + ] } } + }, + "OneOptionalObject": { + "type": "object", + "properties": { "value": { "oneOf": [ + { "type": "object", "nullable": true }, + { "$ref": "#/components/schemas/Known" } + ] } } + }, + "ExplicitRequired": { + "type": "object", + "required": ["value"], + "properties": { "value": { "anyOf": [ + { "$ref": "#/components/schemas/Known" }, + { "type": "null" } + ] } } + }, + "ExplicitOptional": { + "type": "object", + "properties": { "value": { "oneOf": [ + { "const": null }, + { "$ref": "#/components/schemas/Known" } + ] } } + }, + "ThreeBranches": { + "anyOf": [ + { "$ref": "#/components/schemas/Known" }, + { "type": "null" }, + { "type": "array", "items": { "type": "integer" } } + ] + } + } } + }) +} + +fn generate(spec: Value, output_dir: std::path::PathBuf) -> (CodeGenerator, String) { + let mut analyzer = SchemaAnalyzer::new(spec).expect("parse synthetic union spec"); + let mut analysis = analyzer.analyze().expect("analyze synthetic union spec"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir, + module_name: "explicit_null_union".into(), + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let generated = generator + .generate(&mut analysis) + .expect("generate synthetic union models"); + (generator, generated) +} + +#[test] +fn nullable_true_branches_remain_real_union_alternatives() { + let temp = tempfile::TempDir::new().expect("temporary output directory"); + let (_, generated) = generate(union_spec(), temp.path().join("generated")); + + for expected in [ + "pub struct AnyRequired {\n pub value: AnyRequiredValue,", + "pub value: Option", + "pub struct OneRequired {\n pub value: OneRequiredValue,", + "pub value: Option", + ] { + assert!( + generated.contains(expected), + "nullable:true must keep both branches in `{expected}`:\n{generated}" + ); + } + assert!( + generated.contains("serde_json::Value"), + "an unconstrained branch needs a dynamic JSON carrier:\n{generated}" + ); + assert!( + generated.contains("pub enum ThreeBranches") + && generated.contains("Known(Known)") + && generated.contains("Vec"), + "three branches must not collapse to an Option reference:\n{generated}" + ); +} + +#[test] +fn generated_required_and_optional_unions_round_trip_every_valid_shape() { + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + let output_dir = temp.path().join("src/generated"); + let mut analyzer = SchemaAnalyzer::new(union_spec()).expect("parse synthetic union spec"); + let mut analysis = analyzer.analyze().expect("analyze synthetic union spec"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir, + module_name: "explicit_null_union".into(), + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let result = generator + .generate_all(&mut analysis) + .expect("generate synthetic union models"); + generator + .write_files(&result) + .expect("write synthetic union models"); + + let dependency_fragment = + std::fs::read_to_string(temp.path().join("src/generated/REQUIRED_DEPS.toml")) + .expect("generated dependency fragment"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "cu5-22-explicit-null-union-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +{dependency_fragment} +"# + ), + ) + .expect("write scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated; + use serde::{Serialize, de::DeserializeOwned}; + use serde_json::{Value, json}; + + fn round_trip(input: Value) + where + T: DeserializeOwned + Serialize, + { + let hydrated: T = serde_json::from_value(input.clone()).expect("hydrate valid JSON"); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + + fn hydrate_optional_null() + where + T: DeserializeOwned + Serialize, + { + let input = json!({"value": null}); + let hydrated: T = serde_json::from_value(input).expect("hydrate optional explicit null"); + let output = serde_json::to_value(hydrated).unwrap(); + assert!( + output == json!({}) || output == json!({"value": null}), + "an optional null may retain its presence bit or serialize as schema-valid absence" + ); + } + + #[test] + fn required_and_optional_union_fields_preserve_valid_json() { + for value in [ + json!(7), + json!([1, "two"]), + Value::Null, + json!({"free": "shape"}), + json!({"id": "known"}), + ] { + round_trip::(json!({"value": value})); + } + + round_trip::(json!({})); + hydrate_optional_null::(); + for value in [json!({"free": "shape"}), json!({"id": "known"})] { + round_trip::(json!({"value": value})); + } + + // The referenced object also matches the unconstrained branch, so it + // is not valid under oneOf's exactly-one rule. The remaining shapes + // each match exactly the unconstrained branch. + for value in [json!(7), json!([1, "two"]), Value::Null, json!({"free": "shape"})] { + round_trip::(json!({"value": value})); + } + round_trip::(json!({})); + hydrate_optional_null::(); + for value in [json!({"free": "shape"})] { + round_trip::(json!({"value": value})); + } + + round_trip::(json!({"value": null})); + round_trip::(json!({"value": {"id": "known"}})); + round_trip::(json!({})); + hydrate_optional_null::(); + round_trip::(json!({"value": {"id": "known"}})); + } +} +"#, + ) + .expect("write scratch tests"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/cu5-22-explicit-null-union-smoke"), + ) + .output() + .expect("run generated union round-trip tests"); + assert!( + output.status.success(), + "generated union round-trip tests failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn microsoft_graph_nullable_object_spelling_still_parses_and_generates() { + let spec = json!({ + "openapi": "3.0.1", + "info": { "title": "OData compatibility", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "User": { + "type": "object", + "properties": { "id": { "type": "string" } } + }, + "DirectoryObject": { + "type": "object", + "properties": { "user": { "anyOf": [ + { "$ref": "#/components/schemas/User" }, + { "type": "object", "nullable": true } + ] } } + } + } } + }); + let temp = tempfile::TempDir::new().expect("temporary output directory"); + let (_, generated) = generate(spec, temp.path().join("generated")); + assert!( + !generated.contains("pub user: Option"), + "the legacy spelling must parse without discarding its object branch:\n{generated}" + ); +} diff --git a/tests/discriminator_allowed_values_test.rs b/tests/discriminator_allowed_values_test.rs new file mode 100644 index 0000000..75a5b96 --- /dev/null +++ b/tests/discriminator_allowed_values_test.rs @@ -0,0 +1,426 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn discriminator_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "discriminator allowed values", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "Track": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { "type": "string", "enum": ["track"] }, + "id": { "type": "string" } + } + }, + "EpisodeBase": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { "type": "string", "const": "episode" }, + "id": { "type": "string" } + } + }, + "Episode": { + "allOf": [ + { "$ref": "#/components/schemas/EpisodeBase" }, + { + "type": "object", + "required": ["show"], + "properties": { "show": { "type": "string" } } + } + ] + }, + "Media": { + "oneOf": [ + { "$ref": "#/components/schemas/Track" }, + { "$ref": "#/components/schemas/Episode" } + ], + "discriminator": { "propertyName": "type" } + }, + "StreamOutput": { + "type": "object", + "required": ["type", "data"], + "properties": { + "type": { "type": "string", "enum": ["stdout", "stderr"] }, + "data": { "type": "string" } + } + }, + "RichOutputKinds": { + "type": "string", + "enum": ["display_data", "execute_result"] + }, + "DisplayOrExecuteOutput": { + "type": "object", + "required": ["type", "data"], + "properties": { + "type": { + "allOf": [{ "$ref": "#/components/schemas/RichOutputKinds" }] + }, + "data": { "type": "object" } + } + }, + "InterpreterOutput": { + "oneOf": [ + { "$ref": "#/components/schemas/StreamOutput" }, + { "$ref": "#/components/schemas/DisplayOrExecuteOutput" } + ], + "discriminator": { "propertyName": "type" } + }, + "ConversationChannelType": { + "type": "string", + "enum": ["phone_call", "sms_chat"] + }, + "ScheduledPhoneCallEventResponse": { + "type": "object", + "required": ["channel", "target"], + "properties": { + "channel": { "$ref": "#/components/schemas/ConversationChannelType" }, + "target": { "type": "string" } + } + }, + "ScheduledSmsEventResponse": { + "type": "object", + "required": ["channel", "target", "text"], + "properties": { + "channel": { "$ref": "#/components/schemas/ConversationChannelType" }, + "target": { "type": "string" }, + "text": { "type": "string" } + } + }, + "ScheduledEventResponse": { + "anyOf": [ + { "$ref": "#/components/schemas/ScheduledPhoneCallEventResponse" }, + { "$ref": "#/components/schemas/ScheduledSmsEventResponse" } + ], + "discriminator": { "propertyName": "channel" } + }, + "BroadMappedKind": { + "type": "string", + "enum": ["alpha", "beta"] + }, + "BroadMappedObject": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "$ref": "#/components/schemas/BroadMappedKind" }, + "id": { "type": "string" } + } + }, + "MappedAlpha": { + "allOf": [ + { "$ref": "#/components/schemas/BroadMappedObject" }, + { + "type": "object", + "required": ["alpha"], + "properties": { + "kind": { "type": "string", "const": "alpha" }, + "alpha": { "type": "string" } + } + } + ] + }, + "MappedBeta": { + "allOf": [ + { "$ref": "#/components/schemas/BroadMappedObject" }, + { + "type": "object", + "required": ["beta"], + "properties": { + "kind": { "type": "string", "const": "beta" }, + "beta": { "type": "string" } + } + } + ] + }, + "ConflictingMappedUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/MappedAlpha" }, + { "$ref": "#/components/schemas/MappedBeta" } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "alpha": "#/components/schemas/MappedBeta", + "beta": "#/components/schemas/MappedAlpha", + "bogus": "#/components/schemas/MappedAlpha" + } + } + }, + "ScalarCarrier": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" } + ] + }, + "ObjectCarrier": { + "type": "object", + "required": ["@type", "value"], + "properties": { + "@type": { "type": "string", "const": "object" }, + "value": { "type": "string" } + } + }, + "ScalarOrTaggedObject": { + "oneOf": [ + { "$ref": "#/components/schemas/ScalarCarrier" }, + { "$ref": "#/components/schemas/ObjectCarrier" } + ], + "discriminator": { "propertyName": "@type" } + }, + "StainlessAlpha": { + "type": "object", + "required": ["type", "alpha"], + "properties": { + "type": { + "const": "stainless.alpha", + "x-stainless-const": true + }, + "alpha": { "type": "string" } + } + }, + "StainlessBeta": { + "type": "object", + "required": ["type", "beta"], + "properties": { + "type": { + "const": "stainless.beta", + "x-stainless-const": true + }, + "beta": { "type": "string" } + } + }, + "StainlessEvent": { + "anyOf": [ + { "$ref": "#/components/schemas/StainlessAlpha" }, + { "$ref": "#/components/schemas/StainlessBeta" } + ], + "discriminator": { "propertyName": "type" } + }, + "VersionedPreview": { + "type": "object", + "required": ["type", "preview"], + "properties": { + "type": { + "type": "string", + "enum": ["preview", "preview_v2"], + "default": "preview", + "x-stainless-const": true + }, + "preview": { "type": "string" } + } + }, + "VersionedOther": { + "type": "object", + "required": ["type", "other"], + "properties": { + "type": { "type": "string", "const": "other" }, + "other": { "type": "string" } + } + }, + "StainlessVersionedEvent": { + "anyOf": [ + { "$ref": "#/components/schemas/VersionedPreview" }, + { "$ref": "#/components/schemas/VersionedOther" } + ], + "discriminator": { "propertyName": "type" } + } + } } + }) +} + +fn union_values(analysis: &openapi_to_rust::SchemaAnalysis, name: &str) -> Vec> { + let SchemaType::DiscriminatedUnion { variants, .. } = &analysis.schemas[name].schema_type + else { + panic!("{name} should be a discriminated union"); + }; + variants + .iter() + .map(|variant| variant.discriminator_values.clone()) + .collect() +} + +fn preferred_union_values( + analysis: &openapi_to_rust::SchemaAnalysis, + name: &str, +) -> Vec> { + let SchemaType::DiscriminatedUnion { variants, .. } = &analysis.schemas[name].schema_type + else { + panic!("{name} should be a discriminated union"); + }; + variants + .iter() + .map(|variant| variant.preferred_discriminator_values.clone()) + .collect() +} + +#[test] +fn discriminator_values_follow_refs_compositions_and_multi_value_enums() { + let analysis = SchemaAnalyzer::new(discriminator_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + + assert_eq!( + union_values(&analysis, "Media"), + vec![vec!["track"], vec!["episode"]] + ); + assert_eq!( + union_values(&analysis, "InterpreterOutput"), + vec![ + vec![String::from("stdout"), String::from("stderr")], + vec![String::from("display_data"), String::from("execute_result")] + ] + ); + assert_eq!( + union_values(&analysis, "ScheduledEventResponse"), + vec![ + vec![String::from("phone_call"), String::from("sms_chat")], + vec![String::from("phone_call"), String::from("sms_chat")] + ] + ); + assert_eq!( + preferred_union_values(&analysis, "ScheduledEventResponse"), + vec![ + vec![String::from("phone_call")], + vec![String::from("sms_chat")] + ] + ); + assert_eq!( + union_values(&analysis, "ConflictingMappedUnion"), + vec![vec![String::from("alpha")], vec![String::from("beta")]] + ); + assert!(matches!( + &analysis.schemas["ScalarOrTaggedObject"].schema_type, + SchemaType::Union { .. } + )); + assert_eq!( + union_values(&analysis, "StainlessEvent"), + vec![vec!["stainless.alpha"], vec!["stainless.beta"]] + ); + assert_eq!( + union_values(&analysis, "StainlessVersionedEvent"), + vec![vec!["preview", "preview_v2"], vec!["other"]] + ); +} + +#[test] +fn generated_unions_preserve_every_allowed_discriminator_value() { + let mut analysis = SchemaAnalyzer::new(discriminator_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod discriminator_allowed_value_roundtrip { + use super::{ + ConflictingMappedUnion, InterpreterOutput, Media, ScalarOrTaggedObject, + ScheduledEventResponse, StainlessEvent, StainlessVersionedEvent, + }; + use serde::{Serialize, de::DeserializeOwned}; + + fn exact(input: serde_json::Value) + where + T: DeserializeOwned + Serialize, + { + let hydrated: T = serde_json::from_value(input.clone()).expect("hydrate allowed tag"); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + + #[test] + fn every_allowed_tag_round_trips_exactly() { + exact::(serde_json::json!({"type": "track", "id": "t"})); + exact::(serde_json::json!({"type": "episode", "id": "e", "show": "s"})); + + for tag in ["stdout", "stderr"] { + exact::(serde_json::json!({"type": tag, "data": "line"})); + } + for tag in ["display_data", "execute_result"] { + exact::(serde_json::json!({"type": tag, "data": {}})); + } + + exact::(serde_json::json!({ + "channel": "phone_call", "target": "voice" + })); + exact::(serde_json::json!({ + "channel": "sms_chat", "target": "text", "text": "hello" + })); + // The source schema permits either shared enum value in either branch. + // If the preferred SMS branch does not fit, dispatch falls back to the + // phone payload while preserving the original tag. + exact::(serde_json::json!({ + "channel": "sms_chat", "target": "voice-without-text" + })); + + exact::(serde_json::json!({ + "kind": "alpha", "id": "a", "alpha": "payload" + })); + exact::(serde_json::json!({ + "kind": "beta", "id": "b", "beta": "payload" + })); + + exact::(serde_json::json!(12.5)); + exact::(serde_json::json!({ + "@type": "object", "value": "payload" + })); + exact::(serde_json::json!({ + "type": "stainless.alpha", "alpha": "payload" + })); + exact::(serde_json::json!({ + "type": "stainless.beta", "beta": "payload" + })); + exact::(serde_json::json!({ + "type": "preview", "preview": "first" + })); + exact::(serde_json::json!({ + "type": "preview_v2", "preview": "second" + })); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "discriminator-allowed-values-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/discriminator-allowed-values-smoke"), + ) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated discriminator round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/discriminator_array_standalone_test.rs b/tests/discriminator_array_standalone_test.rs index d2ad53e..a9475e3 100644 --- a/tests/discriminator_array_standalone_test.rs +++ b/tests/discriminator_array_standalone_test.rs @@ -3,10 +3,9 @@ //! Test that structs used in both tagged enums and standalone arrays //! serialize correctly in both contexts. //! -//! Reproduces the Anthropic `system` field bug where `RequestTextBlock` has its -//! `type` field stripped for use in `InputContentBlock` (tagged enum), but then -//! `RequestTextBlockArray = Vec` is missing the `type` field -//! when serialized standalone. +//! Reproduces the Anthropic `system` field bug where `RequestTextBlock` used to +//! have its `type` field stripped for use in `InputContentBlock`, leaving +//! standalone `RequestTextBlock` values unable to satisfy their own schema. use openapi_to_rust::test_helpers::*; use serde_json::json; @@ -88,14 +87,24 @@ fn test_discriminator_stripped_struct_in_standalone_array() { let result = test_generation("discriminator_array_standalone", spec).expect("Generation failed"); - // The system field's array variant must serialize RequestTextBlock with type: "text". - // This means the array can't use bare Vec since the struct had - // its `type` field stripped for InputContentBlock's tagged enum. - // - // The generator should produce a wrapper that re-adds the tag. + // The standalone struct remains schema-faithful, including its required + // discriminator field, so the array can safely use it directly. assert!( - !result.contains("pub type RequestTextBlockArray = Vec"), - "Should NOT produce bare Vec — the struct is missing its type field.\n\ + result.contains("pub r#type: RequestTextBlockType") + || result.contains("pub r#type: serde_json::Value"), + "RequestTextBlock must retain its required discriminator field.\n\ + Generated:\n{result}" + ); + assert!( + result.contains("pub type RequestTextBlockArray = Vec"), + "A schema-faithful RequestTextBlock should be reusable directly in arrays.\n\ + Generated:\n{result}" + ); + assert!( + result.contains("Some(discriminator) =>") + && result.contains("\"text\" =>") + && result.contains("\"image\" =>"), + "InputContentBlock must deserialize by inspecting its discriminator.\n\ Generated:\n{result}" ); } diff --git a/tests/discriminator_collision_test.rs b/tests/discriminator_collision_test.rs index 63eb46f..5825979 100644 --- a/tests/discriminator_collision_test.rs +++ b/tests/discriminator_collision_test.rs @@ -17,7 +17,7 @@ fn generate(schemas: Value) -> String { } #[test] -fn repeated_implicit_discriminator_values_fall_back_to_untagged_union() { +fn repeated_implicit_discriminator_values_use_lossless_anyof_dispatch() { let generated = generate(json!({ "GlobalEvent": { "type": "object", @@ -65,15 +65,20 @@ fn repeated_implicit_discriminator_values_fall_back_to_untagged_union() { } })); - assert!(generated.contains("#[serde(untagged)]\npub enum GlobalEventPayload")); + assert!(generated.contains("pub enum GlobalEventPayload")); + assert!(generated.contains("impl<'de> Deserialize<'de> for GlobalEventPayload")); + assert!(generated.contains("preserves_complete_json_input")); + assert!(!generated.contains("#[serde(untagged)]\npub enum GlobalEventPayload")); assert!(!generated.contains("#[serde(tag = \"type\")]\npub enum GlobalEventPayload")); assert_eq!(generated.matches("#[serde(rename = \"sync\")]").count(), 2); - assert!(generated.contains("pub r#type: SyncEventSessionCreatedTypeSync")); - assert!(generated.contains("pub r#type: SyncEventSessionUpdatedTypeSync")); + assert!(generated.contains("pub r#type: SyncEventSessionCreatedType")); + assert!(generated.contains("pub r#type: SyncEventSessionUpdatedType")); + assert!(generated.contains("#[serde(rename = \"session.created.1\")]")); + assert!(generated.contains("#[serde(rename = \"session.updated.1\")]")); } #[test] -fn unique_implicit_discriminator_values_still_generate_tagged_union() { +fn unique_implicit_discriminator_values_still_generate_discriminated_union() { let generated = generate(json!({ "Event": { "anyOf": [ @@ -99,6 +104,9 @@ fn unique_implicit_discriminator_values_still_generate_tagged_union() { } })); - assert!(generated.contains("#[serde(tag = \"type\")]\npub enum Event")); + assert!(generated.contains("pub enum Event")); + assert!(generated.contains("match discriminator")); + assert!(generated.contains("Some(discriminator)")); + assert!(generated.contains("unknown discriminator value")); assert!(!generated.contains("#[serde(untagged)]\npub enum Event")); } diff --git a/tests/discriminator_structural_fallback_test.rs b/tests/discriminator_structural_fallback_test.rs new file mode 100644 index 0000000..1403f32 --- /dev/null +++ b/tests/discriminator_structural_fallback_test.rs @@ -0,0 +1,317 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn structural_fallback_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "discriminator structural fallback", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "MappedPrimary": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "primary"], + "properties": { + "kind": { "type": "string", "enum": ["mapped"] }, + "primary": { "type": "string" } + } + }, + "TaglessFallback": { + "type": "object", + "additionalProperties": true, + "required": ["fallback"], + "properties": { "fallback": { "type": "string" } } + }, + "MappedUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/MappedPrimary" }, + { "$ref": "#/components/schemas/TaglessFallback" } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "mapped": "#/components/schemas/MappedPrimary" + } + } + }, + "OptionalTagged": { + "type": "object", + "additionalProperties": false, + "required": ["track"], + "properties": { + "kind": { "type": "string", "enum": ["track"] }, + "track": { "type": "string" } + } + }, + "RequiredTagged": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "episode"], + "properties": { + "kind": { "type": "string", "enum": ["episode"] }, + "episode": { "type": "string" } + } + }, + "OptionalTagUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/OptionalTagged" }, + { "$ref": "#/components/schemas/RequiredTagged" } + ], + "discriminator": { "propertyName": "kind" } + }, + "TaglessOnly": { + "type": "object", + "additionalProperties": false, + "required": ["raw"], + "properties": { "raw": { "type": "string" } } + }, + "TaglessUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/TaglessOnly" }, + { "$ref": "#/components/schemas/RequiredTagged" } + ], + "discriminator": { "propertyName": "kind" } + }, + "TaglessTwin": { + "type": "object", + "additionalProperties": false, + "required": ["raw"], + "properties": { "raw": { "type": "string" } } + }, + "AmbiguousTaglessUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/TaglessOnly" }, + { "$ref": "#/components/schemas/TaglessTwin" } + ], + "discriminator": { "propertyName": "kind" } + }, + "AmbiguousTaglessAnyUnion": { + "anyOf": [ + { "$ref": "#/components/schemas/TaglessOnly" }, + { "$ref": "#/components/schemas/TaglessTwin" } + ], + "discriminator": { "propertyName": "kind" } + }, + "OptionalRed": { + "type": "object", + "additionalProperties": false, + "required": ["red"], + "properties": { + "kind": { "type": "string", "const": "red" }, + "red": { "type": "string" } + } + }, + "OptionalBlue": { + "type": "object", + "additionalProperties": false, + "required": ["blue"], + "properties": { + "kind": { "type": "string", "const": "blue" }, + "blue": { "type": "string" } + } + }, + "OptionalPaint": { + "anyOf": [ + { "$ref": "#/components/schemas/OptionalRed" }, + { "$ref": "#/components/schemas/OptionalBlue" } + ], + "discriminator": { "propertyName": "kind" } + }, + "PaintWrapper": { + "type": "object", + "additionalProperties": false, + "required": ["paint"], + "properties": { + "paint": { "$ref": "#/components/schemas/OptionalPaint" } + } + }, + "OtherWrapper": { + "type": "object", + "additionalProperties": false, + "required": ["other"], + "properties": { "other": { "type": "string" } } + }, + "CanonicalizingAnyUnion": { + "anyOf": [ + { "$ref": "#/components/schemas/PaintWrapper" }, + { "$ref": "#/components/schemas/OtherWrapper" } + ] + }, + "NumberWrapper": { + "type": "object", + "additionalProperties": false, + "required": ["ttl"], + "properties": { "ttl": { "type": "number" } } + }, + "NumericAnyUnion": { + "anyOf": [ + { "$ref": "#/components/schemas/NumberWrapper" }, + { "$ref": "#/components/schemas/OtherWrapper" } + ] + } + } } + }) +} + +#[test] +fn analysis_marks_declared_and_required_discriminator_fields() { + let analysis = SchemaAnalyzer::new(structural_fallback_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let SchemaType::DiscriminatedUnion { variants, .. } = + &analysis.schemas["OptionalTagUnion"].schema_type + else { + panic!("OptionalTagUnion should be discriminated"); + }; + assert!(variants[0].discriminator_field_declared); + assert!(!variants[0].discriminator_field_required); + assert!(variants[1].discriminator_field_declared); + assert!(variants[1].discriminator_field_required); + + let SchemaType::DiscriminatedUnion { variants, .. } = + &analysis.schemas["TaglessUnion"].schema_type + else { + panic!("TaglessUnion should be discriminated"); + }; + assert!(!variants[0].discriminator_field_declared); + assert!(!variants[0].discriminator_field_required); + + let SchemaType::DiscriminatedUnion { exclusive, .. } = + &analysis.schemas["AmbiguousTaglessAnyUnion"].schema_type + else { + panic!("AmbiguousTaglessAnyUnion should be discriminated"); + }; + assert!(!exclusive); +} + +#[test] +fn generated_discriminator_dispatch_uses_unique_structural_fallbacks() { + let mut analysis = SchemaAnalyzer::new(structural_fallback_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod structural_fallback_runtime { + use super::{ + AmbiguousTaglessAnyUnion, AmbiguousTaglessUnion, CanonicalizingAnyUnion, MappedUnion, + NumericAnyUnion, OptionalTagUnion, TaglessUnion, + }; + + #[test] + fn mapped_fast_path_and_unique_fallback_both_round_trip() { + let direct = serde_json::json!({"kind": "mapped", "primary": "p"}); + let hydrated: MappedUnion = serde_json::from_value(direct.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), direct); + + let fallback = serde_json::json!({"kind": "mapped", "fallback": "f"}); + let hydrated: MappedUnion = serde_json::from_value(fallback.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), fallback); + } + + #[test] + fn missing_tags_only_try_branches_that_do_not_require_them() { + let optional = serde_json::json!({"track": "t"}); + let hydrated: OptionalTagUnion = serde_json::from_value(optional).unwrap(); + assert_eq!( + serde_json::to_value(hydrated).unwrap(), + serde_json::json!({"kind": "track", "track": "t"}) + ); + + let tagless = serde_json::json!({"raw": "wire"}); + let hydrated: TaglessUnion = serde_json::from_value(tagless.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), tagless); + + let ambiguous_any = serde_json::json!({"raw": "matches-both"}); + let hydrated: AmbiguousTaglessAnyUnion = + serde_json::from_value(ambiguous_any.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), ambiguous_any); + } + + #[test] + fn object_anyof_preserves_input_while_allowing_nested_canonical_tags() { + let input = serde_json::json!({"paint": {"red": "warm"}}); + let hydrated: CanonicalizingAnyUnion = serde_json::from_value(input).unwrap(); + let canonical = serde_json::to_value(hydrated).unwrap(); + assert_eq!(canonical, serde_json::json!({ + "paint": {"kind": "red", "red": "warm"} + })); + + let hydrated_again: CanonicalizingAnyUnion = + serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), canonical); + } + + #[test] + fn object_anyof_treats_integer_and_float_number_encodings_as_equal() { + let input = serde_json::json!({"ttl": 1}); + let hydrated: NumericAnyUnion = serde_json::from_value(input).unwrap(); + let canonical = serde_json::to_value(hydrated).unwrap(); + assert_eq!(canonical, serde_json::json!({"ttl": 1.0})); + + let hydrated_again: NumericAnyUnion = + serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), canonical); + } + + #[test] + fn unknown_non_string_ambiguous_and_no_match_inputs_are_errors() { + assert!(serde_json::from_value::(serde_json::json!({ + "kind": "unknown", "fallback": "f" + })).unwrap_err().to_string().contains("unknown discriminator")); + assert!(serde_json::from_value::(serde_json::json!({ + "kind": 7, "fallback": "f" + })).unwrap_err().to_string().contains("non-string discriminator")); + assert!(serde_json::from_value::(serde_json::json!({ + "raw": "matches-both" + })).unwrap_err().to_string().contains("structurally matched both")); + assert!(serde_json::from_value::(serde_json::json!({ + "unrelated": true + })).unwrap_err().to_string().contains("no tagless branch matched")); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "discriminator-structural-fallback-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/discriminator-structural-fallback-smoke"), + ) + .output() + .expect("run generated structural fallback test"); + assert!( + output.status.success(), + "generated discriminator fallback failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/dynamic_additional_properties_constraints_test.rs b/tests/dynamic_additional_properties_constraints_test.rs new file mode 100644 index 0000000..762b759 --- /dev/null +++ b/tests/dynamic_additional_properties_constraints_test.rs @@ -0,0 +1,186 @@ +use openapi_to_rust::analysis::{ObjectAdditionalProperties, SchemaType}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn dynamic_object_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "dynamic object constraints", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "ConstrainedMetric": { + "type": "object", + "minProperties": 2, + "maxProperties": 3, + "properties": { + "timestamp": { "type": "integer" }, + "metric": { "type": "number" } + } + }, + "ExampleDriven": { + "type": "object", + "properties": { + "placeholder": { "type": "string" } + }, + "example": { + "actual_metric_name": 42 + } + }, + "ExamplesDriven": { + "type": "object", + "properties": { + "placeholder": { "type": "boolean" } + }, + "examples": [ + { "first_dynamic_name": true }, + { "second_dynamic_name": "value" } + ] + }, + "Closed": { + "type": "object", + "additionalProperties": false, + "required": ["known"], + "properties": { + "known": { "type": "string" } + } + }, + "Typed": { + "type": "object", + "additionalProperties": { "type": "string" }, + "properties": { + "known": { "type": "string" } + } + } + } + } + }) +} + +fn analyze() -> openapi_to_rust::SchemaAnalysis { + SchemaAnalyzer::new(dynamic_object_spec()) + .expect("dynamic object spec should parse") + .analyze() + .expect("dynamic object spec should analyze") +} + +fn additional_properties<'a>( + analysis: &'a openapi_to_rust::SchemaAnalysis, + name: &str, +) -> &'a ObjectAdditionalProperties { + let SchemaType::Object { + additional_properties, + .. + } = &analysis.schemas[name].schema_type + else { + panic!("{name} should analyze as an object"); + }; + additional_properties +} + +#[test] +fn constraints_and_examples_promote_omitted_additional_properties_to_a_carrier() { + let analysis = analyze(); + for name in ["ConstrainedMetric", "ExampleDriven", "ExamplesDriven"] { + assert!( + matches!( + additional_properties(&analysis, name), + ObjectAdditionalProperties::Untyped + ), + "{name} must retain undeclared keys" + ); + } + assert!(matches!( + additional_properties(&analysis, "Closed"), + ObjectAdditionalProperties::Forbidden + )); + assert!(matches!( + additional_properties(&analysis, "Typed"), + ObjectAdditionalProperties::Typed { .. } + )); +} + +#[test] +fn generated_dynamic_object_members_round_trip_without_breaking_typed_or_closed_controls() { + let mut analysis = analyze(); + let code = CodeGenerator::new(GeneratorConfig { + module_name: "dynamic_object_constraints".into(), + enable_async_client: false, + ..Default::default() + }) + .generate(&mut analysis) + .expect("dynamic object types should generate"); + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "dynamic-additional-properties-constraints-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn round_trip(input: serde_json::Value) +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + let hydrated: T = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); +} + +fn main() { + round_trip::(serde_json::json!({ + "timestamp": 1623159320, + "edge_status_2xx": 21095299 + })); + round_trip::(serde_json::json!({ + "timestamp": 1623159380, + "edge_status_2xx": 62616980, + "edge_download_speed": { "0_250k": "0", "1M_2M": "0.09" } + })); + round_trip::(serde_json::json!({ + "actual_metric_name": 42, + "another_valid_extra": true + })); + round_trip::(serde_json::json!({ + "first_dynamic_name": true + })); + round_trip::(serde_json::json!({ "known": "value" })); + round_trip::(serde_json::json!({ + "known": "declared", + "extra": "typed" + })); +} +"##, + ) + .expect("scratch main"); + + let output = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/dynamic-additional-properties-constraints-smoke"), + ) + .output() + .expect("cargo run"); + assert!( + output.status.success(), + "generated dynamic object round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/dynamic_json_detection_tests.rs b/tests/dynamic_json_detection_tests.rs index 741427b..8c71ccf 100644 --- a/tests/dynamic_json_detection_tests.rs +++ b/tests/dynamic_json_detection_tests.rs @@ -64,6 +64,27 @@ fn test_tool_input_empty_object() { assert!(!result.contains("pub struct ToolUseBlockInput")); } +#[test] +fn test_closed_empty_object_remains_a_structural_type() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "ClosedEmpty": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + } + }); + + let result = test_generation("closed_empty_object_test", spec).expect("Generation failed"); + assert!(result.contains("pub struct ClosedEmpty")); + assert!(!result.contains("pub type ClosedEmpty = serde_json::Value")); +} + #[test] fn test_object_with_additional_properties_only() { let spec = json!({ @@ -173,7 +194,7 @@ fn test_nested_empty_objects() { let result = test_generation("nested_empty_objects_test", spec).expect("Generation failed"); // Both data and metadata should be serde_json::Value - assert!(result.contains("pub data: Option")); + assert!(result.contains("pub data: Option>")); assert!(result.contains("pub metadata: Option")); } diff --git a/tests/enum_improvements_tests.rs b/tests/enum_improvements_tests.rs index b65e73e..433c21c 100644 --- a/tests/enum_improvements_tests.rs +++ b/tests/enum_improvements_tests.rs @@ -476,44 +476,18 @@ fn test_inline_enum_collision_at_different_nesting_levels() { let result = test_generation("inline_enum_collision_nesting", spec).expect("Generation failed"); - // The exact assignment of "primary" vs "disambiguated" depends on - // schema-walk order. What we MUST verify is: - // 1. Two distinct enums got emitted (one for each value-set), and - // 2. The struct field types route to the correct enums. - // - // Both enums must exist somewhere: - let plans_enum_name = if result.contains("pub enum PlanDataType") - && extract_enum_variants(&result, "PlanDataType") - .iter() - .any(|v| v == "Plans") - { - "PlanDataType".to_string() - } else { - let disambiguated = ["PlanDataTypePlans"]; - disambiguated - .iter() - .find(|name| result.contains(&format!("pub enum {name}"))) - .map(|s| s.to_string()) - .unwrap_or_else(|| panic!("could not find resource-type enum in: {result}")) - }; - let drives_enum_name = if result.contains("pub enum PlanDataType") - && extract_enum_variants(&result, "PlanDataType") - .iter() - .any(|v| v == "Nvme") - { - "PlanDataType".to_string() - } else { - let disambiguated = ["PlanDataTypeSsd", "PlanDataTypeHdd", "PlanDataTypeNvme"]; - disambiguated - .iter() - .find(|name| result.contains(&format!("pub enum {name}"))) - .map(|s| s.to_string()) - .unwrap_or_else(|| panic!("could not find drives-media-type enum in: {result}")) - }; - - assert_ne!( - plans_enum_name, drives_enum_name, - "two distinct inline `type` enums must NOT collapse to the same name: {result}" + // Path-aware names make the assignment deterministic: the direct property + // gets the concise name, while the nested array item carries its full + // provenance. The two value domains must remain distinct. + let plans_enum_name = "PlanDataType"; + let drives_enum_name = "PlanDataAttributesSpecsDrivesItemType"; + assert_eq!( + extract_enum_variants(&result, plans_enum_name), + vec!["Plans"] + ); + assert_eq!( + extract_enum_variants(&result, drives_enum_name), + vec!["Ssd", "Hdd", "Nvme"] ); // Resource-type field on `PlanData` references the plans enum. @@ -525,8 +499,8 @@ fn test_inline_enum_collision_at_different_nesting_levels() { ); // Drive-item field references the drives enum. - let drives_struct = extract_struct_block(&result, "PlanDataDrivesItem") - .expect("PlanDataDrivesItem struct must be present"); + let drives_struct = extract_struct_block(&result, "PlanDataAttributesSpecsDrivesItem") + .expect("nested drive item struct must be present"); assert!( drives_struct.contains(&format!("Option<{drives_enum_name}>")), "PlanDataDrivesItem.type must reference {drives_enum_name}, got: {drives_struct}" diff --git a/tests/gcore_uuid4_fixture_regression.rs b/tests/gcore_uuid4_fixture_regression.rs new file mode 100644 index 0000000..805f095 --- /dev/null +++ b/tests/gcore_uuid4_fixture_regression.rs @@ -0,0 +1,105 @@ +use serde_json::Value; + +const CANONICAL_EXAMPLE: &str = "e3c6ee77-48cb-416b-b204-1b492cc776e3"; + +fn is_canonical_uuid4(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8, 13, 18, 23] + .into_iter() + .all(|index| bytes[index] == b'-') + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit()) + && bytes[14] == b'4' + && matches!(bytes[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b') +} + +fn validate_example_value(value: &Value, path: &str, checked: &mut usize) { + match value { + Value::String(example) => { + assert!( + is_canonical_uuid4(example), + "{path} has a noncanonical UUIDv4 example: {example}" + ); + *checked += 1; + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + validate_example_value(value, &format!("{path}/{index}"), checked); + } + } + Value::Object(values) => { + for (key, value) in values { + validate_example_value(value, &format!("{path}/{key}"), checked); + } + } + Value::Null => {} + other => panic!("{path} has a non-string UUIDv4 example: {other}"), + } +} + +fn validate_attached_uuid4_examples(value: &Value, path: &str, checked: &mut usize) { + match value { + Value::Object(object) => { + if object.get("format").and_then(Value::as_str) == Some("uuid4") { + for keyword in ["example", "examples"] { + if let Some(examples) = object.get(keyword) { + validate_example_value(examples, &format!("{path}/{keyword}"), checked); + } + } + } + for (key, value) in object { + validate_attached_uuid4_examples(value, &format!("{path}/{key}"), checked); + } + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + validate_attached_uuid4_examples(value, &format!("{path}/{index}"), checked); + } + } + _ => {} + } +} + +#[test] +fn every_direct_gcore_uuid4_example_is_canonical() { + let source = std::fs::read_to_string("specs/gcore.yaml").expect("read Gcore fixture"); + for malformed in [ + "e3c6ee77-48cb-416b-b204-11b492cc776e3", + "024a29e-b4b7-4c91-9a46-505be123d9f8", + "123e4567-e89b-12d3-a456-426614174000", + ] { + assert!( + !source.contains(malformed), + "Gcore fixture still contains malformed UUID example {malformed}" + ); + } + + let document: Value = serde_yaml::from_str(&source).expect("Gcore fixture is valid YAML"); + let mut checked = 0; + validate_attached_uuid4_examples(&document, "", &mut checked); + assert!( + checked >= 12, + "expected to exercise at least the 12 repaired directly attached uuid4 examples, checked {checked}" + ); +} + +#[test] +fn nested_component_examples_use_the_repaired_uuid4() { + let source = std::fs::read_to_string("specs/gcore.yaml").expect("read Gcore fixture"); + let document: Value = serde_yaml::from_str(&source).expect("Gcore fixture is valid YAML"); + for pointer in [ + "/components/schemas/CreateBareMetalSubnetInterfaceSerializer/examples/0/subnet_id", + "/components/schemas/InstancePricingPreviewV2RequestSerializer/examples/0/interfaces/1/subnet_id", + "/components/schemas/NewInterfaceSpecificSubnetFipSerializerPydantic/examples/0/subnet_id", + ] { + let example = document + .pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("missing nested Gcore UUID example at {pointer}")); + assert_eq!(example, CANONICAL_EXAMPLE, "{pointer}"); + assert!(is_canonical_uuid4(example), "{pointer}: {example}"); + } +} diff --git a/tests/generation_requirements_test.rs b/tests/generation_requirements_test.rs index 191ee23..797ea7d 100644 --- a/tests/generation_requirements_test.rs +++ b/tests/generation_requirements_test.rs @@ -105,6 +105,7 @@ fn requirements_spec() -> serde_json::Value { "elapsed": { "type": "string", "format": "duration" }, "encoded": { "type": "string", "format": "byte" }, "raw": { "type": "string", "format": "binary" }, + "note": { "type": "string", "nullable": true }, "resource": { "type": "string", "format": "uri" } } }, diff --git a/tests/gitpod_required_property_fixture_regression.rs b/tests/gitpod_required_property_fixture_regression.rs new file mode 100644 index 0000000..b8c3962 --- /dev/null +++ b/tests/gitpod_required_property_fixture_regression.rs @@ -0,0 +1,40 @@ +use serde_json::Value; + +#[test] +fn closed_gitpod_components_do_not_require_undeclared_wire_names() { + let source = std::fs::read_to_string("specs/gitpod.yaml").expect("read Gitpod fixture"); + let document: Value = serde_yaml::from_str(&source).expect("Gitpod fixture is valid YAML"); + let schemas = document["components"]["schemas"] + .as_object() + .expect("Gitpod component schemas"); + + let mut mismatches = Vec::new(); + for (name, schema) in schemas { + if schema["additionalProperties"].as_bool() != Some(false) { + continue; + } + let Some(required) = schema["required"].as_array() else { + continue; + }; + let properties = schema["properties"].as_object(); + for required_name in required.iter().filter_map(Value::as_str) { + if properties.is_none_or(|properties| !properties.contains_key(required_name)) { + mismatches.push(format!("{name}.{required_name}")); + } + } + } + + assert!( + mismatches.is_empty(), + "closed Gitpod schemas require undeclared JSON property names: {mismatches:?}" + ); + for component in ["gitpod.v1.Service", "gitpod.v1.Task"] { + let schema = &schemas[component]; + assert!(schema["properties"].get("environmentId").is_some()); + assert!( + schema["required"] + .as_array() + .is_some_and(|required| required.iter().any(|name| name == "environmentId")) + ); + } +} diff --git a/tests/heterogeneous_array_union_test.rs b/tests/heterogeneous_array_union_test.rs new file mode 100644 index 0000000..6c0d6c0 --- /dev/null +++ b/tests/heterogeneous_array_union_test.rs @@ -0,0 +1,125 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{collections::HashSet, fs, process::Command}; + +fn heterogeneous_array_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "heterogeneous array union", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "CompletionPrompt": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { "type": "array", "items": { "type": "integer" } }, + { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + ] + } + } } + }) +} + +#[test] +fn heterogeneous_array_branches_have_distinct_deterministic_targets() { + let analyze = || { + SchemaAnalyzer::new(heterogeneous_array_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze") + }; + let first = analyze(); + let second = analyze(); + let targets = |analysis: &openapi_to_rust::SchemaAnalysis| { + let SchemaType::Union { variants, .. } = &analysis.schemas["CompletionPrompt"].schema_type + else { + panic!("CompletionPrompt should be a union"); + }; + variants + .iter() + .map(|variant| variant.target.clone()) + .collect::>() + }; + let first_targets = targets(&first); + assert_eq!(first_targets, targets(&second)); + assert_eq!(first_targets.len(), 4); + assert_eq!( + first_targets.iter().collect::>().len(), + first_targets.len(), + "no array branch may overwrite another alias: {first_targets:?}" + ); +} + +#[test] +fn generated_flat_and_nested_array_branches_round_trip() { + let mut analysis = SchemaAnalyzer::new(heterogeneous_array_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod heterogeneous_array_runtime { + use super::CompletionPrompt; + + #[test] + fn every_wire_shape_survives() { + for input in [ + serde_json::json!("prompt"), + serde_json::json!(["one", "two"]), + serde_json::json!([1, 2, 3]), + serde_json::json!([[1, 2], [3, 4]]), + ] { + let hydrated: CompletionPrompt = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "heterogeneous-array-union-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/heterogeneous-array-union-smoke"), + ) + .output() + .expect("run generated heterogeneous-array test"); + assert!( + output.status.success(), + "generated heterogeneous array union failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/inline_object_array_test.rs b/tests/inline_object_array_test.rs index 8121e04..3b64b87 100644 --- a/tests/inline_object_array_test.rs +++ b/tests/inline_object_array_test.rs @@ -355,10 +355,11 @@ fn test_inline_object_with_nullable_fields() { "Should generate a struct for data array items" ); - // Check that nullable field is properly typed as Option + // The field is both optional and nullable, so retain the distinction + // between a missing key and an explicit JSON null. assert!( - result.contains("pub optional_field: Option"), - "Nullable field should be Option" + result.contains("pub optional_field: Option>"), + "Optional nullable field should be Option>" ); // Check that required field is not Option diff --git a/tests/inline_union_branch_collision_test.rs b/tests/inline_union_branch_collision_test.rs new file mode 100644 index 0000000..ed07851 --- /dev/null +++ b/tests/inline_union_branch_collision_test.rs @@ -0,0 +1,250 @@ +use openapi_to_rust::analysis::{SchemaAnalysis, SchemaRef, SchemaType}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn analyze(spec: Value) -> SchemaAnalysis { + SchemaAnalyzer::new(spec) + .expect("spec should parse") + .analyze() + .expect("spec should analyze") +} + +fn union_targets(analysis: &SchemaAnalysis, name: &str) -> Vec { + match &analysis.schemas[name].schema_type { + SchemaType::Union { variants, .. } => variants + .iter() + .map(|SchemaRef { target, .. }| target.clone()) + .collect(), + other => panic!("{name} should be an untagged union, got {other:?}"), + } +} + +#[test] +fn discriminated_equal_inline_branches_keep_distinct_provenance() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "discriminated-branch-collision", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "RepeatedDiscriminated": { + "oneOf": [ + false, + { + "type": "object", + "properties": { "payload": { "type": "string" } }, + "required": ["payload"] + }, + { + "type": "object", + "properties": { "payload": { "type": "string" } }, + "required": ["payload"] + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "first": "#/components/schemas/RepeatedDiscriminated/variant_1", + "second": "#/components/schemas/RepeatedDiscriminated/variant_2" + } + } + } + } + } + }); + + let mut analysis = analyze(spec); + let SchemaType::DiscriminatedUnion { variants, .. } = + &analysis.schemas["RepeatedDiscriminated"].schema_type + else { + panic!("RepeatedDiscriminated should be a discriminated union"); + }; + + assert_eq!(variants.len(), 2); + assert_eq!(variants[0].discriminator_value, "first"); + assert_eq!(variants[1].discriminator_value, "second"); + assert_eq!(variants[0].schema_ref, "inline_1"); + assert_eq!(variants[1].schema_ref, "inline_2"); + assert_ne!(variants[0].type_name, variants[1].type_name); + assert!(analysis.schemas.contains_key(&variants[0].type_name)); + assert!(analysis.schemas.contains_key(&variants[1].type_name)); + + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("distinct discriminated branch payloads should generate"); +} + +#[test] +fn inferred_anyof_discriminator_keeps_branch_scoped_payloads() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "inferred-anyof-branch-collision", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "InferredAnyOf": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { "const": "alpha" }, + "payload": { "type": "string" } + }, + "required": ["kind", "payload"] + }, + { + "type": "object", + "properties": { + "kind": { "const": "beta" }, + "payload": { "type": "string" } + }, + "required": ["kind", "payload"] + } + ] + } + } + } + }); + + let mut analysis = analyze(spec); + let SchemaType::DiscriminatedUnion { + discriminator_field, + variants, + .. + } = &analysis.schemas["InferredAnyOf"].schema_type + else { + panic!("InferredAnyOf should infer a discriminated union"); + }; + + assert_eq!(discriminator_field, "kind"); + assert_eq!(variants.len(), 2); + assert_eq!(variants[0].discriminator_value, "alpha"); + assert_eq!(variants[1].discriminator_value, "beta"); + assert_ne!(variants[0].type_name, variants[1].type_name); + + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("inferred anyOf branch payloads should generate"); +} + +#[test] +fn untagged_oneof_and_anyof_equal_inline_branches_do_not_alias() { + let repeated_branch = json!({ + "type": "object", + "properties": { "common": { "type": "string" } }, + "required": ["common"] + }); + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "untagged-branch-collision", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "RepeatedOneOf": { + "oneOf": [repeated_branch.clone(), repeated_branch.clone()] + }, + "RepeatedAnyOf": { + "anyOf": [repeated_branch.clone(), repeated_branch] + } + } + } + }); + + let mut analysis = analyze(spec); + let one_of_targets = union_targets(&analysis, "RepeatedOneOf"); + let any_of_targets = union_targets(&analysis, "RepeatedAnyOf"); + + assert_eq!(one_of_targets.len(), 2); + assert_ne!(one_of_targets[0], one_of_targets[1]); + assert_eq!(any_of_targets.len(), 2); + assert_ne!(any_of_targets[0], any_of_targets[1]); + for target in one_of_targets.iter().chain(&any_of_targets) { + assert!(analysis.schemas.contains_key(target)); + } + + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("distinct untagged branch payloads should generate"); +} + +#[test] +fn source_field_union_does_not_emit_its_int32_branch_as_a_self_variant() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "cloudflare-source-field", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "ListField": { + "type": "object", + "properties": { "items": { "$ref": "#/components/schemas/SourceField" } }, + "required": ["items"] + }, + "SourceField": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"], + "discriminator": { "propertyName": "type" }, + "anyOf": [ + { + "title": "Int32", + "type": "object", + "properties": { "type": { "type": "string", "enum": ["int32"] } }, + "required": ["type"] + }, + { + "title": "Int64", + "type": "object", + "properties": { "type": { "type": "string", "enum": ["int64"] } }, + "required": ["type"] + }, + { + "title": "List", + "allOf": [ + { "$ref": "#/components/schemas/ListField" }, + { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["list"] } + }, + "required": ["type"] + } + ] + } + ] + } + } + } + }); + + let mut analysis = analyze(spec); + let SchemaType::Object { + variant: Some(variant), + .. + } = &analysis.schemas["SourceField"].schema_type + else { + panic!("SourceField should retain its base fields and flattened variants"); + }; + let union_name = variant.target.clone(); + let SchemaType::DiscriminatedUnion { variants, .. } = + &analysis.schemas[&union_name].schema_type + else { + panic!("SourceField flattened variant should be discriminated"); + }; + + assert_eq!(variants.len(), 3); + assert!(variants.iter().all(|branch| branch.type_name != union_name)); + assert_eq!( + variants + .iter() + .map(|branch| branch.type_name.as_str()) + .collect::>() + .len(), + 3 + ); + + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("recursive SourceField/ListField models should generate"); + assert!(!generated.contains(&format!("Box<{union_name}>"))); +} diff --git a/tests/nested_inline_path_test.rs b/tests/nested_inline_path_test.rs new file mode 100644 index 0000000..9f4fcc7 --- /dev/null +++ b/tests/nested_inline_path_test.rs @@ -0,0 +1,224 @@ +use openapi_to_rust::analysis::{ObjectAdditionalProperties, SchemaType}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn nested_inline_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "nested inline paths", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "Root": { + "type": "object", + "additionalProperties": false, + "required": ["created_by_user", "org", "wrapper", "users", "composed", "extras"], + "properties": { + "created_by_user": { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string" } } + } + } + }, + "org": { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["slug"], + "properties": { "slug": { "type": "string" } } + } + } + }, + "wrapper": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["email"], + "properties": { "email": { "type": "string" } } + } + } + }, + "users": { + "type": "array", + "items": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["active"], + "properties": { "active": { "type": "boolean" } } + } + } + } + }, + "composed": { + "allOf": [{ + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["token"], + "properties": { "token": { "type": "string" } } + } + } + }] + }, + "extras": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["label"], + "properties": { "label": { "type": "string" } } + } + } + } + } + } } + }) +} + +fn referenced_property<'a>( + analysis: &'a openapi_to_rust::SchemaAnalysis, + owner: &str, + field: &str, +) -> &'a str { + let SchemaType::Object { properties, .. } = &analysis.schemas[owner].schema_type else { + panic!("{owner} should be an object"); + }; + let SchemaType::Reference { target } = &properties[field].schema_type else { + panic!("{owner}.{field} should reference a named inline schema"); + }; + target +} + +#[test] +fn every_nested_inline_container_uses_its_complete_owner_path() { + let analysis = SchemaAnalyzer::new(nested_inline_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + + assert_eq!( + referenced_property(&analysis, "Root", "created_by_user"), + "RootCreatedByUser" + ); + assert_eq!( + referenced_property(&analysis, "RootCreatedByUser", "data"), + "RootCreatedByUserData" + ); + assert_eq!(referenced_property(&analysis, "Root", "org"), "RootOrg"); + assert_eq!( + referenced_property(&analysis, "RootOrg", "data"), + "RootOrgData" + ); + assert_eq!( + referenced_property(&analysis, "RootWrapper", "user"), + "RootWrapperUser" + ); + assert_eq!( + referenced_property(&analysis, "RootUsersItem", "user"), + "RootUsersItemUser" + ); + assert_eq!( + referenced_property(&analysis, "RootComposed", "data"), + "RootComposedData" + ); + + let SchemaType::Object { + additional_properties, + .. + } = &analysis.schemas["RootExtras"].schema_type + else { + panic!("RootExtras should be an object"); + }; + let ObjectAdditionalProperties::Typed { value_type } = additional_properties else { + panic!("RootExtras should retain typed additional properties"); + }; + let SchemaType::Reference { target } = value_type.as_ref() else { + panic!("additional-property objects should be named"); + }; + assert_eq!(target, "RootExtrasAdditionalProperty"); +} + +#[test] +fn generated_nested_inline_shapes_round_trip_without_cross_path_overwrites() { + let mut analysis = SchemaAnalyzer::new(nested_inline_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod nested_inline_path_runtime { + use super::Root; + + #[test] + fn every_nested_shape_survives_hydration_and_serialization() { + let input = serde_json::json!({ + "created_by_user": {"data": {"name": "Ada"}}, + "org": {"data": {"slug": "compiler-team"}}, + "wrapper": {"user": {"email": "ada@example.test"}}, + "users": [{"user": {"active": true}}], + "composed": {"data": {"token": "secret"}}, + "extras": {"primary": {"label": "one"}} + }); + let hydrated: Root = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "nested-inline-path-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/nested-inline-path-smoke"), + ) + .output() + .expect("run generated nested-inline test"); + assert!( + output.status.success(), + "generated nested-inline round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/null_only_enum_test.rs b/tests/null_only_enum_test.rs new file mode 100644 index 0000000..223c2c3 --- /dev/null +++ b/tests/null_only_enum_test.rs @@ -0,0 +1,160 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn null_enum_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "null-only enum", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "NullEnum": { "enum": [null] }, + "NullConst": { "const": null }, + "StringNull": { "type": "string", "enum": ["null"] }, + "NullableStatus": { + "type": "string", "enum": ["ready"], "nullable": true + }, + "Payload": { + "type": "object", + "required": ["value"], + "properties": { "value": { "type": "string" } } + }, + "NullOrPayload": { + "oneOf": [ + { "$ref": "#/components/schemas/NullEnum" }, + { "$ref": "#/components/schemas/Payload" } + ] + }, + "Envelope": { + "type": "object", + "required": ["enum_null", "const_null", "text", "status", "composed"], + "properties": { + "enum_null": { "$ref": "#/components/schemas/NullEnum" }, + "const_null": { "$ref": "#/components/schemas/NullConst" }, + "text": { "$ref": "#/components/schemas/StringNull" }, + "status": { "$ref": "#/components/schemas/NullableStatus" }, + "composed": { "$ref": "#/components/schemas/NullOrPayload" } + } + } + } + } + }) +} + +#[test] +fn analyzer_and_generator_keep_json_null_distinct_from_string_enums() { + let analysis = SchemaAnalyzer::new(null_enum_spec()) + .expect("null-enum spec should parse") + .analyze() + .expect("null-enum spec should analyze"); + for name in ["NullEnum", "NullConst"] { + assert!(matches!( + &analysis.schemas[name].schema_type, + SchemaType::Primitive { rust_type, .. } if rust_type == "()" + )); + } + assert!(matches!( + &analysis.schemas["StringNull"].schema_type, + SchemaType::StringEnum { values } if values == &["null"] + )); + assert!(matches!( + &analysis.schemas["NullableStatus"].schema_type, + SchemaType::StringEnum { values } if values == &["ready"] + )); + assert!(analysis.schemas["NullableStatus"].nullable); + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("null-only schemas should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("pubtypeNullEnum=();")); + assert!(compact.contains("pubtypeNullConst=();")); + assert!(compact.contains("pubenumStringNull")); + assert!(compact.contains("NullEnum(NullEnum)")); + assert!(!compact.contains("pubenumNullEnum")); +} + +#[test] +fn generated_null_only_types_round_trip_directly_and_in_composition() { + let mut analysis = SchemaAnalyzer::new(null_enum_spec()) + .expect("null-enum spec should parse") + .analyze() + .expect("null-enum spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("null-only schemas should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod null_only_roundtrip { + use super::{Envelope, NullEnum, NullOrPayload}; + + #[test] + fn null_is_exact_and_non_null_is_rejected() { + let direct: NullEnum = serde_json::from_value(serde_json::json!(null)).unwrap(); + assert_eq!(serde_json::to_value(direct).unwrap(), serde_json::json!(null)); + assert!(serde_json::from_value::(serde_json::json!("null")).is_err()); + + for input in [serde_json::json!(null), serde_json::json!({"value": "known"})] { + let value: NullOrPayload = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(value).unwrap(), input); + } + } + + #[test] + fn required_null_fields_and_composed_null_are_stable() { + let input = serde_json::json!({ + "enum_null": null, + "const_null": null, + "text": "null", + "status": null, + "composed": null + }); + let hydrated: Envelope = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(hydrated).unwrap(); + assert_eq!(output, input); + let stable: Envelope = serde_json::from_value(output).unwrap(); + assert_eq!(serde_json::to_value(stable).unwrap(), input); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "null-only-enum-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/null-only-enum-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated null-only round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/nullable_container_values_test.rs b/tests/nullable_container_values_test.rs new file mode 100644 index 0000000..af816a7 --- /dev/null +++ b/tests/nullable_container_values_test.rs @@ -0,0 +1,287 @@ +use openapi_to_rust::analysis::{ObjectAdditionalProperties, SchemaType}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn legacy_spec() -> Value { + json!({ + "openapi": "3.0.3", + "info": { "title": "legacy nullable container values", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "LegacyNode": { + "type": "object", + "nullable": true, + "required": ["name"], + "properties": { "name": { "type": "string" } } + }, + "LegacyAlias": { "$ref": "#/components/schemas/LegacyNode" }, + "LegacyEnvelope": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/LegacyAlias" } + } + } + } + } + } + }) +} + +fn modern_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "nullable container values", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Node": { + "type": "object", + "required": ["name", "children"], + "properties": { + "name": { "type": "string" }, + "children": { + "type": "array", + "items": { + "anyOf": [ + { "$ref": "#/components/schemas/Node" }, + { "type": "null" } + ] + } + } + } + }, + "MaybeNode": { + "anyOf": [ + { "$ref": "#/components/schemas/Node" }, + { "type": "null" } + ] + }, + "MaybeNodeAlias": { "$ref": "#/components/schemas/MaybeNode" }, + "NodePair": { + "type": "array", + "prefixItems": [ + { "$ref": "#/components/schemas/MaybeNodeAlias" }, + { "$ref": "#/components/schemas/MaybeNode" } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "NodeLookup": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MaybeNodeAlias" + } + }, + "Envelope": { + "type": "object", + "required": ["items", "nested", "pair", "lookup"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/MaybeNodeAlias" } + }, + "nested": { + "type": "array", + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/MaybeNode" } + } + }, + "pair": { "$ref": "#/components/schemas/NodePair" }, + "lookup": { "$ref": "#/components/schemas/NodeLookup" } + } + } + } + } + }) +} + +fn assert_nullable_reference(schema_type: &SchemaType, target: &str) { + let SchemaType::Nullable { inner_type } = schema_type else { + panic!("expected nullable container value, got {schema_type:?}"); + }; + assert!( + matches!(inner_type.as_ref(), SchemaType::Reference { target: actual } if actual == target), + "expected nullable reference to {target}, got {inner_type:?}" + ); +} + +#[test] +fn openapi_30_nullable_reference_chains_reach_array_items() { + let analysis = SchemaAnalyzer::new(legacy_spec()) + .expect("legacy spec should parse") + .analyze() + .expect("legacy spec should analyze"); + let SchemaType::Object { properties, .. } = &analysis.schemas["LegacyEnvelope"].schema_type + else { + panic!("LegacyEnvelope should be an object"); + }; + let SchemaType::Array { item_type } = &properties["items"].schema_type else { + panic!("items should be an array"); + }; + assert_nullable_reference(item_type, "LegacyAlias"); + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("legacy nullable array should generate"); + assert!( + generated.contains("pub items: Vec>"), + "legacy nullable item reference was not wrapped:\n{generated}" + ); +} + +#[test] +fn nullable_container_ir_covers_nested_arrays_tuples_maps_and_recursive_boxing() { + let analysis = SchemaAnalyzer::new(modern_spec()) + .expect("modern spec should parse") + .analyze() + .expect("modern spec should analyze"); + + let SchemaType::Object { properties, .. } = &analysis.schemas["Envelope"].schema_type else { + panic!("Envelope should be an object"); + }; + let SchemaType::Array { item_type } = &properties["items"].schema_type else { + panic!("items should be an array"); + }; + assert_nullable_reference(item_type, "MaybeNodeAlias"); + + let SchemaType::Array { item_type } = &properties["nested"].schema_type else { + panic!("nested should be an array"); + }; + let SchemaType::Array { + item_type: nested_item, + } = item_type.as_ref() + else { + panic!("nested items should themselves be arrays: {item_type:?}"); + }; + assert_nullable_reference(nested_item, "MaybeNode"); + + let SchemaType::Tuple { element_types } = &analysis.schemas["NodePair"].schema_type else { + panic!("NodePair should be an exact tuple"); + }; + assert_nullable_reference(&element_types[0], "MaybeNodeAlias"); + assert_nullable_reference(&element_types[1], "MaybeNode"); + + let SchemaType::Object { + additional_properties: ObjectAdditionalProperties::Typed { value_type }, + .. + } = &analysis.schemas["NodeLookup"].schema_type + else { + panic!("NodeLookup should have typed additional properties"); + }; + assert_nullable_reference(value_type, "MaybeNodeAlias"); + + let SchemaType::Object { + properties: node_properties, + .. + } = &analysis.schemas["Node"].schema_type + else { + panic!("Node should be an object"); + }; + let SchemaType::Array { item_type } = &node_properties["children"].schema_type else { + panic!("Node.children should be an array"); + }; + assert_nullable_reference(item_type, "Node"); + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("modern nullable containers should generate"); + let compact = generated.split_whitespace().collect::(); + for expected in [ + // Vec already supplies the recursive indirection, so the nullable + // wrapper belongs immediately around the element type. + "pubchildren:Vec>", + "pubitems:Vec>", + "pubnested:Vec>>", + "pubtypeNodePair=(Option,Option);", + "BTreeMap,>", + ] { + assert!( + compact.contains(expected), + "missing generated nullable-container fragment {expected:?}:\n{generated}" + ); + } +} + +#[test] +fn generated_nullable_containers_round_trip_explicit_nulls_exactly() { + let mut analysis = SchemaAnalyzer::new(modern_spec()) + .expect("modern spec should parse") + .analyze() + .expect("modern spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("modern nullable containers should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod nullable_container_roundtrip { + use super::Envelope; + + #[test] + fn explicit_null_elements_survive_every_container() { + let input = serde_json::json!({ + "items": [ + null, + {"name": "item", "children": [null, {"name": "leaf", "children": []}]} + ], + "nested": [[null, {"name": "nested", "children": []}]], + "pair": [null, {"name": "pair", "children": [null]}], + "lookup": { + "missing": null, + "present": {"name": "map", "children": []} + } + }); + let hydrated: Envelope = serde_json::from_value(input.clone()).expect("hydrate"); + let output = serde_json::to_value(hydrated).expect("serialize"); + assert_eq!(output, input); + let stable: Envelope = serde_json::from_value(output).expect("rehydrate"); + assert_eq!(serde_json::to_value(stable).unwrap(), input); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "nullable-container-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/nullable-container-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated nullable-container round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/nullable_reference_sibling_test.rs b/tests/nullable_reference_sibling_test.rs new file mode 100644 index 0000000..b976d7a --- /dev/null +++ b/tests/nullable_reference_sibling_test.rs @@ -0,0 +1,218 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn reference_sibling_spec() -> Value { + json!({ + "openapi": "3.0.3", + "info": { "title": "nullable reference siblings", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Reason": { + "type": "string", + "enum": ["stop", "length"] + }, + "ReasonAlias": { "$ref": "#/components/schemas/Reason" }, + "Envelope": { + "type": "object", + "required": ["required_nullable", "required_plain"], + "properties": { + "required_nullable": { + "$ref": "#/components/schemas/ReasonAlias", + "nullable": true + }, + "required_plain": { + "$ref": "#/components/schemas/ReasonAlias", + "nullable": false + }, + "optional_nullable": { + "$ref": "#/components/schemas/Reason", + "nullable": true + } + } + }, + "NullableReasonList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReasonAlias", + "nullable": true + } + }, + "MinCount": { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "note": { "type": "string", "nullable": true } + } + }, + "MaxCount": { + "type": "object", + "additionalProperties": false, + "maxProperties": 1, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "note": { "type": "string", "nullable": true } + } + } + } + } + }) +} + +#[test] +fn analyzer_and_generator_honor_local_nullable_reference_siblings() { + let analysis = SchemaAnalyzer::new(reference_sibling_spec()) + .expect("reference-sibling spec should parse") + .analyze() + .expect("reference-sibling spec should analyze"); + let SchemaType::Object { + properties, + required, + .. + } = &analysis.schemas["Envelope"].schema_type + else { + panic!("Envelope should be an object"); + }; + assert!(required.contains("required_nullable")); + assert!(properties["required_nullable"].nullable); + assert!(!properties["required_plain"].nullable); + assert!(properties["optional_nullable"].nullable); + + let SchemaType::Array { item_type } = &analysis.schemas["NullableReasonList"].schema_type + else { + panic!("NullableReasonList should be an array"); + }; + assert!(matches!( + item_type.as_ref(), + SchemaType::Nullable { inner_type } + if matches!(inner_type.as_ref(), SchemaType::Reference { target } if target == "ReasonAlias") + )); + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("nullable reference siblings should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("pubrequired_nullable:Option")); + assert!(compact.contains("pubrequired_plain:ReasonAlias")); + assert!(compact.contains("puboptional_nullable:Option>")); + assert!(compact.contains("pubtypeNullableReasonList=Vec>")); + + assert!( + !compact.contains("#[serde(skip_serializing_if=\"Option::is_none\")]pubrequired_nullable"), + "required nullable references must serialize None as JSON null:\n{generated}" + ); +} + +#[test] +fn generated_required_nullable_reference_round_trips_explicit_null() { + let mut analysis = SchemaAnalyzer::new(reference_sibling_spec()) + .expect("reference-sibling spec should parse") + .analyze() + .expect("reference-sibling spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("nullable reference siblings should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod nullable_reference_sibling_roundtrip { + use super::{Envelope, MaxCount, MinCount}; + + #[test] + fn required_null_is_present_and_stable() { + let input = serde_json::json!({ + "required_nullable": null, + "required_plain": "stop" + }); + let hydrated: Envelope = serde_json::from_value(input.clone()).expect("hydrate null"); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + + let non_null = serde_json::json!({ + "required_nullable": "length", + "required_plain": "stop" + }); + let hydrated: Envelope = serde_json::from_value(non_null.clone()).expect("hydrate value"); + assert_eq!(serde_json::to_value(hydrated).unwrap(), non_null); + + let missing_optional = serde_json::json!({ + "required_nullable": null, + "required_plain": "stop" + }); + let hydrated: Envelope = serde_json::from_value(missing_optional.clone()).unwrap(); + assert!(hydrated.optional_nullable.is_none()); + assert_eq!(serde_json::to_value(hydrated).unwrap(), missing_optional); + + let explicit_optional_null = serde_json::json!({ + "required_nullable": null, + "required_plain": "stop", + "optional_nullable": null + }); + let hydrated: Envelope = serde_json::from_value(explicit_optional_null.clone()).unwrap(); + assert_eq!(hydrated.optional_nullable, Some(None)); + assert_eq!(serde_json::to_value(hydrated).unwrap(), explicit_optional_null); + + let optional_value = serde_json::json!({ + "required_nullable": null, + "required_plain": "stop", + "optional_nullable": "length" + }); + let hydrated: Envelope = serde_json::from_value(optional_value.clone()).unwrap(); + assert!(matches!(hydrated.optional_nullable, Some(Some(_)))); + assert_eq!(serde_json::to_value(hydrated).unwrap(), optional_value); + + let min_properties = serde_json::json!({"id": "minimum", "note": null}); + let hydrated: MinCount = serde_json::from_value(min_properties.clone()).unwrap(); + assert_eq!(hydrated.note, Some(None)); + assert_eq!(serde_json::to_value(hydrated).unwrap(), min_properties); + + let max_properties = serde_json::json!({"id": "maximum"}); + let hydrated: MaxCount = serde_json::from_value(max_properties.clone()).unwrap(); + assert!(hydrated.note.is_none()); + assert_eq!(serde_json::to_value(hydrated).unwrap(), max_properties); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "nullable-reference-sibling-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/nullable-reference-sibling-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated nullable-reference round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/nullable_type_array_test.rs b/tests/nullable_type_array_test.rs index 3ec6d0e..6b5f236 100644 --- a/tests/nullable_type_array_test.rs +++ b/tests/nullable_type_array_test.rs @@ -40,6 +40,12 @@ fn required_type_array_null_property_is_optional() { result.contains("pub pool: Option"), "A required property typed [\"string\", \"null\"] must be Option, got:\n{result}" ); + assert!( + !result.contains( + "#[serde(skip_serializing_if = \"Option::is_none\")]\n pub pool: Option" + ), + "A required nullable property must serialize None as JSON null, got:\n{result}" + ); assert!( result.contains("pub id: String") && !result.contains("pub id: Option"), "A required non-nullable property must stay non-Option, got:\n{result}" @@ -123,5 +129,49 @@ fn all_three_nullability_spellings_agree() { result.contains(&format!("pub {field}: Option<")), "required nullable `{field}` must be Option, got:\n{result}" ); + assert!( + !result.contains(&format!( + "#[serde(skip_serializing_if = \"Option::is_none\")]\n pub {field}: Option<" + )), + "required nullable `{field}` must serialize None as JSON null, got:\n{result}" + ); } } + +#[test] +fn required_reference_inherits_component_nullability() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "NullableItems": { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"} + ] + }, + "Envelope": { + "type": "object", + "required": ["items"], + "properties": { + "items": {"$ref": "#/components/schemas/NullableItems"} + } + } + } + } + }); + + let result = test_generation("nullable_component_reference", spec).expect("Generation failed"); + + assert!( + result.contains("pub items: Option"), + "a field must inherit nullability from its referenced component, got:\n{result}" + ); + assert!( + !result.contains( + "#[serde(skip_serializing_if = \"Option::is_none\")]\n pub items: Option" + ), + "a required nullable reference must emit explicit JSON null, got:\n{result}" + ); +} diff --git a/tests/nullable_union_reference_branch_test.rs b/tests/nullable_union_reference_branch_test.rs new file mode 100644 index 0000000..26319f8 --- /dev/null +++ b/tests/nullable_union_reference_branch_test.rs @@ -0,0 +1,170 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn nullable_union_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "nullable union reference branches", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "A": { + "type": "object", + "required": ["a"], + "properties": { "a": { "type": "string" } } + }, + "NullableB": { + "type": "object", + "nullable": true, + "required": ["b"], + "properties": { "b": { "type": "string" } } + }, + "NullableBAlias": { "$ref": "#/components/schemas/NullableB" }, + "OneDirect": { + "oneOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/NullableB" } + ] + }, + "OneChained": { + "oneOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/NullableBAlias" } + ] + }, + "AnyDirect": { + "anyOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/NullableB" } + ] + }, + "AnyChained": { + "anyOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/NullableBAlias" } + ] + } + } + } + }) +} + +#[test] +fn direct_and_chained_union_reference_branches_retain_nullability() { + let analysis = SchemaAnalyzer::new(nullable_union_spec()) + .expect("nullable-union spec should parse") + .analyze() + .expect("nullable-union spec should analyze"); + + for (union_name, nullable_target) in [ + ("OneDirect", "NullableB"), + ("OneChained", "NullableBAlias"), + ("AnyDirect", "NullableB"), + ("AnyChained", "NullableBAlias"), + ] { + let SchemaType::Union { variants, .. } = &analysis.schemas[union_name].schema_type else { + panic!("{union_name} should be an untagged union"); + }; + assert_eq!(variants.len(), 2, "{union_name}: {variants:?}"); + assert!(!variants[0].nullable, "A must remain non-nullable"); + assert_eq!(variants[1].target, nullable_target); + assert!( + variants[1].nullable, + "{union_name} must retain target nullability: {variants:?}" + ); + } + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("nullable union references should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("NullableB(Option)")); + assert!(compact.contains("NullableBAlias(Option)")); + assert!( + !compact.contains("A(Option)"), + "non-null branches must not be widened:\n{generated}" + ); +} + +#[test] +fn generated_nullable_union_branches_round_trip_null_and_objects() { + let mut analysis = SchemaAnalyzer::new(nullable_union_spec()) + .expect("nullable-union spec should parse") + .analyze() + .expect("nullable-union spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("nullable union references should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod nullable_union_roundtrip { + use super::{AnyChained, AnyDirect, OneChained, OneDirect}; + use serde::{Serialize, de::DeserializeOwned}; + + fn stable(input: serde_json::Value) + where + T: DeserializeOwned + Serialize, + { + let hydrated: T = serde_json::from_value(input.clone()).expect("hydrate"); + let output = serde_json::to_value(hydrated).expect("serialize"); + assert_eq!(output, input); + let hydrated_again: T = serde_json::from_value(output).expect("rehydrate"); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), input); + } + + #[test] + fn every_union_accepts_its_source_valid_shapes() { + for input in [ + serde_json::json!(null), + serde_json::json!({"a": "first"}), + serde_json::json!({"b": "second"}), + ] { + stable::(input.clone()); + stable::(input.clone()); + stable::(input.clone()); + stable::(input); + } + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "nullable-union-reference-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/nullable-union-reference-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated nullable-union round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/oneof_untagged_tests.rs b/tests/oneof_untagged_tests.rs index c7c7b6a..7b13713 100644 --- a/tests/oneof_untagged_tests.rs +++ b/tests/oneof_untagged_tests.rs @@ -47,7 +47,7 @@ mod tests { let generator = openapi_to_rust::CodeGenerator::new(Default::default()); let types_content = generator.generate(&mut analysis).unwrap(); - // Should generate untagged union + // Primitive/array branches can use Serde's untagged dispatch. assert!( types_content.contains("#[serde(untagged)]"), "Should generate untagged union" @@ -152,8 +152,9 @@ mod tests { println!("Object variants test output:\n{types_content}"); assert!( - types_content.contains("#[serde(untagged)]"), - "Should generate untagged union" + types_content.contains("impl<'de> Deserialize<'de> for MessageContent") + && types_content.contains("preserved the complete input"), + "Object oneOf should deserialize by exact branch shape" ); assert!( types_content.contains("pub enum MessageContent"), @@ -268,10 +269,13 @@ mod tests { let generator = openapi_to_rust::CodeGenerator::new(Default::default()); let types_content = generator.generate(&mut analysis).unwrap(); - // Should generate tagged union for discriminated oneOf + // Discriminator dispatch is implemented manually so standalone + // variant structs can retain their own discriminator fields. assert!( - types_content.contains("#[serde(tag = \"type\")]"), - "Should generate tagged union for discriminated oneOf" + types_content.contains("match discriminator {") + && types_content.contains("\"cat\" =>") + && types_content.contains("\"dog\" =>"), + "Should deserialize the discriminated oneOf by tag" ); assert!( !types_content.contains("#[serde(untagged)]"), @@ -279,6 +283,75 @@ mod tests { ); } + #[test] + fn test_anyof_preserves_nested_discriminated_oneof_branch() { + let spec_json = minimal_spec(json!({ + "ClearThinking": { + "type": "object", + "properties": { + "keep": { + "anyOf": [ + { + "oneOf": [ + {"$ref": "#/components/schemas/ThinkingTurns"}, + {"$ref": "#/components/schemas/AllThinkingTurns"} + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "thinking_turns": "#/components/schemas/ThinkingTurns", + "all": "#/components/schemas/AllThinkingTurns" + } + } + }, + {"type": "string", "const": "all"} + ] + } + } + }, + "ThinkingTurns": { + "type": "object", + "properties": { + "type": {"type": "string", "const": "thinking_turns"}, + "value": {"type": "integer"} + }, + "required": ["type", "value"] + }, + "AllThinkingTurns": { + "type": "object", + "properties": { + "type": {"type": "string", "const": "all"} + }, + "required": ["type"] + } + })); + + let mut analyzer = openapi_to_rust::SchemaAnalyzer::new(spec_json).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let generator = openapi_to_rust::CodeGenerator::new(Default::default()); + let types_content = generator.generate(&mut analysis).unwrap(); + + assert!( + types_content.contains("pub enum ClearThinkingKeep"), + "outer anyOf should remain a Rust union, got:\n{types_content}" + ); + assert!( + types_content.contains("String(String)"), + "outer anyOf should retain its string branch, got:\n{types_content}" + ); + assert!( + types_content.contains("ThinkingTurns(ThinkingTurns)") + && types_content.contains("AllThinkingTurns(AllThinkingTurns)"), + "nested discriminated oneOf should retain both object variants, got:\n{types_content}" + ); + assert!( + types_content.contains("match discriminator {") + && types_content.contains("\"thinking_turns\" =>") + && types_content.contains("\"all\" =>"), + "nested discriminated oneOf should dispatch on its tag, got:\n{types_content}" + ); + } + #[test] fn test_oneof_deeply_nested() { // Test oneOf in deeply nested properties diff --git a/tests/openai_generation_test.rs b/tests/openai_generation_test.rs index e68db14..d0ee601 100644 --- a/tests/openai_generation_test.rs +++ b/tests/openai_generation_test.rs @@ -191,15 +191,15 @@ mod tests { "Input should NOT be serde_json::Value" ); - // 4. Nullable fields should be Option + // 4. Optional nullable fields preserve missing vs explicit null. assert!( - types_content.contains("pub store: Option"), - "Nullable boolean should be Option" + types_content.contains("pub store: Option>"), + "Optional nullable boolean should be Option>" ); assert!( - types_content.contains("pub max_tokens: Option") - || types_content.contains("pub max_tokens: Option"), - "Nullable integer should be Option" + types_content.contains("pub max_tokens: Option>") + || types_content.contains("pub max_tokens: Option>"), + "Optional nullable integer should be Option>" ); } @@ -299,14 +299,15 @@ mod tests { "Object field should be typed" ); - // Nullable fields + // Both nullable fields are optional, so preserve the difference between + // a missing property and an explicit JSON null. assert!( - types_content.contains("pub content: Option"), - "Nullable content should be Option" + types_content.contains("pub content: Option>"), + "Optional nullable content should be Option>" ); assert!( - types_content.contains("pub finish_reason: Option<"), - "Nullable finish_reason should be Option" + types_content.contains("pub finish_reason: Option>" ); } } diff --git a/tests/operation_builder_test.rs b/tests/operation_builder_test.rs index 5261035..9bea3d0 100644 --- a/tests/operation_builder_test.rs +++ b/tests/operation_builder_test.rs @@ -99,6 +99,7 @@ fn builder_spec() -> serde_json::Value { "properties": { "name": { "type": "string" }, "instructions": { "type": "string" }, + "nullable_note": { "type": ["string", "null"] }, "send": { "type": "string" }, "with_send": { "type": "string" }, "request": { "type": "string" }, @@ -209,6 +210,9 @@ fn mixed_body_builder_is_additive_and_collision_safe() { assert!(compact.contains("pubfntags(mutself,tags:Vec)->Self")); assert!(compact.contains("pubfnx_trace(mutself,x_trace:implInto)->Self")); assert!(compact.contains("pubfninstructions(mutself,instructions:String)->Self")); + assert!(compact.contains("pubfnnullable_note(mutself,nullable_note:String)->Self")); + assert!(compact.contains("pubfnnullable_note_null(mutself)->Self")); + assert!(compact.contains("pubfnnullable_note_absent(mutself)->Self")); assert!(compact.contains("pubfnwith_send(mutself,send:String)->Self")); assert!(compact.contains("pubfnwith_with_send(mutself,with_send:String)->Self")); assert!(compact.contains("pubfnwith_request(mutself,request:String)->Self")); @@ -270,6 +274,9 @@ pub async fn both_calls_compile(client: &generated::HttpClient) { .tags(vec!["a".to_string(), "b".to_string()]) .x_trace("trace-id") .instructions("be concise".to_string()) + .nullable_note("value".to_string()) + .nullable_note_null() + .nullable_note_absent() .with_send("reserved".to_string()) .with_with_send("collision".to_string()) .with_request("field".to_string()) diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs index f508a5d..5a5b030 100644 --- a/tests/recoverable_typing_test.rs +++ b/tests/recoverable_typing_test.rs @@ -65,11 +65,12 @@ fn assert_types(spec: Value, expected: &[&str]) { } #[test] -fn odata_nullable_reference_union_becomes_option() { - // `anyOf: [$ref, {type: object, nullable: true}]` is how OData spells "that - // type, or null" — Microsoft Graph emits it for every navigation property, - // 2,127 times across the corpus. Read literally it is "that type or any - // object", which has no single Rust type. +fn odata_nullable_reference_union_keeps_its_literal_object_branch() { + // Microsoft Graph uses this spelling for navigation properties. Whatever + // the producer intended, `nullable: true` widens the object branch to + // object-or-null; it does not turn it into a null-only marker. Keeping the + // dynamic branch lets generated models hydrate every value the schema + // actually admits. assert_types( spec_with_schemas(json!({ "User": { "type": "object", "additionalProperties": false, @@ -81,7 +82,11 @@ fn odata_nullable_reference_union_becomes_option() { ]} }} })), - &["pub user: Option"], + &[ + "pub user: Option", + "pub enum MemberUser", + "pub type MemberVariant2 = serde_json::Value", + ], ); } @@ -363,30 +368,21 @@ fn a_union_that_only_alternates_requiredness_is_the_object_it_describes() { #[test] fn union_branches_that_are_deep_pointers_are_expanded() { - // PagerDuty builds a request body from three pointers into a response's - // `oneOf`. Every branch resolves, but a union of unresolvable references - // had nothing to build variants from. - let spec = json!({ - "openapi": "3.1.0", - "info": { "title": "typing", "version": "1.0.0" }, - "components": { - "responses": { "CacheData": { - "description": "cache data", - "content": { "application/json": { "schema": { - "oneOf": [{ "type": "string" }, { "type": "number" }] - }}} - }}, - "schemas": { - "PutRequest": { "type": "object", "oneOf": [ - { "$ref": "#/components/responses/CacheData/content/application~1json/schema/oneOf/0" }, - { "$ref": "#/components/responses/CacheData/content/application~1json/schema/oneOf/1" } - ]}, - "Holder": { "type": "object", "additionalProperties": false, "properties": { - "data": { "$ref": "#/components/schemas/PutRequest" } - }} - } - } - }); + // A component-root prefix must not make these look like two references to + // the whole `CacheData` schema. Each pointer names one union member. + let spec = spec_with_schemas(json!({ + "CacheData": { "oneOf": [ + { "type": "string" }, + { "type": "number" } + ]}, + "PutRequest": { "oneOf": [ + { "$ref": "#/components/schemas/CacheData/oneOf/0" }, + { "$ref": "#/components/schemas/CacheData/oneOf/1" } + ]}, + "Holder": { "type": "object", "additionalProperties": false, "properties": { + "data": { "$ref": "#/components/schemas/PutRequest" } + }} + })); let (generated, recoverable) = generate_and_census(spec); assert!( @@ -400,22 +396,163 @@ fn union_branches_that_are_deep_pointers_are_expanded() { #[test] fn a_pointer_into_a_composition_resolves_to_that_member() { - // The other pointer form PagerDuty uses: one member of another schema's - // `allOf`, addressed by index. + // PagerDuty points at one allOf member. Give the root a second, visibly + // different member so truncating the pointer to `Tag` cannot pass. let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ "Tag": { "allOf": [ { "type": "object", "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "string" } } }, + { "type": "object", "additionalProperties": false, + "required": ["label"], "properties": { "label": { "type": "string" } } } ]}, - "Action": { "type": "object", "additionalProperties": false, "properties": { - "base": { "$ref": "#/components/schemas/Tag/allOf/0" } + "Action": { "type": "object", "additionalProperties": false, + "required": ["base", "again"], "properties": { + "base": { "$ref": "#/components/schemas/Tag/allOf/0" }, + "again": { "$ref": "#/components/schemas/Tag/allOf/0" } + }} + }))); + + let Some(action) = generated + .split("pub struct Action {") + .nth(1) + .and_then(|tail| tail.split("\n}").next()) + else { + panic!("missing Action struct:\n{generated}"); + }; + let Some(exact_member) = generated + .split("pub struct TagAllOf0 {") + .nth(1) + .and_then(|tail| tail.split("\n}").next()) + else { + panic!("missing exact allOf member struct:\n{generated}"); + }; + assert!( + action.contains("pub base: TagAllOf0") && action.contains("pub again: TagAllOf0"), + "both uses must share the exact pointer target:\n{generated}" + ); + assert!( + exact_member.contains("pub id: String") && !exact_member.contains("label"), + "the first member must not inherit its sibling's fields:\n{generated}" + ); + assert_eq!( + generated.matches("pub struct TagAllOf0 {").count(), + 1, + "reusing a pointer must not generate duplicate target types" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn an_escaped_deep_scalar_pointer_preserves_type_and_nullability() { + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "AHolder": { "type": "object", "additionalProperties": false, + "required": ["selected"], "properties": { + "selected": { + "$ref": "#/components/schemas/Source/properties/media~1type" + } + }}, + "Source": { "type": "object", "additionalProperties": false, "properties": { + "media/type": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "unrelated": { "type": "integer" } + }} + }))); + + assert!( + generated.contains("pub selected: Option"), + "escaped pointer token must select the nullable scalar, not Source:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn array_items_can_reuse_a_deep_object_pointer() { + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "AHolder": { "type": "object", "additionalProperties": false, + "required": ["first", "second"], "properties": { + "first": { "type": "array", "items": { + "$ref": "#/components/schemas/Source/properties/entries/items" + }}, + "second": { "type": "array", "items": { + "$ref": "#/components/schemas/Source/properties/entries/items" + }} + }}, + "Source": { "type": "object", "additionalProperties": false, "properties": { + "entries": { "type": "array", "items": { + "type": "object", "additionalProperties": false, + "required": ["code"], "properties": { "code": { "type": "string" } } + }} + }} + }))); + + assert!( + generated.contains("pub first: Vec") + && generated.contains("pub second: Vec"), + "deep item refs must use the exact shared object type:\n{generated}" + ); + assert_eq!( + generated.matches("pub struct SourceEntriesItems {").count(), + 1 + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn allof_merges_the_exact_deep_object_target() { + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "Composite": { "allOf": [ + { "$ref": "#/components/schemas/Source/properties/fragment" }, + { "type": "object", "additionalProperties": false, + "required": ["own"], "properties": { "own": { "type": "boolean" } } } + ]}, + "Source": { "type": "object", "additionalProperties": false, "properties": { + "fragment": { "type": "object", "additionalProperties": false, + "required": ["selected"], + "properties": { "selected": { "type": "string" } } }, + "unrelated": { "type": "integer" } + }} + }))); + + let Some(composite) = generated + .split("pub struct Composite {") + .nth(1) + .and_then(|tail| tail.split("\n}").next()) + else { + panic!("missing Composite struct:\n{generated}"); + }; + assert!( + composite.contains("pub selected: String") && composite.contains("pub own: bool"), + "allOf must merge the exact object and its sibling:\n{generated}" + ); + assert!( + !composite.contains("unrelated") && !composite.contains("pub fragment:"), + "allOf must not merge the component root:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn a_recursive_deep_object_pointer_terminates_and_is_reused() { + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "AHolder": { "type": "object", "additionalProperties": false, + "required": ["node"], "properties": { + "node": { "$ref": "#/components/schemas/Source/properties/node" } + }}, + "Source": { "type": "object", "additionalProperties": false, "properties": { + "node": { "type": "object", "additionalProperties": false, "properties": { + "next": { "$ref": "#/components/schemas/Source/properties/node" } + }} }} }))); assert!( - !generated.contains("pub base: Option"), - "a pointer into a composition must resolve:\n{generated}" + generated.contains("pub node: Box") + && generated.contains("pub struct SourceNode {") + && generated.contains("pub next: Option>"), + "recursive deep pointer must terminate as a recursive named object:\n{generated}" ); + assert_eq!(generated.matches("pub struct SourceNode {").count(), 1); assert!(recoverable.is_empty(), "{recoverable:?}"); } @@ -560,8 +697,9 @@ fn an_extensible_enum_renders_as_a_string_everywhere_a_string_is_expected() { fn an_object_that_also_declares_variants_keeps_both_halves() { // `{properties: {...}, anyOf: [A, B]}` means "these fields, and one of // these shapes" — Cloudflare's DLP entries. Neither half can be dropped, so - // the object generates a struct and the union its own enum, held in a - // flattened field (#65). + // the object generates a struct and the union its own enum. Manual outer + // serde lets both halves inspect the complete object, including any shared + // discriminator, without emitting duplicate keys (#65). assert_types( spec_with_schemas(json!({ "Profile": { "type": "object", "additionalProperties": false, @@ -585,9 +723,10 @@ fn an_object_that_also_declares_variants_keeps_both_halves() { &[ "pub struct Entry", "pub profiles: Vec", - "#[serde(flatten)]", "pub variant: EntryVariant", "pub enum EntryVariant", + "struct __EntryBase", + "impl serde::Serialize for Entry", ], ); } diff --git a/tests/request_model_ergonomics_test.rs b/tests/request_model_ergonomics_test.rs index ff11958..feed8fe 100644 --- a/tests/request_model_ergonomics_test.rs +++ b/tests/request_model_ergonomics_test.rs @@ -75,6 +75,9 @@ fn ergonomics_spec() -> serde_json::Value { "email_address": { "type": "string" }, "external_id": { "type": "string", "nullable": true }, "first_name": { "type": "string" }, + "nullable_note": { "type": ["string", "null"] }, + "nullable_note_null": { "type": "string" }, + "nullable_note_absent": { "type": "string" }, "notify": { "type": "boolean" }, "new": { "type": "string" }, "with_new": { "type": "string" }, @@ -202,6 +205,13 @@ fn mixed_request_root_has_required_constructor_and_every_optional_setter() { "pubfnbuilder(email_address:String,external_id:Option,)->CreateInvitationRequestBuilder" )); assert!(compact.contains("pubfnfirst_name(mutself,first_name:String)->Self")); + assert!(compact.contains("pubfnnullable_note(mutself,nullable_note:String)->Self")); + assert!(compact.contains("pubfnnullable_note_null(mutself)->Self")); + assert!(compact.contains("pubfnnullable_note_absent(mutself)->Self")); + assert!(compact.contains("pubfnnullable_note_null_2(mutself,nullable_note_null:String)->Self")); + assert!( + compact.contains("pubfnnullable_note_absent_2(mutself,nullable_note_absent:String)->Self") + ); assert!(compact.contains("pubfnnotify(mutself,notify:bool)->Self")); assert!(compact.contains("pubfnwith_new(mutself,new:String)->Self")); assert!(compact.contains("pubfnwith_new_2(mutself,with_new:String)->Self")); @@ -337,7 +347,7 @@ use super::generated; #[test] fn exercise_generated_api() { let update = generated::UpdateUserRequest { - first_name: Some("Ada".to_string()), + first_name: Some(Some("Ada".to_string())), ..Default::default() }; let empty_patch = generated::UpdateUserRequest::default(); @@ -352,6 +362,10 @@ fn exercise_generated_api() { None, ) .first_name("Ada".to_string()) + .nullable_note("transient".to_string()) + .nullable_note_null() + .nullable_note_null_2("literal null suffix".to_string()) + .nullable_note_absent_2("literal absent suffix".to_string()) .notify(true) .with_new("new value".to_string()) .with_new_2("also new".to_string()) @@ -363,9 +377,13 @@ fn exercise_generated_api() { .build(); assert_eq!(request.email_address, "ada@example.com"); assert_eq!(request.first_name.as_deref(), Some("Ada")); + assert_eq!(request.nullable_note, Some(None)); assert_eq!(request.additional_properties["source"], "docs"); let request_json = serde_json::to_value(&request).unwrap(); assert_eq!(request_json["connectionString"], "camel"); + assert_eq!(request_json["nullable_note"], serde_json::Value::Null); + assert_eq!(request_json["nullable_note_null"], "literal null suffix"); + assert_eq!(request_json["nullable_note_absent"], "literal absent suffix"); assert_eq!(request_json["connection_string"], "snake"); assert_eq!(request_json["additional_properties"], "declared"); assert_eq!(request_json["source"], "docs"); @@ -375,11 +393,24 @@ fn exercise_generated_api() { Some("external-1".to_string()), ); assert!(direct.first_name.is_none()); + assert!(direct.nullable_note.is_none()); assert!(direct.additional_properties.is_empty()); let aliased = generated::AliasRequest::builder("alias-id".to_string()) .note("available through the alias".to_string()) .build(); assert_eq!(aliased.note.as_deref(), Some("available through the alias")); + let absent_again = generated::CreateInvitationRequest::builder( + "missing@example.com".to_string(), + None, + ) + .nullable_note_null() + .nullable_note_absent() + .build(); + assert!(absent_again.nullable_note.is_none()); + assert!(serde_json::to_value(absent_again) + .unwrap() + .get("nullable_note") + .is_none()); let _json = serde_json::to_value((update, request)).unwrap(); } } diff --git a/tests/required_multi_branch_nullable_union_test.rs b/tests/required_multi_branch_nullable_union_test.rs new file mode 100644 index 0000000..01665b1 --- /dev/null +++ b/tests/required_multi_branch_nullable_union_test.rs @@ -0,0 +1,165 @@ +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn nullable_union_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "required nullable unions", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Envelope": { + "type": "object", + "required": ["any_type", "one_type", "any_const", "one_enum"], + "properties": { + "any_type": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "null" } + ] + }, + "one_type": { + "oneOf": [ + { "type": "integer" }, + { "type": "string" }, + { "type": "null" } + ] + }, + "any_const": { + "anyOf": [ + { "type": "boolean" }, + { "type": "array", "items": { "type": "string" } }, + { "const": null } + ] + }, + "one_enum": { + "oneOf": [ + { "type": "number" }, + { "type": "string" }, + { "enum": [null] } + ] + } + } + } + } + } + }) +} + +fn generate() -> String { + let mut analysis = SchemaAnalyzer::new(nullable_union_spec()) + .expect("nullable union spec should parse") + .analyze() + .expect("nullable union spec should analyze"); + CodeGenerator::new(GeneratorConfig { + module_name: "required_nullable_unions".into(), + enable_async_client: false, + ..Default::default() + }) + .generate(&mut analysis) + .expect("nullable union types should generate") +} + +#[test] +fn required_multi_branch_unions_retain_explicit_nullability() { + let analysis = SchemaAnalyzer::new(nullable_union_spec()) + .expect("nullable union spec should parse") + .analyze() + .expect("nullable union spec should analyze"); + let envelope = &analysis.schemas["Envelope"].schema_type; + let openapi_to_rust::analysis::SchemaType::Object { properties, .. } = envelope else { + panic!("Envelope should analyze as an object: {envelope:?}"); + }; + for field in ["any_type", "one_type", "any_const", "one_enum"] { + assert!(properties[field].nullable, "{field} must remain nullable"); + } + + let code = generate(); + for (field, type_name) in [ + ("any_type", "EnvelopeAnyType"), + ("one_type", "EnvelopeOneType"), + ("any_const", "EnvelopeAnyConst"), + ("one_enum", "EnvelopeOneEnum"), + ] { + assert!( + code.contains(&format!("pub {field}: Option<{type_name}>")), + "required nullable {field} must use Option without changing its non-null union. Code:\n{code}" + ); + } +} + +#[test] +fn generated_required_multi_branch_unions_round_trip_null_and_non_null_values() { + let code = generate(); + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "required-multi-branch-nullable-union-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn check(input: serde_json::Value) { + let hydrated: generated::Envelope = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(hydrated).unwrap(); + assert_eq!(output, input); + let hydrated_again: generated::Envelope = serde_json::from_value(output.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated_again).unwrap(), output); +} + +fn main() { + check(serde_json::json!({ + "any_type": null, + "one_type": null, + "any_const": null, + "one_enum": null + })); + check(serde_json::json!({ + "any_type": "text", + "one_type": 42, + "any_const": true, + "one_enum": "other" + })); + check(serde_json::json!({ + "any_type": 7, + "one_type": "text", + "any_const": ["a", "b"], + "one_enum": 1.5 + })); +} +"##, + ) + .expect("scratch main"); + + let output = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/required-multi-branch-nullable-union-smoke"), + ) + .output() + .expect("cargo run"); + assert!( + output.status.success(), + "generated nullable union round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/schema_aware_oneof_test.rs b/tests/schema_aware_oneof_test.rs new file mode 100644 index 0000000..88184b1 --- /dev/null +++ b/tests/schema_aware_oneof_test.rs @@ -0,0 +1,326 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn schema_aware_oneof_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "schema-aware oneOf", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "PermissiveToken": { + "type": "object", + "additionalProperties": false, + "properties": { + "access_token": { "type": ["string", "null"] }, + "channel_id": { "type": ["string", "null"] } + } + }, + "Webhook": { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { "url": { "type": "string" } } + }, + "Connection": { + "oneOf": [ + { "$ref": "#/components/schemas/PermissiveToken" }, + { "$ref": "#/components/schemas/Webhook" } + ] + }, + "ConnectionReversed": { + "oneOf": [ + { "$ref": "#/components/schemas/Webhook" }, + { "$ref": "#/components/schemas/PermissiveToken" } + ] + }, + "OverlapA": { + "type": "object", + "additionalProperties": false, + "required": ["shared"], + "properties": { + "shared": { "type": "string" }, + "a": { "type": "string" } + } + }, + "OverlapB": { + "type": "object", + "additionalProperties": false, + "required": ["shared"], + "properties": { + "shared": { "type": "string" }, + "b": { "type": "string" } + } + }, + "ExclusiveOverlap": { + "oneOf": [ + { "$ref": "#/components/schemas/OverlapA" }, + { "$ref": "#/components/schemas/OverlapB" } + ] + }, + "NonExclusiveOverlap": { + "anyOf": [ + { "$ref": "#/components/schemas/OverlapA" }, + { "$ref": "#/components/schemas/OverlapB" } + ] + }, + "AnyPermissive": { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" } } + }, + "AnyDetailed": { + "type": "object", + "required": ["id", "detail"], + "properties": { + "id": { "type": "string" }, + "detail": { "type": "string" } + } + }, + "LosslessAny": { + "anyOf": [ + { "$ref": "#/components/schemas/AnyPermissive" }, + { "$ref": "#/components/schemas/AnyDetailed" } + ] + }, + "NumericKindA": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { "type": "integer", "enum": [1] }, + "value": { "type": "string" } + } + }, + "NumericKindB": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { "type": "integer", "enum": [2] }, + "value": { "type": "string" } + } + }, + "NumericKind": { + "oneOf": [ + { "$ref": "#/components/schemas/NumericKindA" }, + { "$ref": "#/components/schemas/NumericKindB" } + ] + }, + "RequiredUser": { + "type": "object", + "required": ["login"], + "properties": { "login": { "type": "string" } } + }, + "ClosedEmpty": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "UserOrEmpty": { + "oneOf": [ + { "$ref": "#/components/schemas/RequiredUser" }, + { "$ref": "#/components/schemas/ClosedEmpty" } + ] + }, + "ConflictingKindBase": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["base"] } + } + }, + "ConflictingKindNarrow": { + "allOf": [ + { "$ref": "#/components/schemas/ConflictingKindBase" }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["narrow"] } + } + } + ] + }, + "Marker": { + "type": "object", + "additionalProperties": false, + "required": ["marker"], + "properties": { "marker": { "type": "string" } } + }, + "EmptyDomainUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/ConflictingKindNarrow" }, + { "$ref": "#/components/schemas/Marker" } + ] + }, + "NullableWeb": { + "type": "object", + "additionalProperties": false, + "required": ["call_type"], + "properties": { + "call_type": { "type": "string", "enum": ["web"] }, + "storage": { + "type": "string", + "enum": ["everything"], + "nullable": true + } + } + }, + "NullablePhone": { + "type": "object", + "additionalProperties": false, + "required": ["call_type"], + "properties": { + "call_type": { "type": "string", "enum": ["phone"] } + } + }, + "NullableLiteralUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/NullableWeb" }, + { "$ref": "#/components/schemas/NullablePhone" } + ] + } + } } + }) +} + +#[test] +fn oneof_analysis_retains_exclusivity_without_changing_anyof() { + let analysis = SchemaAnalyzer::new(schema_aware_oneof_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + + let SchemaType::Union { exclusive, .. } = &analysis.schemas["Connection"].schema_type else { + panic!("Connection should be an untagged union"); + }; + assert!(*exclusive); + + let SchemaType::Union { exclusive, .. } = &analysis.schemas["NonExclusiveOverlap"].schema_type + else { + panic!("NonExclusiveOverlap should be an untagged union"); + }; + assert!(!*exclusive); +} + +#[test] +fn generated_oneof_selects_one_complete_shape_independent_of_branch_order() { + let mut analysis = SchemaAnalyzer::new(schema_aware_oneof_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("impl<'de>Deserialize<'de>forConnection")); + assert!(compact.contains("pubenumNonExclusiveOverlap")); + + generated.push_str( + r#" +#[cfg(test)] +mod schema_aware_oneof_runtime { + use super::{ + Connection, ConnectionReversed, EmptyDomainUnion, ExclusiveOverlap, LosslessAny, + NonExclusiveOverlap, NullableLiteralUnion, NumericKind, UserOrEmpty, + }; + + #[test] + fn complete_shape_wins_regardless_of_branch_order() { + for input in [ + serde_json::json!({"url": "https://hooks.example.test/one"}), + serde_json::json!({"access_token": null, "channel_id": "C123"}), + ] { + let forward: Connection = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(forward).unwrap(), input); + let reversed: ConnectionReversed = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(reversed).unwrap(), input); + } + for input in [ + serde_json::json!({"type": 1, "value": "a"}), + serde_json::json!({"type": 2, "value": "b"}), + ] { + let hydrated: NumericKind = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + } + + #[test] + fn ambiguous_and_no_match_oneof_values_are_explicit_errors() { + let ambiguous = serde_json::from_value::( + serde_json::json!({"shared": "both"}), + ) + .unwrap_err(); + assert!(ambiguous.to_string().contains("ambiguous oneOf value")); + + let no_match = serde_json::from_value::( + serde_json::json!({"unknown": true}), + ) + .unwrap_err(); + assert!(no_match.to_string().contains("no oneOf branch")); + } + + #[test] + fn overlapping_anyof_keeps_non_exclusive_serde_behavior() { + let input = serde_json::json!({"shared": "either"}); + let hydrated: NonExclusiveOverlap = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + + let detailed = serde_json::json!({"id": "one", "detail": "preserved"}); + let hydrated: LosslessAny = serde_json::from_value(detailed.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), detailed); + } + + #[test] + fn closed_empty_object_is_not_an_unconstrained_value_branch() { + for input in [serde_json::json!({}), serde_json::json!({"login": "octocat"})] { + let hydrated: UserOrEmpty = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); + } + + let marker = serde_json::json!({"marker": "valid"}); + let hydrated: EmptyDomainUnion = serde_json::from_value(marker.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), marker); + + let nullable = serde_json::json!({"call_type": "web", "storage": null}); + let hydrated: NullableLiteralUnion = + serde_json::from_value(nullable.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), nullable); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "schema-aware-oneof-smoke" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/schema-aware-oneof-smoke"), + ) + .output() + .expect("run generated oneOf test"); + assert!( + output.status.success(), + "generated schema-aware oneOf failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/schema_name_collision_test.rs b/tests/schema_name_collision_test.rs index dbbf13c..60607f3 100644 --- a/tests/schema_name_collision_test.rs +++ b/tests/schema_name_collision_test.rs @@ -1,3 +1,4 @@ +use openapi_to_rust::analysis::SchemaType as AnalyzedSchemaType; use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; use serde_json::json; @@ -80,3 +81,213 @@ fn distinct_component_keys_that_map_to_the_same_rust_type_are_disambiguated() { "SessionStatus2" ); } + +#[test] +fn inline_objects_do_not_replace_exact_named_components() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "inline-object-component-collision", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Model": { + "type": "object", + "properties": { + "api": { + "type": "object", + "properties": { "endpoint": { "type": "string" } }, + "required": ["endpoint"] + }, + "capabilities": { + "type": "object", + "properties": { "streaming": { "type": "boolean" } }, + "required": ["streaming"] + } + }, + "required": ["api", "capabilities"] + }, + "ModelApi": { + "type": "object", + "properties": { "component_api": { "type": "string" } }, + "required": ["component_api"] + }, + "ModelCapabilities": { + "type": "object", + "properties": { + "input": { "type": "boolean" }, + "output": { "type": "boolean" }, + "tools": { "type": "boolean" } + }, + "required": ["input", "output", "tools"] + } + } + } + }); + + let mut analyzer = SchemaAnalyzer::new(spec).expect("spec should parse"); + let mut analysis = analyzer.analyze().expect("spec should analyze"); + + let AnalyzedSchemaType::Object { properties, .. } = &analysis.schemas["Model"].schema_type + else { + panic!("Model should remain an object"); + }; + assert!(matches!( + &properties["api"].schema_type, + AnalyzedSchemaType::Reference { target } if target == "ModelApiInline" + )); + assert!(matches!( + &properties["capabilities"].schema_type, + AnalyzedSchemaType::Reference { target } if target == "ModelCapabilitiesInline" + )); + + let AnalyzedSchemaType::Object { + properties: component_api, + .. + } = &analysis.schemas["ModelApi"].schema_type + else { + panic!("ModelApi should remain the named component object"); + }; + assert_eq!( + component_api.keys().collect::>(), + vec!["component_api"] + ); + + let AnalyzedSchemaType::Object { + properties: component_capabilities, + .. + } = &analysis.schemas["ModelCapabilities"].schema_type + else { + panic!("ModelCapabilities should remain the named component object"); + }; + assert_eq!( + component_capabilities.keys().collect::>(), + vec!["input", "output", "tools"] + ); + + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("component and inline object names should coexist"); + assert!(generated.contains("pub struct ModelApiInline")); + assert!(generated.contains("pub struct ModelCapabilitiesInline")); + assert!(generated.contains("pub struct ModelApi")); + assert!(generated.contains("pub component_api: String")); +} + +#[test] +fn inline_enums_do_not_replace_exact_named_components() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "inline-enum-component-collision", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "OutputFormat": { + "type": "object", + "properties": { + "container": { "type": "string", "enum": ["raw"] } + }, + "required": ["container"] + }, + "OutputFormatContainer": { + "type": "string", + "enum": ["raw", "wav", "mp3"] + }, + "Control": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["control"] } + }, + "required": ["type"] + }, + "ControlType": { + "type": "string", + "enum": ["button", "checkbox", "slider"] + }, + "Page": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["page"] } + }, + "required": ["type"] + }, + "PageType": { + "type": "string", + "enum": ["canvas", "embed"] + }, + "Table": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["table"] } + }, + "required": ["type"] + }, + "TableType": { + "type": "string", + "enum": ["table", "view"] + } + } + } + }); + + let mut analyzer = SchemaAnalyzer::new(spec).expect("spec should parse"); + let mut analysis = analyzer.analyze().expect("spec should analyze"); + + assert!(matches!( + &analysis.schemas["OutputFormatContainer"].schema_type, + AnalyzedSchemaType::StringEnum { values } + if values == &["raw".to_string(), "wav".to_string(), "mp3".to_string()] + )); + assert!(matches!( + &analysis.schemas["OutputFormatContainerRaw"].schema_type, + AnalyzedSchemaType::StringEnum { values } if values == &["raw".to_string()] + )); + assert!(matches!( + &analysis.schemas["PageType"].schema_type, + AnalyzedSchemaType::StringEnum { values } + if values == &["canvas".to_string(), "embed".to_string()] + )); + assert!(matches!( + &analysis.schemas["PageTypePage"].schema_type, + AnalyzedSchemaType::StringEnum { values } if values == &["page".to_string()] + )); + assert!(matches!( + &analysis.schemas["ControlType"].schema_type, + AnalyzedSchemaType::StringEnum { values } + if values == &["button".to_string(), "checkbox".to_string(), "slider".to_string()] + )); + assert!(matches!( + &analysis.schemas["ControlTypeControl"].schema_type, + AnalyzedSchemaType::StringEnum { values } if values == &["control".to_string()] + )); + assert!(matches!( + &analysis.schemas["TableType"].schema_type, + AnalyzedSchemaType::StringEnum { values } + if values == &["table".to_string(), "view".to_string()] + )); + assert!(matches!( + &analysis.schemas["TableTypeTable"].schema_type, + AnalyzedSchemaType::StringEnum { values } if values == &["table".to_string()] + )); + + let AnalyzedSchemaType::Object { properties, .. } = + &analysis.schemas["OutputFormat"].schema_type + else { + panic!("OutputFormat should remain an object"); + }; + assert!(matches!( + &properties["container"].schema_type, + AnalyzedSchemaType::Reference { target } if target == "OutputFormatContainerRaw" + )); + + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("component and inline enum names should coexist"); + assert!(generated.contains("pub enum OutputFormatContainer")); + assert!(generated.contains("pub enum OutputFormatContainerRaw")); + assert!(generated.contains("pub enum ControlType")); + assert!(generated.contains("pub enum ControlTypeControl")); + assert!(generated.contains("pub enum PageType")); + assert!(generated.contains("pub enum PageTypePage")); + assert!(generated.contains("pub enum TableType")); + assert!(generated.contains("pub enum TableTypeTable")); +} diff --git a/tests/server_raw_body_roundtrip_test.rs b/tests/server_raw_body_roundtrip_test.rs index 9c0a9d8..8882a10 100644 --- a/tests/server_raw_body_roundtrip_test.rs +++ b/tests/server_raw_body_roundtrip_test.rs @@ -404,12 +404,15 @@ mod tests { fn sample_plugin() -> Plugin { Plugin { added_at: "2024-01-01T00:00:00Z".parse().unwrap(), - connection: PluginModeUnion::PluginSupervisedProps(PluginSupervisedProps {}), + connection: PluginModeUnion::PluginSupervisedProps(PluginSupervisedProps { + mode: PluginSupervisedPropsMode::Supervised, + }), description: None, id: "plugin-one".to_string(), manifest: PluginManifest::default(), name: "Test Plugin".to_string(), status: PluginStatus::PluginStatusActive(PluginStatusActive { + active_state: PluginStatusActiveActiveState::Active, activated_at: "2024-01-01T00:00:00Z".parse().unwrap(), }), version: None, diff --git a/tests/shared_discriminator_flatten_test.rs b/tests/shared_discriminator_flatten_test.rs new file mode 100644 index 0000000..60e48f0 --- /dev/null +++ b/tests/shared_discriminator_flatten_test.rs @@ -0,0 +1,123 @@ +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn shared_discriminator_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "shared flattened discriminator", "version": "1" }, + "paths": {}, + "components": { "schemas": { + "NonEmptyString": { "type": "string", "minLength": 1 }, + "TextPart": { + "type": "object", + "additionalProperties": false, + "required": ["type", "text"], + "properties": { + "type": { "const": "text" }, + "text": { "type": "string" } + } + }, + "FilePart": { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "file" }, + "url": { "type": "string" } + } + }, + "MessagePart": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "$ref": "#/components/schemas/NonEmptyString" } + }, + "oneOf": [ + { "$ref": "#/components/schemas/TextPart" }, + { "$ref": "#/components/schemas/FilePart" } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextPart", + "file": "#/components/schemas/FilePart" + } + } + } + } } + }) +} + +#[test] +fn sibling_and_variant_deserialize_from_one_complete_object() { + let mut analysis = SchemaAnalyzer::new(shared_discriminator_spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("spec should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("struct__MessagePartBase")); + assert!(compact.contains("impl<'de>serde::Deserialize<'de>forMessagePart")); + assert!(!compact.contains("#[serde(flatten)]pubvariant:MessagePartVariant")); + + generated.push_str( + r#" +#[cfg(test)] +mod shared_discriminator_runtime { + use super::MessagePart; + + #[test] + fn shared_tag_is_visible_to_both_halves_and_serializes_once() { + for input in [ + serde_json::json!({"type": "text", "text": "hello"}), + serde_json::json!({"type": "file", "url": "https://example.test/file"}), + ] { + let hydrated: MessagePart = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(hydrated).unwrap(); + assert_eq!(output, input); + let stable: MessagePart = serde_json::from_value(output.clone()).unwrap(); + assert_eq!(serde_json::to_value(stable).unwrap(), output); + } + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "shared-discriminator-flatten-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/shared-discriminator-flatten-smoke"), + ) + .output() + .expect("run generated shared-discriminator test"); + assert!( + output.status.success(), + "generated shared-discriminator round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/single_reference_allof_tests.rs b/tests/single_reference_allof_tests.rs index 37e7087..73f74d8 100644 --- a/tests/single_reference_allof_tests.rs +++ b/tests/single_reference_allof_tests.rs @@ -6,7 +6,27 @@ //! resolve to direct type references instead of unnecessary compositions. use openapi_to_rust::test_helpers::*; -use serde_json::json; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn generate(spec: Value, module_name: &str) -> String { + let mut analyzer = SchemaAnalyzer::new(spec).expect("schema analyzer"); + let mut analysis = analyzer.analyze().expect("schema analysis"); + CodeGenerator::new(GeneratorConfig { + module_name: module_name.to_string(), + ..Default::default() + }) + .generate(&mut analysis) + .expect("code generation") +} + +fn struct_body<'a>(generated: &'a str, name: &str) -> &'a str { + generated + .split(&format!("pub struct {name} {{")) + .nth(1) + .and_then(|tail| tail.split("\n}").next()) + .unwrap_or_else(|| panic!("missing struct `{name}`:\n{generated}")) +} #[test] fn test_single_reference_allof_resolves_directly() { @@ -247,3 +267,263 @@ fn reference_with_annotation_sibling_preserves_recursive_model_reference() { assert!(expression.contains("Expression"), "{expression}"); assert!(!expression.contains("serde_json::Value"), "{expression}"); } + +#[test] +fn transitive_alias_extension_preserves_inherited_required_fields() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "transitive alias", "version": "1.0"}, + "components": { "schemas": { + "Base": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + }, + "Alias": { + "allOf": [ + { "$ref": "#/components/schemas/Base" }, + { "description": "single-reference alias wrapper" } + ] + }, + "Extended": { + "allOf": [ + { "$ref": "#/components/schemas/Alias" }, + { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + }, + "required": ["enabled"] + } + ] + }, + "Holder": { + "type": "object", + "properties": { + "extended": { + "allOf": [{ "$ref": "#/components/schemas/Extended" }] + } + }, + "required": ["extended"] + } + }} + }); + + let generated = generate(spec, "transitive_alias_extension"); + let extended = struct_body(&generated, "Extended"); + let holder = struct_body(&generated, "Holder"); + + assert!(extended.contains("pub id: String"), "{generated}"); + assert!(!extended.contains("pub id: Option<"), "{generated}"); + assert!(extended.contains("pub enabled: bool"), "{generated}"); + assert!(!extended.contains("pub enabled: Option<"), "{generated}"); + assert!(holder.contains("pub extended: Extended"), "{generated}"); + assert!(!holder.contains("serde_json::Value"), "{generated}"); +} + +#[test] +fn three_hop_single_reference_alias_chain_stays_typed() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "three hop alias", "version": "1.0"}, + "components": { "schemas": { + "Base": { + "type": "object", + "properties": { + "code": { "type": "string" } + }, + "required": ["code"] + }, + "AliasOne": { + "allOf": [{ "$ref": "#/components/schemas/Base" }] + }, + "AliasTwo": { + "allOf": [ + { "$ref": "#/components/schemas/AliasOne" }, + { "description": "hop two" } + ] + }, + "AliasThree": { + "allOf": [{ "$ref": "#/components/schemas/AliasTwo" }] + }, + "Extended": { + "allOf": [ + { "$ref": "#/components/schemas/AliasThree" }, + { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + }, + "required": ["enabled"] + } + ] + }, + "Holder": { + "type": "object", + "properties": { + "leaf": { + "allOf": [{ "$ref": "#/components/schemas/Extended" }] + } + }, + "required": ["leaf"] + } + }} + }); + + let generated = generate(spec, "three_hop_alias_chain"); + let extended = struct_body(&generated, "Extended"); + let holder = struct_body(&generated, "Holder"); + + assert!(extended.contains("pub code: String"), "{generated}"); + assert!(!extended.contains("pub code: Option<"), "{generated}"); + assert!(extended.contains("pub enabled: bool"), "{generated}"); + assert!(!extended.contains("pub enabled: Option<"), "{generated}"); + assert!(holder.contains("pub leaf: Extended"), "{generated}"); + assert!(!holder.contains("Option<"), "{generated}"); + assert!(!holder.contains("serde_json::Value"), "{generated}"); + assert!(!generated.contains("pub struct HolderLeaf"), "{generated}"); +} + +#[test] +fn self_referential_single_reference_allof_terminates_and_keeps_outer_field() { + let spec = json!({ + "openapi": "3.0.0", + "info": {"title": "self cycle", "version": "1.0"}, + "components": { "schemas": { + "SelfAlias": { + "allOf": [{ "$ref": "#/components/schemas/SelfAlias" }] + }, + "Extended": { + "allOf": [ + { "$ref": "#/components/schemas/SelfAlias" }, + { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + ] + }, + "Holder": { + "type": "object", + "properties": { + "root": { + "allOf": [{ "$ref": "#/components/schemas/Extended" }] + } + }, + "required": ["root"] + } + }} + }); + + let generated = generate(spec, "self_cycle_single_reference_allof"); + let extended = struct_body(&generated, "Extended"); + let holder = struct_body(&generated, "Holder"); + + assert!(extended.contains("pub id: String"), "{generated}"); + assert!(!extended.contains("pub id: Option<"), "{generated}"); + assert!(holder.contains("pub root: Extended"), "{generated}"); +} + +#[test] +fn two_node_single_reference_allof_cycle_terminates_and_keeps_local_fields() { + let spec = json!({ + "openapi": "3.0.0", + "info": {"title": "two node cycle", "version": "1.0"}, + "components": { "schemas": { + "AliasA": { + "allOf": [{ "$ref": "#/components/schemas/AliasB" }] + }, + "AliasB": { + "allOf": [{ "$ref": "#/components/schemas/AliasA" }] + }, + "Extended": { + "allOf": [ + { "$ref": "#/components/schemas/AliasA" }, + { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + } + ] + }, + "Holder": { + "type": "object", + "properties": { + "left": { + "allOf": [{ "$ref": "#/components/schemas/Extended" }] + } + }, + "required": ["left"] + } + }} + }); + + let generated = generate(spec, "two_node_cycle_single_reference_allof"); + let extended = struct_body(&generated, "Extended"); + let holder = struct_body(&generated, "Holder"); + + assert!(extended.contains("pub name: String"), "{generated}"); + assert!(!extended.contains("pub name: Option<"), "{generated}"); + assert!(holder.contains("pub left: Extended"), "{generated}"); +} + +#[test] +fn reverse_component_order_still_resolves_transitive_single_reference_allof() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "reverse order", "version": "1.0"}, + "components": { "schemas": { + "XExtended": { + "allOf": [ + { "$ref": "#/components/schemas/YAlias" }, + { + "type": "object", + "properties": { + "extra": { "type": "string" } + }, + "required": ["extra"] + } + ] + }, + "YAlias": { + "allOf": [ + { "$ref": "#/components/schemas/ZBase" }, + { "description": "declared after dependent" } + ] + }, + "ZBase": { + "type": "object", + "properties": { + "base_id": { "type": "string" } + }, + "required": ["base_id"] + }, + "Holder": { + "type": "object", + "properties": { + "item": { + "allOf": [{ "$ref": "#/components/schemas/XExtended" }] + } + }, + "required": ["item"] + } + }} + }); + + let generated = generate(spec, "reverse_component_order_alias_chain"); + let extended = struct_body(&generated, "XExtended"); + let holder = struct_body(&generated, "Holder"); + + assert!(extended.contains("pub base_id: String"), "{generated}"); + assert!(!extended.contains("pub base_id: Option<"), "{generated}"); + assert!(extended.contains("pub extra: String"), "{generated}"); + assert!(!extended.contains("pub extra: Option<"), "{generated}"); + assert!(holder.contains("pub item: XExtended"), "{generated}"); + assert!(!generated.contains("serde_json::Value"), "{generated}"); +} diff --git a/tests/structured_generation_tests.rs b/tests/structured_generation_tests.rs index 3ab81a9..2060cb4 100644 --- a/tests/structured_generation_tests.rs +++ b/tests/structured_generation_tests.rs @@ -78,14 +78,14 @@ fn test_underscore_properties_structured() { // Verify the struct is generated assert!(result.contains("pub struct ConfigSchema")); - // Verify the union types are generated with proper names (no underscores) + // Optional nullable properties preserve missing versus explicit null. assert!( result.contains("ConfigSchemaAllowedTools") - || result.contains("pub allowed_tools: Option>") + || result.contains("pub allowed_tools: Option>>") ); assert!( result.contains("ConfigSchemaCacheControl") - || result.contains("pub cache_control: Option") + || result.contains("pub cache_control: Option>") ); // Verify no underscores in generated type names @@ -167,11 +167,9 @@ fn test_complex_nested_schema() { // Verify status enum is generated assert!(result.contains("pub enum MessageBatchStatus")); - // The last_id property is `anyOf: [null, string]` — a nullable - // pattern. We unwrap to Option rather than synthesizing a - // union wrapper type (avoids name collisions with referenced - // schemas; see analyze_object_schema's nullable-pattern handling). - assert!(result.contains("pub last_id: Option")); + // The nullable pattern avoids a synthetic union type, while the two + // Options retain field presence and explicit JSON null independently. + assert!(result.contains("pub last_id: Option>")); // Check union types don't have underscores assert!(!result.contains("Beta_List_Response")); @@ -210,11 +208,6 @@ fn test_nullable_fields_structured() { assert!(result.contains("pub struct User")); // Verify nullable field types are handled properly - assert!( - result.contains("email: Option") || result.contains("email: Option") - ); - assert!( - result.contains("metadata: Option") - || result.contains("metadata: Option") - ); + assert!(result.contains("email: Option>")); + assert!(result.contains("metadata: Option>")); } diff --git a/tests/test_x_stainless_bug.rs b/tests/test_x_stainless_bug.rs index 28c7316..accba5a 100644 --- a/tests/test_x_stainless_bug.rs +++ b/tests/test_x_stainless_bug.rs @@ -175,8 +175,10 @@ mod tests { "EventUnion should be generated as discriminated union" ); assert!( - result.contains("#[serde(tag = \"type\")]"), - "EventUnion should use type as discriminator tag" + result.contains("match discriminator {") + && result.contains("\"response.reasoning_summary_part.added\" =>") + && result.contains("\"response.reasoning_summary_part.done\" =>"), + "EventUnion should deserialize by its type discriminator" ); } @@ -399,8 +401,10 @@ mod tests { "EventUnion should be generated as discriminated union" ); assert!( - result.contains("#[serde(tag = \"type\")]"), - "EventUnion should use type as discriminator tag" + result.contains("match discriminator {") + && result.contains("\"response.reasoning_summary_part.added\" =>") + && result.contains("\"response.reasoning_summary_part.done\" =>"), + "EventUnion should deserialize by its type discriminator" ); } } diff --git a/tests/typed_multi_union_test.rs b/tests/typed_multi_union_test.rs index f4803ae..db12a72 100644 --- a/tests/typed_multi_union_test.rs +++ b/tests/typed_multi_union_test.rs @@ -98,10 +98,11 @@ fn top_level_two_scalar_type_array_becomes_untagged_enum() { ); } -/// The 3.1 nullable shorthand (`[X, "null"]`) must keep collapsing to -/// `Option` — only genuine multi-scalar unions get an enum. +/// The 3.1 nullable shorthand (`[X, "null"]`) must not synthesize an enum. +/// On an optional property, the outer `Option` tracks field presence while the +/// inner `Option` retains an explicit JSON null. #[test] -fn nullable_shorthand_still_collapses_to_option() { +fn nullable_shorthand_uses_tri_state_option_without_union() { let spec = json!({ "openapi": "3.1.0", "info": {"title": "Test", "version": "1.0"}, @@ -120,8 +121,8 @@ fn nullable_shorthand_still_collapses_to_option() { let result = test_generation("nullable_shorthand_collapses", spec).expect("Generation failed"); assert!( - result.contains("pub maybe_name: Option"), - "the nullable shorthand must stay a plain Option, got:\n{result}" + result.contains("pub maybe_name: Option>"), + "the nullable shorthand must preserve missing versus explicit null, got:\n{result}" ); assert!( !result.contains("enum WidgetMaybeName"), diff --git a/tests/typed_scalars_test.rs b/tests/typed_scalars_test.rs index f8ca546..af6e163 100644 --- a/tests/typed_scalars_test.rs +++ b/tests/typed_scalars_test.rs @@ -11,6 +11,7 @@ //! only on `TypeMapper` would miss the codec threading through //! `SchemaType::Primitive.serde_with`. +use openapi_to_rust::type_mapping::{BinaryStrategy, DateStrategy}; use openapi_to_rust::{ ByteStrategy, CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, }; @@ -136,6 +137,182 @@ fn binary_default_emits_bytes_bytes() { ); } +fn binary_model_round_trip_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "binary model", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "BinaryBlob": { + "type": "string", + "format": "binary" + }, + "BinaryAlias": { + "$ref": "#/components/schemas/BinaryBlob" + }, + "google.protobuf.Any": { + "additionalProperties": true, + "properties": { + "debug": { + "additionalProperties": true, + "type": "object" + }, + "type": { "type": "string" }, + "value": { "type": "string", "format": "binary" } + }, + "type": "object" + }, + "Sample": { + "type": "object", + "required": ["direct", "aliased", "encoded", "gitpod_any"], + "properties": { + "direct": { "type": "string", "format": "binary" }, + "optional": { "type": ["string", "null"], "format": "binary" }, + "aliased": { "$ref": "#/components/schemas/BinaryAlias" }, + "encoded": { "type": "string", "format": "byte" }, + "optional_encoded": { "type": ["string", "null"], "format": "byte" }, + "gitpod_any": { "$ref": "#/components/schemas/google.protobuf.Any" } + } + } + } + } + }) +} + +fn assert_generated_binary_model_round_trip(strategy: BinaryStrategy, name: &str) { + let types = TypeMappingConfig { + binary: strategy, + ..TypeMappingConfig::default() + }; + let code = generate_with_types(binary_model_round_trip_spec(), types); + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), &code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "binary-model-{name}" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +base64 = "0.22" +bytes = {{ version = "1", features = ["serde"] }} +serde = {{ version = "1", features = ["derive"] }} +serde_json = "1" +"# + ), + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn main() { + let input = serde_json::json!({ + "direct": "direct bytes", + "optional": "optional bytes", + "aliased": "referenced bytes", + "encoded": "aGk=", + "optional_encoded": "aGk=", + "gitpod_any": { + "debug": {}, + "type": "type.googleapis.com/example.Message", + "value": "protobuf bytes", + "synthetic_extension": true + } + }); + let value: generated::Sample = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(value).unwrap(), input); + + let missing_optional = serde_json::json!({ + "direct": "direct bytes", + "aliased": "referenced bytes", + "encoded": "aGk=", + "gitpod_any": { + "type": "type.googleapis.com/example.Empty" + } + }); + let value: generated::Sample = serde_json::from_value(missing_optional.clone()).unwrap(); + assert_eq!(serde_json::to_value(value).unwrap(), missing_optional); + + let explicit_nulls = serde_json::json!({ + "direct": "direct bytes", + "optional": null, + "aliased": "referenced bytes", + "encoded": "aGk=", + "optional_encoded": null, + "gitpod_any": { + "type": "type.googleapis.com/example.Empty" + } + }); + let value: generated::Sample = serde_json::from_value(explicit_nulls.clone()).unwrap(); + assert_eq!(value.optional, Some(None)); + assert_eq!(value.optional_encoded, Some(None)); + assert_eq!(serde_json::to_value(value).unwrap(), explicit_nulls); +} +"##, + ) + .expect("scratch main"); + + let status = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("target/generated-binary-model-{name}")), + ) + .status() + .expect("cargo run"); + assert!( + status.success(), + "generated {name} binary model did not round-trip. Code:\n{code}" + ); + + assert!( + code.contains(r#"with = "base64_serde""#), + "format: byte must remain base64-encoded under {name}. Code:\n{code}" + ); +} + +#[test] +fn generated_binary_model_fields_round_trip_as_json_strings_for_every_strategy() { + for (name, strategy, codec) in [ + ("bytes", BinaryStrategy::Bytes, Some("binary_bytes_serde")), + ("vec-u8", BinaryStrategy::VecU8, Some("binary_vec_serde")), + ("string", BinaryStrategy::String, None), + ] { + let types = TypeMappingConfig { + binary: strategy, + ..TypeMappingConfig::default() + }; + let code = generate_with_types(binary_model_round_trip_spec(), types); + match codec { + Some(codec) => { + assert!(code.contains(&format!("mod {codec}")), "Code:\n{code}"); + assert!( + code.matches(&format!(r#"with = "{codec}""#)).count() >= 2, + "direct and referenced required fields need the codec. Code:\n{code}" + ); + assert!( + code.contains(&format!(r#"with = "{codec}::option""#)), + "optional field needs the option codec. Code:\n{code}" + ); + } + None => { + assert!(!code.contains("mod binary_bytes_serde"), "Code:\n{code}"); + assert!(!code.contains("mod binary_vec_serde"), "Code:\n{code}"); + } + } + assert_generated_binary_model_round_trip(strategy, name); + } +} + #[test] fn byte_default_emits_vec_u8_with_base64_codec() { let code = generate( @@ -405,14 +582,13 @@ fn no_format_property_remains_string() { } // ===================================================================== -// GH #25: DateStrategy::Time for `format: date` / `format: time`. -// `time::serde::iso8601` only supports OffsetDateTime, so the -// generator must emit its own codec modules via -// `time::serde::format_description!` instead. +// GH #25: DateStrategy::Time for `format: date`. `time::serde::iso8601` +// only supports OffsetDateTime, so the generator must emit its own date codec. +// JSON Schema `format: time` is RFC 3339 full-time and stays a string because +// both chrono::NaiveTime and time::Time lack its required UTC offset. // ===================================================================== fn time_strategy_mapper() -> TypeMapper { - use openapi_to_rust::type_mapping::DateStrategy; TypeMapper::new(TypeMappingConfig { date_time: DateStrategy::Time, date: DateStrategy::Time, @@ -440,6 +616,24 @@ fn spec_with_optional_format(format: &str) -> serde_json::Value { }) } +fn spec_with_optional_nullable_format(format: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "properties": { + "value": { "type": ["string", "null"], "format": format } + } + } + } + } + }) +} + #[test] fn date_with_time_strategy_emits_generated_codec() { let code = generate(spec_with_format("date"), time_strategy_mapper()); @@ -475,22 +669,172 @@ fn optional_date_with_time_strategy_uses_option_codec() { } #[test] -fn time_with_time_strategy_emits_generated_codec() { +fn time_with_time_strategy_preserves_rfc3339_offset_as_string() { let code = generate(spec_with_optional_format("time"), time_strategy_mapper()); assert!( - code.contains("pub value: Option"), - "optional time should be Option. Code:\n{code}" + code.contains("pub value: Option"), + "RFC 3339 full-time must retain its offset-bearing string. Code:\n{code}" ); assert!( - code.contains(r#"with = "time_time_format::option""#), - "optional time field should use the ::option submodule. Code:\n{code}" + !code.contains("time_time_format") && !code.contains("NaiveTime"), + "an offset-less time codec must not be emitted. Code:\n{code}" ); +} + +#[test] +fn nullable_time_scalars_compose_their_codecs_with_field_presence() { + for (format, rust_type, codec) in [ + ("date", "time::Date", "time_date_double_option"), + ( + "date-time", + "time::OffsetDateTime", + "time_rfc3339_double_option", + ), + ] { + let code = generate( + spec_with_optional_nullable_format(format), + time_strategy_mapper(), + ); + assert!( + code.contains(&format!("pub value: Option>")), + "optional nullable {format} must retain both presence and nullability. Code:\n{code}" + ); + assert!( + code.contains(&format!(r#"with = "{codec}""#)), + "optional nullable {format} must use its double-option codec. Code:\n{code}" + ); + } + + let time = generate( + spec_with_optional_nullable_format("time"), + time_strategy_mapper(), + ); + assert!(time.contains("pub value: Option>")); + assert!(time.contains(r#"deserialize_with = "tri_state_serde::deserialize""#)); + assert!(!time.contains("time_time_format")); +} + +fn rfc3339_full_time_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "RFC 3339 full-time", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "Clock": { + "type": "object", + "additionalProperties": false, + "required": ["required", "series", "by_name"], + "properties": { + "required": { "type": "string", "format": "time" }, + "optional": { "type": ["string", "null"], "format": "time" }, + "series": { + "type": "array", + "items": { "type": "string", "format": "time" } + }, + "by_name": { + "type": "object", + "additionalProperties": { "type": "string", "format": "time" } + } + } + } + } } + }) +} + +fn assert_generated_rfc3339_time_round_trip(strategy: DateStrategy, name: &str) { + let code = generate_with_types( + rfc3339_full_time_spec(), + TypeMappingConfig { + time: strategy, + ..TypeMappingConfig::default() + }, + ); + let compact = code.split_whitespace().collect::(); + assert!(compact.contains("pubrequired:String")); + assert!(compact.contains("puboptional:Option>")); + assert!(compact.contains("pubseries:Vec")); + assert!(compact.contains("BTreeMap")); + assert!(!code.contains("NaiveTime") && !code.contains("time::Time")); + + let temp = tempfile::TempDir::new().expect("RFC 3339 time scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/generated.rs"), code).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "rfc3339-time-{name}" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = {{ version = "1", features = ["derive"] }} +serde_json = "1" +"# + ), + ) + .unwrap(); + std::fs::write( + temp.path().join("src/main.rs"), + r#"mod generated; + +fn round_trip(input: serde_json::Value) { + let hydrated: generated::Clock = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(hydrated).unwrap(), input); +} + +fn main() { + round_trip(serde_json::json!({ + "required": "03:04:05Z", + "optional": "03:04:05.123+05:30", + "series": ["23:59:59-07:00", "00:00:00.000001+14:00"], + "by_name": {"negative": "12:34:56.789-03:30"} + })); + round_trip(serde_json::json!({ + "required": "03:04:05+00:00", + "series": [], + "by_name": {} + })); + round_trip(serde_json::json!({ + "required": "03:04:05Z", + "optional": null, + "series": ["03:04:05Z"], + "by_name": {} + })); +} +"#, + ) + .unwrap(); + + let output = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("target/generated-rfc3339-time-{name}")), + ) + .output() + .unwrap(); assert!( - code.contains("time_time_format"), - "the time codec module declaration must be emitted. Code:\n{code}" + output.status.success(), + "generated RFC 3339 time model failed for {name}:\n{}", + String::from_utf8_lossy(&output.stderr) ); } +#[test] +fn every_time_strategy_round_trips_rfc3339_offsets_exactly() { + for (strategy, name) in [ + (DateStrategy::String, "string"), + (DateStrategy::Chrono, "chrono"), + (DateStrategy::Time, "time"), + ] { + assert_generated_rfc3339_time_round_trip(strategy, name); + } +} + #[test] fn date_time_with_time_strategy_does_not_emit_codec_modules() { // OffsetDateTime has a built-in rfc3339 codec; the generated diff --git a/tests/vercel_dns_fixture_regression.rs b/tests/vercel_dns_fixture_regression.rs new file mode 100644 index 0000000..ba2fa39 --- /dev/null +++ b/tests/vercel_dns_fixture_regression.rs @@ -0,0 +1,68 @@ +use serde_json::{Value, json}; +use std::{fs, path::PathBuf}; + +fn load_vercel_fixture() -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("specs/vercel.json"); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + serde_json::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {} as JSON: {error}", path.display())) +} + +#[test] +fn closed_srv_txt_and_https_record_branches_declare_required_name() { + let fixture = load_vercel_fixture(); + let branches = fixture + .pointer( + "/paths/~1v2~1domains~1{domain}~1records/post/requestBody/content/application~1json/schema/anyOf", + ) + .and_then(Value::as_array) + .expect("POST /v2/domains/{domain}/records should declare request anyOf branches"); + + // MX immediately precedes SRV and uses the common record-name contract + // shared by the other non-NS branches. + let canonical_name = &branches[5]["properties"]["name"]; + assert_eq!( + canonical_name, + &json!({ + "description": "A subdomain name or an empty string for the root domain.", + "type": "string", + "example": "subdomain" + }) + ); + + for (index, record_type) in [(6, "SRV"), (7, "TXT"), (9, "HTTPS")] { + let branch = &branches[index]; + assert_eq!( + branch["properties"]["type"]["enum"], + json!([record_type]), + "branch {index} is no longer the expected {record_type} record" + ); + assert_eq!( + branch["additionalProperties"], + Value::Bool(false), + "{record_type} branch should remain closed" + ); + + let properties = branch["properties"] + .as_object() + .unwrap_or_else(|| panic!("{record_type} branch properties should be an object")); + let required = branch["required"] + .as_array() + .unwrap_or_else(|| panic!("{record_type} branch required should be an array")); + for member in required { + let member = member + .as_str() + .unwrap_or_else(|| panic!("{record_type} branch required contains a non-string")); + assert!( + properties.contains_key(member), + "{record_type} branch requires undeclared member {member:?} while additionalProperties is false" + ); + } + + assert_eq!( + &branch["properties"]["name"], canonical_name, + "{record_type} name should match adjacent DNS record branches" + ); + } +} diff --git a/tests/wide_integer_domain_test.rs b/tests/wide_integer_domain_test.rs new file mode 100644 index 0000000..897ec67 --- /dev/null +++ b/tests/wide_integer_domain_test.rs @@ -0,0 +1,216 @@ +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::{fs, process::Command}; + +fn wide_integer_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "wide integer domains", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "InRangeInt64": { + "type": "integer", "format": "int64", + "minimum": -100, "maximum": 100 + }, + "ExactInt64Domain": { + "type": "integer", "format": "int64", + "minimum": -9223372036854775808_i64, + "maximum": 9223372036854775807_i64 + }, + "WideInt64": { + "type": "integer", "format": "int64", + "minimum": 0, "maximum": 1.8446744073709551e19 + }, + "WideDefault": { + "type": "integer", + "minimum": 0, "maximum": 9223372036854776000_u64 + }, + "ExampleOnly": { + "type": "integer", + "example": 18446744073709551615_u64 + }, + "NarrowInt32": { + "type": "integer", "format": "int32", + "minimum": -100, "maximum": 100 + }, + "WidenedInt32": { + "type": "integer", "format": "int32", + "minimum": 0, "maximum": 5000000000_u64 + }, + "Unsigned64": { + "type": "integer", "format": "uint64", + "minimum": 0, "maximum": 1.8446744073709551e19 + }, + "WideArray": { + "type": "array", + "items": { + "type": "integer", "format": "int64", + "minimum": 0, "maximum": 1.8446744073709551e19 + } + }, + "WideOrText": { + "anyOf": [ + { + "type": "integer", "format": "int64", + "minimum": 0, "maximum": 1.8446744073709551e19 + }, + { "type": "string" } + ] + }, + "Envelope": { + "type": "object", + "required": [ + "in_range", "wide", "default_wide", "example_wide", + "narrow", "widened_32", "unsigned", "array", "union" + ], + "properties": { + "in_range": { "$ref": "#/components/schemas/InRangeInt64" }, + "wide": { "$ref": "#/components/schemas/WideInt64" }, + "default_wide": { "$ref": "#/components/schemas/WideDefault" }, + "example_wide": { "$ref": "#/components/schemas/ExampleOnly" }, + "narrow": { "$ref": "#/components/schemas/NarrowInt32" }, + "widened_32": { "$ref": "#/components/schemas/WidenedInt32" }, + "unsigned": { "$ref": "#/components/schemas/Unsigned64" }, + "array": { "$ref": "#/components/schemas/WideArray" }, + "union": { "$ref": "#/components/schemas/WideOrText" } + } + } + } + } + }) +} + +fn primitive_rust_type<'a>( + analysis: &'a openapi_to_rust::analysis::SchemaAnalysis, + name: &str, +) -> &'a str { + let SchemaType::Primitive { rust_type, .. } = &analysis.schemas[name].schema_type else { + panic!( + "{name} should be primitive: {:?}", + analysis.schemas[name].schema_type + ); + }; + rust_type +} + +#[test] +fn integer_width_follows_effective_schema_domain() { + let analysis = SchemaAnalyzer::new(wide_integer_spec()) + .expect("wide-integer spec should parse") + .analyze() + .expect("wide-integer spec should analyze"); + for (name, expected) in [ + ("InRangeInt64", "i64"), + ("ExactInt64Domain", "i64"), + ("WideInt64", "u64"), + ("WideDefault", "u64"), + ("ExampleOnly", "i128"), + ("NarrowInt32", "i32"), + ("WidenedInt32", "i64"), + ("Unsigned64", "u64"), + ] { + assert_eq!(primitive_rust_type(&analysis, name), expected, "{name}"); + } + + let SchemaType::Array { item_type } = &analysis.schemas["WideArray"].schema_type else { + panic!("WideArray should be an array"); + }; + assert!(matches!( + item_type.as_ref(), + SchemaType::Primitive { rust_type, .. } if rust_type == "u64" + )); + + let SchemaType::Union { variants, .. } = &analysis.schemas["WideOrText"].schema_type else { + panic!("WideOrText should be a union"); + }; + assert_eq!(variants[0].target, "u64"); + assert_eq!(variants[1].target, "String"); + + let mut generated_analysis = analysis; + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut generated_analysis) + .expect("wide integer domains should generate"); + let compact = generated.split_whitespace().collect::(); + assert!(compact.contains("pubtypeWideInt64=u64;")); + assert!(compact.contains("pubtypeWideArray=Vec;")); + assert!(compact.contains("UnsignedInteger(u64)")); + assert!(compact.contains("pubtypeInRangeInt64=i64;")); + assert!(compact.contains("pubtypeNarrowInt32=i32;")); + assert!(compact.contains("pubtypeUnsigned64=u64;")); +} + +#[test] +fn generated_wide_integers_round_trip_exact_json_numbers() { + let mut analysis = SchemaAnalyzer::new(wide_integer_spec()) + .expect("wide-integer spec should parse") + .analyze() + .expect("wide-integer spec should analyze"); + let mut generated = CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("wide integer domains should generate"); + generated.push_str( + r#" +#[cfg(test)] +mod wide_integer_roundtrip { + use super::Envelope; + + #[test] + fn values_above_i64_are_stable() { + let input = serde_json::json!({ + "in_range": -100, + "wide": 18446744073709551615_u64, + "default_wide": 9223372036854775808_u64, + "example_wide": 18446744073709551615_u64, + "narrow": 100, + "widened_32": 5000000000_u64, + "unsigned": 18446744073709551615_u64, + "array": [9223372036854775808_u64, 18446744073709551615_u64], + "union": 18446744073709551615_u64 + }); + let hydrated: Envelope = serde_json::from_value(input.clone()).expect("hydrate"); + let output = serde_json::to_value(hydrated).expect("serialize"); + assert_eq!(output, input); + let stable: Envelope = serde_json::from_value(output).expect("rehydrate"); + assert_eq!(serde_json::to_value(stable).unwrap(), input); + } +} +"#, + ); + + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "wide-integer-roundtrip-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + fs::create_dir(temp.path().join("src")).expect("create scratch source directory"); + fs::write(temp.path().join("src/lib.rs"), generated).expect("write generated source"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/wide-integer-roundtrip-smoke"), + ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .expect("run generated round-trip test"); + assert!( + output.status.success(), + "generated wide-integer round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +}