diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b04006f..48c534df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ concurrency: cancel-in-progress: true env: + RUSTUP_TOOLCHAIN: stable RUSTFLAGS: -Dwarnings RUST_BACKTRACE: 1 rust_min: "1.90.0" @@ -114,6 +115,8 @@ jobs: name: MSRV runs-on: ubuntu-latest needs: [basics] + env: + RUSTUP_TOOLCHAIN: "1.90.0" steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@master @@ -158,6 +161,9 @@ jobs: if: contains(matrix.generator, 'rust-') - uses: Swatinem/rust-cache@v2 if: contains(matrix.generator, 'rust-') + with: + key: golden-${{ matrix.generator }} + workspaces: . -> target/golden-build/${{ matrix.generator }} - uses: actions/setup-node@v6 if: contains(matrix.generator, 'typescript-fetch') with: @@ -180,11 +186,44 @@ jobs: if: contains(matrix.generator, 'java-') || contains(matrix.generator, 'kotlin-') with: distribution: 'temurin' - java-version: '17' + java-version: '27' - uses: gradle/actions/setup-gradle@v6 if: contains(matrix.generator, 'java-') || contains(matrix.generator, 'kotlin-') + with: + gradle-version: current - run: ${{ matrix.cmd }} + script-checks: + name: Validation Scripts + runs-on: ubuntu-latest + needs: [basics] + steps: + - uses: actions/checkout@v6 + - uses: taiki-e/install-action@just + - run: just script-check + + jvm-client-tests: + name: JVM Client Tests (${{ matrix.backend }}) + runs-on: ubuntu-latest + needs: [basics] + strategy: + fail-fast: false + matrix: + backend: [java, kotlin] + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@just + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '27' + - uses: gradle/actions/setup-gradle@v6 + with: + gradle-version: current + - run: just golden-jvm::test-${{ matrix.backend }} + rust-client-tests: name: Rust Client Tests (${{ matrix.backend }}) runs-on: ubuntu-latest @@ -211,6 +250,8 @@ jobs: runs-on: ${{ matrix.os }} needs: [basics] if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + RUSTUP_TOOLCHAIN: "1.90" strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 244aaf65..420ad9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ test-output/ examples/*/generated/ examples/*/target/ tests/generated-clients/rust/*/generated/ +tests/generated-clients/jvm/*/generated/ +tests/generated-clients/jvm/**/.gradle/ +tests/generated-clients/jvm/**/build/ # mdbook build output docs/book/ diff --git a/Justfile b/Justfile index 5ba0c759..b7921bf5 100644 --- a/Justfile +++ b/Justfile @@ -8,6 +8,7 @@ mod golden-rust 'just/golden-rust.just' mod golden-python 'just/golden-python.just' mod golden-java 'just/golden-java.just' mod golden-kotlin 'just/golden-kotlin.just' +mod golden-jvm 'just/golden-jvm.just' # ---------- Build ---------- @@ -63,6 +64,10 @@ test-doc: # Run all tests (nextest + doctests) test-all: test test-doc +# Lint native validation entrypoints +script-check: + shellcheck scripts/golden-build-*.sh scripts/generate-*-client-fixtures.sh + # Run a specific test by name test-specific TEST_NAME: cargo test {{ TEST_NAME }} @@ -164,4 +169,4 @@ publish: # ---------- CI (run everything locally) ---------- # Run the full CI pipeline locally -ci: fmt-check clippy doc-check book msrv test-all coverage-lcov golden-check golden-rust::test +ci: fmt-check clippy doc-check book msrv script-check test-all coverage-lcov golden-build-all golden-rust::test golden-jvm::test diff --git a/build.rs b/build.rs index c3d50484..25032029 100644 --- a/build.rs +++ b/build.rs @@ -1,161 +1,28 @@ -//! Build script: discover OpenAPI Generator fixtures and generate one `#[test]` per file. -//! Emits `generated_fixture_tests.rs` into OUT_DIR for inclusion under `#[cfg(test)]`. +//! Register upstream fixtures with Cargo's native test runner. -use std::collections::HashSet; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; +use std::{env, fs, path::PathBuf}; -const FIXTURES_SUBDIR: &str = "tests/openapi-generator-fixtures"; -const OAS30_PREFIX: &str = "oas30/"; -const OAS31_PREFIX: &str = "oas31/"; -const OAS32_PREFIX: &str = "oas32/"; -const SUPPORTED_EXTENSIONS: &[&str] = &["yaml", "yml", "json"]; -const SKIP_SUFFIX: &str = "tags.json"; -const TEST_FN_PREFIX: &str = "test_oag_fixture_"; - -/// Load skip paths from `test_fixture_skip_paths.txt` (one path per line; # and blank lines ignored). -fn load_skip_paths(manifest_dir: &Path) -> Vec { - let path = manifest_dir.join("test_fixture_skip_paths.txt"); - let content = match fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(String::from) - .collect() -} - -fn is_skip_path(rel: &str, skip_paths: &[String]) -> bool { - if rel.ends_with(SKIP_SUFFIX) { - return true; - } - skip_paths.iter().any(|s| s == rel) -} - -fn collect_fixture_paths( - dir: &Path, - base: &Path, - prefix: &str, - skip_paths: &[String], - out: &mut Vec, -) { - if !dir.exists() { - return; - } - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for e in entries.flatten() { - let path = e.path(); - if path.is_dir() { - let name = path.file_name().unwrap().to_string_lossy(); - collect_fixture_paths( - &path, - base, - &format!("{}{}/", prefix, name), - skip_paths, - out, - ); - } else if path.is_file() { - let rel = path.strip_prefix(base).unwrap_or(&path); - let rel_str = rel.to_string_lossy().replace('\\', "/"); - let full_path = format!("{}{}", prefix, rel_str); - let ext_ok = rel - .extension() - .and_then(|e| e.to_str()) - .map(|e| SUPPORTED_EXTENSIONS.contains(&e)) - .unwrap_or(false); - if ext_ok && !is_skip_path(&full_path, skip_paths) { - out.push(full_path); - } - } - } -} - -/// Sanitize relative path to a valid Rust test function name (alphanumeric and `_` only). -fn path_to_test_name(rel: &str) -> String { - let s: String = rel - .chars() - .map(|c| match c { - '/' | '.' | '-' => '_', - c if c.is_ascii_alphanumeric() || c == '_' => c, - _ => '_', - }) - .collect(); - s.trim_matches('_') - .split("__") - .filter(|p| !p.is_empty()) - .collect::>() - .join("_") -} - -fn unique_test_name(base_name: &str, taken: &mut HashSet) -> String { - let mut name = base_name.to_string(); - let mut counter = 0u32; - while !taken.insert(name.clone()) { - counter += 1; - name = format!("{}_{}", base_name, counter); - } - name -} +#[path = "build_support/fixture_tests.rs"] +mod fixture_tests; fn main() { - let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - let manifest_dir = PathBuf::from(&manifest_dir); - let fixtures_base = manifest_dir.join(FIXTURES_SUBDIR); - - println!("cargo::rerun-if-changed={}", fixtures_base.display()); - println!( - "cargo::rerun-if-changed={}", - manifest_dir.join("test_fixture_skip_paths.txt").display() - ); - - let skip_paths = load_skip_paths(&manifest_dir); - - let oas30 = fixtures_base.join("oas30"); - let oas31 = fixtures_base.join("oas31"); - let oas32 = fixtures_base.join("oas32"); - let mut rel_paths = Vec::new(); - if oas30.exists() { - collect_fixture_paths(&oas30, &oas30, OAS30_PREFIX, &skip_paths, &mut rel_paths); + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + for input in [ + "build_support/fixture_tests.rs", + "tests/openapi-generator-fixtures", + "test_fixture_skip_paths.txt", + "test_fixture_rejections.txt", + ] { + println!( + "cargo::rerun-if-changed={}", + manifest_dir.join(input).display() + ); } - if oas31.exists() { - collect_fixture_paths(&oas31, &oas31, OAS31_PREFIX, &skip_paths, &mut rel_paths); - } - if oas32.exists() { - collect_fixture_paths(&oas32, &oas32, OAS32_PREFIX, &skip_paths, &mut rel_paths); - } - rel_paths.sort(); - - let mut test_names = HashSet::new(); - let lines: Vec = rel_paths - .iter() - .map(|rel| { - let base_name = format!("{}{}", TEST_FN_PREFIX, path_to_test_name(rel)); - let unique_name = unique_test_name(&base_name, &mut test_names); - let rel_escaped = rel.replace('\\', "/").replace('"', r#"\""#); - format!( - r#"#[test] -fn {}() {{ super::run_fixture_test("{}"); }}"#, - unique_name, rel_escaped - ) - }) - .collect(); - - let content = if lines.is_empty() { - r#"// No fixtures found (run scripts/sync-openapi-generator-fixtures.sh). -#[test] -fn test_oag_fixture_no_fixtures_placeholder() {}"# - .to_string() - } else { - lines.join("\n\n") - }; - - let out_path = Path::new(&out_dir).join("generated_fixture_tests.rs"); - fs::write(out_path, content).expect("write generated_fixture_tests.rs"); + fs::write( + out_dir.join("generated_fixture_tests.rs"), + fixture_tests::generate_fixture_tests(&manifest_dir), + ) + .expect("write generated_fixture_tests.rs"); } diff --git a/build_support/fixture_tests.rs b/build_support/fixture_tests.rs new file mode 100644 index 00000000..7b8ce168 --- /dev/null +++ b/build_support/fixture_tests.rs @@ -0,0 +1,153 @@ +//! Shared fixture discovery and Cargo test registration used by the build script. + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +const FIXTURES_SUBDIR: &str = "tests/openapi-generator-fixtures"; +const OAS30_PREFIX: &str = "oas30/"; +const OAS31_PREFIX: &str = "oas31/"; +const OAS32_PREFIX: &str = "oas32/"; +const SUPPORTED_EXTENSIONS: &[&str] = &["yaml", "yml", "json"]; +const SKIP_SUFFIX: &str = "tags.json"; +const TEST_FN_PREFIX: &str = "test_oag_fixture_"; + +/// Load fixture expectation paths (one per line; # and blank lines ignored). +fn load_paths(manifest_dir: &Path, filename: &str) -> Vec { + let path = manifest_dir.join(filename); + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(error) => panic!("cannot read {}: {error}", path.display()), + }; + content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(String::from) + .collect() +} + +fn is_skip_path(rel: &str, skip_paths: &[String]) -> bool { + if rel.ends_with(SKIP_SUFFIX) { + return true; + } + skip_paths.iter().any(|s| s == rel) +} + +fn collect_fixture_paths( + dir: &Path, + base: &Path, + prefix: &str, + skip_paths: &[String], + out: &mut Vec, +) { + let entries = fs::read_dir(dir) + .unwrap_or_else(|error| panic!("cannot discover fixtures in {}: {error}", dir.display())); + for e in entries { + let e = e.expect("fixture directory entry is readable"); + let path = e.path(); + if path.is_dir() { + collect_fixture_paths(&path, base, prefix, skip_paths, out); + } else if path.is_file() { + let rel = path.strip_prefix(base).unwrap_or(&path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + let full_path = format!("{}{}", prefix, rel_str); + let ext_ok = rel + .extension() + .and_then(|e| e.to_str()) + .map(|e| SUPPORTED_EXTENSIONS.contains(&e)) + .unwrap_or(false); + if ext_ok && !is_skip_path(&full_path, skip_paths) { + out.push(full_path); + } + } + } +} + +/// Sanitize relative path to a valid Rust test function name (alphanumeric and `_` only). +fn path_to_test_name(rel: &str) -> String { + let s: String = rel + .chars() + .map(|c| match c { + '/' | '.' | '-' => '_', + c if c.is_ascii_alphanumeric() || c == '_' => c, + _ => '_', + }) + .collect(); + s.trim_matches('_') + .split("__") + .filter(|p| !p.is_empty()) + .collect::>() + .join("_") +} + +fn unique_test_name(base_name: &str, taken: &mut HashSet) -> String { + let mut name = base_name.to_string(); + let mut counter = 0u32; + while !taken.insert(name.clone()) { + counter += 1; + name = format!("{}_{}", base_name, counter); + } + name +} + +/// Discover the corpus and emit registrations for Cargo's native test runner. +pub fn generate_fixture_tests(manifest_dir: &Path) -> String { + let fixtures_base = manifest_dir.join(FIXTURES_SUBDIR); + let skip_paths = load_paths(manifest_dir, "test_fixture_skip_paths.txt"); + let rejections = load_paths(manifest_dir, "test_fixture_rejections.txt"); + + let oas30 = fixtures_base.join("oas30"); + let oas31 = fixtures_base.join("oas31"); + let oas32 = fixtures_base.join("oas32"); + let mut rel_paths = Vec::new(); + if oas30.exists() { + collect_fixture_paths(&oas30, &oas30, OAS30_PREFIX, &skip_paths, &mut rel_paths); + } + if oas31.exists() { + collect_fixture_paths(&oas31, &oas31, OAS31_PREFIX, &skip_paths, &mut rel_paths); + } + if oas32.exists() { + collect_fixture_paths(&oas32, &oas32, OAS32_PREFIX, &skip_paths, &mut rel_paths); + } + rel_paths.sort(); + + for rejection in &rejections { + assert!( + rel_paths.binary_search(rejection).is_ok(), + "rejection fixture not registered: {rejection} (missing or excluded)" + ); + } + + let mut test_names = HashSet::new(); + let lines: Vec = rel_paths + .iter() + .map(|rel| { + let base_name = format!("{}{}", TEST_FN_PREFIX, path_to_test_name(rel)); + let unique_name = unique_test_name(&base_name, &mut test_names); + let rel_escaped = rel.replace('\\', "/").replace('"', r#"\""#); + let expectation = if rejections.contains(rel) { + "#[should_panic(expected = \"parse oas\")]\n" + } else { + "" + }; + format!( + r#"#[test] +{}fn {}() {{ super::run_fixture_test("{}"); }}"#, + expectation, unique_name, rel_escaped + ) + }) + .collect(); + + if lines.is_empty() { + r#"// No fixtures found (run scripts/sync-openapi-generator-fixtures.sh). +#[test] +fn test_oag_fixture_requires_nonempty_selection() { + panic!("no OpenAPI fixtures discovered; run scripts/sync-openapi-generator-fixtures.sh"); +}"# + .to_string() + } else { + lines.join("\n\n") + } +} diff --git a/docs/src/golden_testing.md b/docs/src/golden_testing.md index 382b2a26..76771bde 100644 --- a/docs/src/golden_testing.md +++ b/docs/src/golden_testing.md @@ -140,6 +140,49 @@ rather than replace, snapshot comparison and the all-golden compile checks. See [the consumer test README](https://github.com/rust-codegen-group/openapi-nexus/tree/main/tests/generated-clients/rust) for direct Cargo/IDE use and the retained coverage per fixture. +## JVM Client Runtime Tests + +Java and Kotlin runtime checks are standalone Gradle consumer builds under +`tests/generated-clients/jvm/`. `just golden-jvm::test` prepares fresh generated +dependencies and runs normal JUnit tests, one Gradle invocation per backend. +Generated manifests are never patched. Each fixture has an isolated consumer +classpath, and both backends assert union precedence and complete payload +round-trips, not only wrapper identity or discriminator substrings. + +There are no JVM build subprocesses or global directory locks in the Rust test +suite. The independent JVM Client Tests CI matrix and `just ci` run these checks. + +## Validation Contracts and Toolchains + +- Missing fixture files fail their parser tests. Nested fixture paths are relative + to the fixture tree exactly once; an empty discovery produces a failing test, + not a green placeholder. Known malformed upstream documents are executed as + explicit expected parse failures in `test_fixture_rejections.txt`. Missing + files or read errors cannot satisfy those rejection expectations. +- Every golden directory must have its build manifest. Native validators reject + empty selections and missing manifests instead of reporting `0/0 passed`. +- Rust golden builds retain their cache under `target/golden-build/rust-` + or the explicitly configured `CARGO_TARGET_DIR`. CI caches the same directory. +- Python prepares dependencies and resolves pyright once per backend, sharing + that environment across its fixtures. `PYTHON` selects the install interpreter; + the active `pyright` is used when available, otherwise npm resolves it once. +- Development and ordinary CI validation select current Rust `stable` (locally + validated with Rust 1.99.0). MSRV checks explicitly select Rust 1.90.0; they do + not substitute for stable validation. +- Current local JVM validation uses Homebrew JDK 27 with Gradle 9.8.1, respecting + `JAVA_HOME` or the active JVM. CI validates on JDK 27. Generated libraries may + still declare a Java 17 compile target, independently of the consumer test JVM. + +Fixture discovery is tested in-process through the same build-support module +used by `build.rs`. Cargo compiles and executes the registered parser tests +normally; no test harness launches a compiler, build tool, or validation script. +`just script-check` lints the native build entrypoints. Tool orchestration belongs +to just/CI, which directly run the golden builds and ordinary consumer projects. + +`just ci` includes generator tests and coverage, all nine generators' native +golden builds, and Rust/JVM consumer tests. Golden comparison is already included +in `test-all`, so the full recipe does not repeat it as another standalone step. + ## Extra File Detection The test harness detects when a generator produces files that have no corresponding `.golden` file. This prevents regressions where new output silently goes untested. If a generator adds a new file, the test will fail with a clear message listing the unmatched files and instructing you to run `UPDATE_GOLDEN=1`. diff --git a/docs/src/ir_reference.md b/docs/src/ir_reference.md index ccfa3f07..f0eb059a 100644 --- a/docs/src/ir_reference.md +++ b/docs/src/ir_reference.md @@ -132,6 +132,12 @@ For object properties, the outer nullable wrapper is recorded in container does not make its contents nullable, and nullability is independent of whether a property is required. +OpenAPI 3.0 boolean `exclusiveMinimum` / `exclusiveMaximum` flags retain both +`true` and `false` in validation metadata. The public 3.0 `ObjectSchema` fields +use `ExclusiveBound` to preserve those booleans as well as the legacy numeric +form already accepted by the parser. The numeric form retains its previous +lowering behavior; OpenAPI 3.1/3.2 numeric-bound handling is unchanged. + ## Operations Each `IrOperation` represents one HTTP method + path: diff --git a/just/golden-jvm.just b/just/golden-jvm.just new file mode 100644 index 00000000..87b6c268 --- /dev/null +++ b/just/golden-jvm.just @@ -0,0 +1,13 @@ +# Native consumer tests for freshly generated Java and Kotlin clients. +set working-directory := '..' + +test: test-java test-kotlin + +@prepare BACKEND: + bash ./scripts/generate-jvm-client-fixtures.sh {{ BACKEND }} + +@test-java: (prepare "java") + gradle --no-daemon --max-workers=2 -p tests/generated-clients/jvm/java test + +@test-kotlin: (prepare "kotlin") + gradle --no-daemon --max-workers=2 -p tests/generated-clients/jvm/kotlin test diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8cf9c187..73cb934d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.90" +channel = "stable" components = ["rustfmt", "clippy"] diff --git a/scripts/generate-jvm-client-fixtures.sh b/scripts/generate-jvm-client-fixtures.sh new file mode 100644 index 00000000..78e557df --- /dev/null +++ b/scripts/generate-jvm-client-fixtures.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +backend="${1:?Usage: generate-jvm-client-fixtures.sh }" +case "$backend" in + java|kotlin) ;; + *) echo "unsupported JVM backend: $backend" >&2; exit 1 ;; +esac +root="$(cd "$(dirname "$0")/.." && pwd)" +project="$root/tests/generated-clients/jvm/$backend" + +while read -r name fixture; do + case "$name" in + ""|\#*) continue ;; + *[!a-zA-Z0-9_-]*) echo "invalid fixture name: $name" >&2; exit 1 ;; + esac + output="$project/generated/$name" + # Only these fixed, ignored fixture output directories are disposable. + rm -rf "$output" + cargo run --quiet --manifest-path "$root/Cargo.toml" --bin openapi-nexus -- generate \ + --config "$root/tests/generated-clients/config.toml" \ + --input "$root/tests/fixtures/$fixture" --output "$output" --generators "$backend-okhttp" +done < "$root/tests/generated-clients/jvm/fixtures.txt" diff --git a/scripts/generate-rust-client-fixtures.sh b/scripts/generate-rust-client-fixtures.sh index ba5927e9..12a690cf 100644 --- a/scripts/generate-rust-client-fixtures.sh +++ b/scripts/generate-rust-client-fixtures.sh @@ -16,7 +16,7 @@ for fixture in typed-error-responses rust-error-payload-shapes nested-nullable; # Remove stale source files so a deleted declaration cannot pass unnoticed. rm -rf "$output" cargo run --quiet --manifest-path "$root/Cargo.toml" --bin openapi-nexus -- generate \ - --config "$root/tests/generated-clients/rust/config.toml" \ + --config "$root/tests/generated-clients/config.toml" \ --input "$root/tests/fixtures/valid/$fixture.yaml" \ --output "$output" \ --generators "rust-$backend" diff --git a/scripts/golden-build-common.sh b/scripts/golden-build-common.sh new file mode 100644 index 00000000..f58cbd8a --- /dev/null +++ b/scripts/golden-build-common.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Shared input contracts for every native golden validator. + +golden_require_projects() { + local directory="$1" marker="$2" project count=0 + if [ ! -d "$directory" ]; then + echo "error: golden dir not found: $directory" >&2 + return 1 + fi + for project in "$directory"/*/; do + [ -d "$project" ] || continue + if [ ! -f "$project/$marker" ]; then + echo "error: missing $marker in $project" >&2 + return 1 + fi + count=$((count + 1)) + done + golden_require_selection "$count" +} + +golden_require_selection() { + if [ "$1" -eq 0 ]; then + echo "error: no golden projects selected; no compilation was performed" >&2 + return 1 + fi +} diff --git a/scripts/golden-build-go.sh b/scripts/golden-build-go.sh index c1ac1094..91b33dae 100755 --- a/scripts/golden-build-go.sh +++ b/scripts/golden-build-go.sh @@ -4,10 +4,9 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/go/go-http" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 -fi +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" go.mod.golden total=0; passed=0; failed=0; fails=() @@ -43,6 +42,7 @@ for test_dir in "$golden"/*/; do done echo +golden_require_selection "$total" echo "Go: $passed/$total passed" if [ "$failed" -gt 0 ]; then echo "failed:" diff --git a/scripts/golden-build-java.sh b/scripts/golden-build-java.sh index 67258c8a..de08b94b 100755 --- a/scripts/golden-build-java.sh +++ b/scripts/golden-build-java.sh @@ -4,10 +4,9 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/java/java-okhttp" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 -fi +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" build.gradle.golden tmp=$(mktemp -d) log=$(mktemp) @@ -45,10 +44,7 @@ SETTINGS done echo -if [ "$total" -eq 0 ]; then - echo "Java: 0/0 passed" - exit 0 -fi +golden_require_selection "$total" echo "Java: compiling $total golden projects" if (cd "$tmp" && gradle --no-daemon --quiet --parallel --continue "${tasks[@]}") >"$log" 2>&1; then diff --git a/scripts/golden-build-kotlin.sh b/scripts/golden-build-kotlin.sh index 328cebcd..042f3adf 100755 --- a/scripts/golden-build-kotlin.sh +++ b/scripts/golden-build-kotlin.sh @@ -4,10 +4,9 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/kotlin/kotlin-okhttp" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 -fi +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" build.gradle.kts.golden tmp=$(mktemp -d) log=$(mktemp) @@ -45,10 +44,7 @@ SETTINGS done echo -if [ "$total" -eq 0 ]; then - echo "Kotlin: 0/0 passed" - exit 0 -fi +golden_require_selection "$total" echo "Kotlin: compiling $total golden projects" if (cd "$tmp" && gradle --no-daemon --quiet --parallel --continue "${tasks[@]}") >"$log" 2>&1; then diff --git a/scripts/golden-build-python.sh b/scripts/golden-build-python.sh index 2af8f89c..7a135b46 100755 --- a/scripts/golden-build-python.sh +++ b/scripts/golden-build-python.sh @@ -6,9 +6,24 @@ backend="${1:?Usage: golden-build-python.sh }" root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/python/python-$backend" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" pyproject.toml.golden + +# One dependency installation and checker resolution per backend, not per fixture. +deps=$(mktemp -d) +tmp=""; log="" +cleanup() { + rm -rf "$deps" + [ -z "$tmp" ] || rm -rf "$tmp" + [ -z "$log" ] || rm -f "$log" +} +trap cleanup EXIT +"${PYTHON:-python3}" -m pip install "$backend" --quiet --target "$deps" +if command -v pyright >/dev/null 2>&1; then + checker=$(command -v pyright) +else + checker=$(npm exec --yes --package pyright -- which pyright) fi total=0; passed=0; failed=0; fails=() @@ -20,7 +35,6 @@ for test_dir in "$golden"/*/; do printf "[%d] %s... " "$total" "$name" tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' EXIT while IFS= read -r -d '' f; do rel="${f#"$test_dir"}" @@ -29,19 +43,17 @@ for test_dir in "$golden"/*/; do cp "$f" "$dst" done < <(find "$test_dir" -type f -name '*.golden' -print0) - pip3 install "$backend" --quiet --target "$tmp/.deps" 2>/dev/null - cat > "$tmp/pyrightconfig.json" <"$log" 2>&1; then + if (cd "$tmp" && "$checker") >"$log" 2>&1; then echo "ok" passed=$((passed + 1)) else @@ -52,10 +64,11 @@ PYRIGHT fi rm -rf "$tmp" "$log" - trap - EXIT + tmp=""; log="" done echo +golden_require_selection "$total" echo "Python-$backend: $passed/$total passed" if [ "$failed" -gt 0 ]; then echo "failed:" diff --git a/scripts/golden-build-rust.sh b/scripts/golden-build-rust.sh index e92ee8e8..4bcda893 100755 --- a/scripts/golden-build-rust.sh +++ b/scripts/golden-build-rust.sh @@ -6,12 +6,16 @@ backend="${1:?Usage: golden-build-rust.sh }" root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/rust/rust-$backend" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 -fi - -shared_target=$(mktemp -d) +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" Cargo.toml.golden + +shared_target="${CARGO_TARGET_DIR:-$root/target/golden-build/rust-$backend}" +case "$shared_target" in + /*) ;; + *) shared_target="$PWD/$shared_target" ;; +esac +mkdir -p "$shared_target" total=0; passed=0; failed=0; fails=() for test_dir in "$golden"/*/; do @@ -45,7 +49,7 @@ for test_dir in "$golden"/*/; do trap - EXIT done -rm -rf "$shared_target" +golden_require_selection "$total" echo echo "Rust-$backend: $passed/$total passed" diff --git a/scripts/golden-build-typescript.sh b/scripts/golden-build-typescript.sh index b65cd2fc..786f3115 100755 --- a/scripts/golden-build-typescript.sh +++ b/scripts/golden-build-typescript.sh @@ -4,10 +4,9 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" golden="$root/tests/golden/typescript/typescript-fetch" -if [ ! -d "$golden" ]; then - echo "error: golden dir not found: $golden" >&2 - exit 1 -fi +# shellcheck source=./scripts/golden-build-common.sh +source "$root/scripts/golden-build-common.sh" +golden_require_projects "$golden" tsconfig.json.golden toolchain="${1:-}" if [ "$toolchain" != "tsc" ] && [ "$toolchain" != "vp" ]; then @@ -73,6 +72,7 @@ for test_dir in "$golden"/*/; do done echo +golden_require_selection "$total" echo "TypeScript ($toolchain): $passed/$total passed" if [ "$failed" -gt 0 ]; then echo "failed:" diff --git a/src/ir/lower/v30.rs b/src/ir/lower/v30.rs index d9e41ed7..40b90535 100644 --- a/src/ir/lower/v30.rs +++ b/src/ir/lower/v30.rs @@ -1291,9 +1291,15 @@ fn extract_validation(obj: &ObjectSchema) -> Option { min_length: obj.min_length, pattern: obj.pattern.clone(), maximum: obj.maximum.as_ref().and_then(|n| n.as_f64()), - exclusive_maximum: obj.exclusive_maximum.as_ref().map(|_| true), + exclusive_maximum: obj + .exclusive_maximum + .as_ref() + .map(|bound| bound.is_exclusive()), minimum: obj.minimum.as_ref().and_then(|n| n.as_f64()), - exclusive_minimum: obj.exclusive_minimum.as_ref().map(|_| true), + exclusive_minimum: obj + .exclusive_minimum + .as_ref() + .map(|bound| bound.is_exclusive()), multiple_of: obj.multiple_of.as_ref().and_then(|n| n.as_f64()), max_items: obj.max_items, min_items: obj.min_items, diff --git a/src/spec/oas30/spec/schema.rs b/src/spec/oas30/spec/schema.rs index 7cd55160..c3b3dd98 100644 --- a/src/spec/oas30/spec/schema.rs +++ b/src/spec/oas30/spec/schema.rs @@ -32,6 +32,24 @@ pub enum AdditionalProperties { Schema(Box), } +/// OpenAPI 3.0 uses boolean exclusivity flags. Retain the previously accepted +/// numeric representation for compatibility with generated specifications. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ExclusiveBound { + Boolean(bool), + Number(serde_json::Number), +} + +impl ExclusiveBound { + pub fn is_exclusive(&self) -> bool { + match self { + Self::Boolean(value) => *value, + Self::Number(_) => true, + } + } +} + /// A schema object allows the definition of input and output data types. /// OAS 3.0 uses a single `type` string and optional `nullable` boolean. #[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)] @@ -71,13 +89,13 @@ pub struct ObjectSchema { pub maximum: Option, #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")] - pub exclusive_maximum: Option, + pub exclusive_maximum: Option, #[serde(skip_serializing_if = "Option::is_none")] pub minimum: Option, #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")] - pub exclusive_minimum: Option, + pub exclusive_minimum: Option, #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")] pub max_length: Option, diff --git a/src/spec/test_utils.rs b/src/spec/test_utils.rs index b7113991..29226931 100644 --- a/src/spec/test_utils.rs +++ b/src/spec/test_utils.rs @@ -1,6 +1,5 @@ //! Test-only utilities for fixture-based spec parsing tests. -use std::env; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -11,34 +10,12 @@ const FIXTURES_SUBDIR: &str = "tests/openapi-generator-fixtures"; const OAS30_PREFIX: &str = "oas30/"; const OAS31_PREFIX: &str = "oas31/"; -/// Candidate base directories for the fixtures tree (order matters: first found wins). -fn fixture_base_candidates() -> Vec { - let mut candidates = Vec::new(); - if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") { - candidates.push( - Path::new(&manifest_dir) - .join("..") - .join("..") - .join(FIXTURES_SUBDIR), - ); - candidates.push(Path::new(&manifest_dir).join("..").join(FIXTURES_SUBDIR)); - } - candidates.extend([ - Path::new(FIXTURES_SUBDIR).to_path_buf(), - Path::new("../").join(FIXTURES_SUBDIR), - Path::new("../../").join(FIXTURES_SUBDIR), - Path::new("../../../").join(FIXTURES_SUBDIR), - ]); - candidates -} - -/// Resolve `rel_path` to an absolute path under one of the candidate fixture bases. -/// Returns `None` if the path does not exist under any candidate (e.g. stale generated test). +/// Resolve a fixture under this checkout, independent of the test's working directory. fn resolve_fixture_path(rel_path: &str) -> Option { - fixture_base_candidates().into_iter().find_map(|base| { - let p = base.join(rel_path); - if p.exists() { Some(p) } else { None } - }) + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURES_SUBDIR) + .join(rel_path); + path.is_file().then_some(path) } /// Parse fixture content as OAS 3.0 or 3.1 spec based on `rel_path` prefix. @@ -61,14 +38,20 @@ fn parse_fixture(content: &str, ext: &str, rel_path: &str) { } /// Load fixture at `rel_path`, parse as OAS 3.0 or 3.1, and panic on error. -/// Skips (passes) if the path does not exist under any candidate (stale generated test). pub fn run_fixture_test(rel_path: &str) { - let path = match resolve_fixture_path(rel_path) { - Some(p) => p, - None => return, // path missing, e.g. stale generated test; skip - }; + let path = + resolve_fixture_path(rel_path).unwrap_or_else(|| panic!("fixture not found: {rel_path}")); let content = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); parse_fixture(&content, ext, rel_path); } + +#[cfg(test)] +mod tests { + #[test] + #[should_panic(expected = "fixture not found")] + fn missing_fixture_is_a_failure_not_a_passing_parser_test() { + super::run_fixture_test("oas30/__missing_fixture_regression__.yaml"); + } +} diff --git a/test_fixture_rejections.txt b/test_fixture_rejections.txt new file mode 100644 index 00000000..626510d4 --- /dev/null +++ b/test_fixture_rejections.txt @@ -0,0 +1,28 @@ +# Explicit rejection cases: these files are exercised, not silently skipped. +# Each registered test must fail during spec parsing, not file loading. + +# Swagger 2 documents placed in the upstream oas30 tree (no openapi field). +oas30/cpp-ue4/petstore.yaml +oas30/spring/petstore-with-spring-provide-args.yaml +# Schema fragment without required document-level info/paths. +oas30/csharp/composed-enum.yaml +# Invalid string values `required: optional` where OpenAPI requires booleans. +oas30/dart/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/elixir/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/javascript/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/jaxrs-spec/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/k6/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/perl/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/php/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/python/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/ruby/petstore-with-fake-endpoints-models-for-testing.yaml +oas30/spring/petstore-with-fake-endpoints-models-for-testing-x-implements.yaml +# Empty/null response objects rather than Response Objects. +oas30/kotlin/issue21238_queryParam_validation.yaml +# Schema.required is a boolean, not a list of required property names. +oas30/kotlin/petstore-with-tags.yaml +# Unquoted YAML null key where a string property name is required. +oas30/kotlin/reserved_words.yaml +# Numeric YAML parameter names where OpenAPI requires string names. +oas30/python/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml diff --git a/test_fixture_skip_paths.txt b/test_fixture_skip_paths.txt index 60321285..d1465237 100644 --- a/test_fixture_skip_paths.txt +++ b/test_fixture_skip_paths.txt @@ -27,14 +27,7 @@ oas30/v1beta3.yaml oas30/validation.yaml oas31/exclusive-min-max.yaml # -# Stale / wrong spec (e.g. cpp-ue4/petstore is Swagger 2.0) -oas30/cpp-ue4/cpp-ue4/petstore.yaml -oas30/csharp/csharp/composed-enum.yaml -oas30/csharp/csharp/composed_enum.yaml -# -# asciidoc (and stale duplicated-path variants if present) -oas30/asciidoc/asciidoc/api-docs.json -oas30/asciidoc/generated-snippets/rest/project/GET/asciidoc/generated-snippets/rest/project/GET/GET.json +# asciidoc fragments (malformed complete documents are in test_fixture_rejections.txt) oas30/asciidoc/api-docs.json oas30/asciidoc/generated-snippets/rest/project/GET/GET.json # diff --git a/tests/fixture_discovery.rs b/tests/fixture_discovery.rs new file mode 100644 index 00000000..8eb23fe7 --- /dev/null +++ b/tests/fixture_discovery.rs @@ -0,0 +1,60 @@ +//! Exercise the actual build-support logic in-process, without compiling a harness. + +use std::{fs, path::Path}; + +#[path = "../build_support/fixture_tests.rs"] +mod fixture_tests; + +#[test] +fn nested_fixture_paths_resolve_once_under_the_corpus() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let registrations = fixture_tests::generate_fixture_tests(root); + assert!(registrations.contains("super::run_fixture_test(\"oas30/ada/petstore.yaml\")")); + + let paths: Vec<_> = registrations + .lines() + .filter_map(|line| line.split_once("super::run_fixture_test(\"")) + .map(|(_, rest)| rest.split_once('"').expect("registered path ends").0) + .collect(); + assert!(!paths.is_empty(), "the corpus must register real fixtures"); + for relative in paths { + let path = root.join("tests/openapi-generator-fixtures").join(relative); + fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("registered fixture {}: {error}", path.display())); + } +} + +#[test] +fn empty_discovery_registers_a_failure_instead_of_a_passing_placeholder() { + // This checked-in directory has no upstream corpus or expectation manifests. + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/valid"); + assert!(root.is_dir()); + assert_eq!( + fixture_tests::generate_fixture_tests(&root), + r#"// No fixtures found (run scripts/sync-openapi-generator-fixtures.sh). +#[test] +fn test_oag_fixture_requires_nonempty_selection() { + panic!("no OpenAPI fixtures discovered; run scripts/sync-openapi-generator-fixtures.sh"); +}"# + ); +} + +#[test] +#[should_panic(expected = "rejection fixture not registered: oas30/missing.yaml")] +fn missing_rejection_fixture_fails_even_when_other_fixtures_are_registered() { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/discovery/missing-rejection"); + fixture_tests::generate_fixture_tests(&root); +} + +#[test] +#[should_panic(expected = "rejection fixture not registered: oas30/rejected.yaml")] +fn excluded_rejection_fixture_fails_even_when_its_file_exists() { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/discovery/excluded-rejection"); + assert!( + root.join("tests/openapi-generator-fixtures/oas30/rejected.yaml") + .is_file() + ); + fixture_tests::generate_fixture_tests(&root); +} diff --git a/tests/fixtures/discovery/excluded-rejection/test_fixture_rejections.txt b/tests/fixtures/discovery/excluded-rejection/test_fixture_rejections.txt new file mode 100644 index 00000000..b3cad356 --- /dev/null +++ b/tests/fixtures/discovery/excluded-rejection/test_fixture_rejections.txt @@ -0,0 +1 @@ +oas30/rejected.yaml diff --git a/tests/fixtures/discovery/excluded-rejection/test_fixture_skip_paths.txt b/tests/fixtures/discovery/excluded-rejection/test_fixture_skip_paths.txt new file mode 100644 index 00000000..b3cad356 --- /dev/null +++ b/tests/fixtures/discovery/excluded-rejection/test_fixture_skip_paths.txt @@ -0,0 +1 @@ +oas30/rejected.yaml diff --git a/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/present.yaml b/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/present.yaml new file mode 100644 index 00000000..56b93dd3 --- /dev/null +++ b/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/present.yaml @@ -0,0 +1,5 @@ +openapi: 3.0.3 +info: + title: Present fixture + version: '1' +paths: {} diff --git a/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/rejected.yaml b/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/rejected.yaml new file mode 100644 index 00000000..1577e224 --- /dev/null +++ b/tests/fixtures/discovery/excluded-rejection/tests/openapi-generator-fixtures/oas30/rejected.yaml @@ -0,0 +1,5 @@ +swagger: '2.0' +info: + title: Rejected fixture + version: '1' +paths: {} diff --git a/tests/fixtures/discovery/missing-rejection/test_fixture_rejections.txt b/tests/fixtures/discovery/missing-rejection/test_fixture_rejections.txt new file mode 100644 index 00000000..e838e90a --- /dev/null +++ b/tests/fixtures/discovery/missing-rejection/test_fixture_rejections.txt @@ -0,0 +1 @@ +oas30/missing.yaml diff --git a/tests/fixtures/discovery/missing-rejection/tests/openapi-generator-fixtures/oas30/present.yaml b/tests/fixtures/discovery/missing-rejection/tests/openapi-generator-fixtures/oas30/present.yaml new file mode 100644 index 00000000..56b93dd3 --- /dev/null +++ b/tests/fixtures/discovery/missing-rejection/tests/openapi-generator-fixtures/oas30/present.yaml @@ -0,0 +1,5 @@ +openapi: 3.0.3 +info: + title: Present fixture + version: '1' +paths: {} diff --git a/tests/generated-clients/rust/config.toml b/tests/generated-clients/config.toml similarity index 100% rename from tests/generated-clients/rust/config.toml rename to tests/generated-clients/config.toml diff --git a/tests/generated-clients/jvm/README.md b/tests/generated-clients/jvm/README.md new file mode 100644 index 00000000..415af281 --- /dev/null +++ b/tests/generated-clients/jvm/README.md @@ -0,0 +1,45 @@ +# JVM generated-client tests + +Java and Kotlin each have an independent Gradle consumer build. The public CLI +generates the six inputs in `fixtures.txt` into ignored `generated/` directories. +Generated manifests and source are used unchanged. Each fixture has a separate +consumer subproject, so identically named SDK classes do not share a classpath. + +```bash +just golden-jvm::test +just golden-jvm::test-java +just golden-jvm::test-kotlin + +# Prepare once, then run a normal Gradle test directly or from an IDE. +just golden-jvm::prepare java +gradle -p tests/generated-clients/jvm/java test +gradle -p tests/generated-clients/jvm/java :cases:any-union:test +``` + +Test sources and dependency lockfiles are checked in under each build's `cases/` +tree. Update locks intentionally with +`gradle -p tests/generated-clients/jvm/java test --write-locks` (and the +corresponding Kotlin command). A single Gradle build runs all fixtures for a +backend and retains normal Gradle caches; Rust tests no longer write JVM programs, patch Gradle files, +launch Gradle, or acquire global directory locks. Interrupted processes therefore +cannot leave the previous harness's lock directories behind. + +The cases retain charset/header decoding, union dispatch, explicit nulls, and +tagged/untagged shapes. They additionally verify that string/number alternatives +win before `Any`, that fallback payloads are preserved, and that discriminator, +adjacent, and external round-trips retain complete JSON and decoded payloads. + +The runner respects `JAVA_HOME` or Gradle's active JVM. Current local validation +uses Homebrew JDK 27 and Gradle 9.8.1; CI also runs the JVM validation on JDK 27. +Generated libraries keep their declared Java 17 compilation target. The Foojay +resolver supplies that compiler where needed, without forcing the consumer tests +to run on an older JVM. No patch-versioned JDK installation path is baked into +the tests. + +On JDK 27, the generated Kotlin clients' Gson 2.11.0 emits final-field reflection +warnings; Kotlin's Gradle plugin also reports Gradle 10 deprecations. Tests do +not suppress either warning or rewrite generated dependencies to hide them. + +Run this matrix separately from generator coverage. `just ci` and the CI JVM +Client Tests matrix include it. Regenerate after any generator or fixture change; +do not edit generated dependencies to make a test pass. diff --git a/tests/generated-clients/jvm/build-common.gradle b/tests/generated-clients/jvm/build-common.gradle new file mode 100644 index 00000000..b6e3aa95 --- /dev/null +++ b/tests/generated-clients/jvm/build-common.gradle @@ -0,0 +1,28 @@ +apply plugin: "java" + +subprojects { + if (path.startsWith(":cases:")) { + apply plugin: "java" + if (fileTree("src/test/java").matching { include "**/*Test.java" }.isEmpty()) { + throw new GradleException("No consumer tests found in ${projectDir}") + } + repositories { mavenCentral() } + dependencyLocking { lockAllConfigurations() } + dependencies { + testImplementation project(":clients:${project.name}") + testImplementation platform("org.junit:junit-bom:5.12.2") + testImplementation "org.junit.jupiter:junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" + testImplementation "com.google.code.gson:gson:2.11.0" + testImplementation "com.squareup.okhttp3:okhttp:4.12.0" + } + tasks.named("test") { + useJUnitPlatform() + testLogging { events "passed", "skipped", "failed" } + } + } +} + +tasks.named("test") { + dependsOn subprojects.findAll { it.path.startsWith(":cases:") }.collect { "${it.path}:test" } +} diff --git a/tests/generated-clients/jvm/fixtures.txt b/tests/generated-clients/jvm/fixtures.txt new file mode 100644 index 00000000..b3cd81ab --- /dev/null +++ b/tests/generated-clients/jvm/fixtures.txt @@ -0,0 +1,7 @@ +# Consumer project name and input under tests/fixtures/. +typed-error-responses valid/typed-error-responses.yaml +closed-sum valid/type-aliases/closed-sum-payload-shapes.yaml +object-union valid/comprehensive-schemas.yaml +any-union valid/type-aliases/union-with-any.yaml +tagged-union valid/type-aliases/discriminated-union-mixed-unit-and-allof.yaml +tagged-shapes valid/enum-repr/enum-repr.yaml diff --git a/tests/generated-clients/jvm/java/build.gradle b/tests/generated-clients/jvm/java/build.gradle new file mode 100644 index 00000000..40528512 --- /dev/null +++ b/tests/generated-clients/jvm/java/build.gradle @@ -0,0 +1 @@ +apply from: file("../build-common.gradle") diff --git a/tests/generated-clients/jvm/java/cases/any-union/gradle.lockfile b/tests/generated-clients/jvm/java/cases/any-union/gradle.lockfile new file mode 100644 index 00000000..ee58d791 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/any-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:any-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/any-union/src/test/java/AnyUnionTest.java b/tests/generated-clients/jvm/java/cases/any-union/src/test/java/AnyUnionTest.java new file mode 100644 index 00000000..9ae9bbc1 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/any-union/src/test/java/AnyUnionTest.java @@ -0,0 +1,29 @@ +import com.example.sdk.models.Config; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class AnyUnionTest { + private final Gson gson = new Gson(); + + @org.junit.jupiter.api.Test + void stringPrefersItsSpecificVariantOverAny() { + Config.VariantString value = assertInstanceOf(Config.VariantString.class, gson.fromJson("\"specific\"", Config.class)); + assertEquals("specific", value.value0()); + } + + @org.junit.jupiter.api.Test + void numberPrefersItsSpecificVariantOverAny() { + Config.VariantNumber value = assertInstanceOf(Config.VariantNumber.class, gson.fromJson("7.5", Config.class)); + assertEquals(7.5, value.value0()); + } + + @org.junit.jupiter.api.Test + void unmatchedValuesUseAnyWithoutLosingTheirPayload() { + for (String json : new String[] {"true", "{\"key\":1}"}) { + Config.VariantAny value = assertInstanceOf(Config.VariantAny.class, gson.fromJson(json, Config.class)); + assertEquals(JsonParser.parseString(json), value.value0()); + assertEquals(JsonParser.parseString(json), JsonParser.parseString(gson.toJson(value, Config.class))); + } + } +} diff --git a/tests/generated-clients/jvm/java/cases/closed-sum/gradle.lockfile b/tests/generated-clients/jvm/java/cases/closed-sum/gradle.lockfile new file mode 100644 index 00000000..f8918557 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/closed-sum/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:closed-sum:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/closed-sum/src/test/java/ClosedSumTest.java b/tests/generated-clients/jvm/java/cases/closed-sum/src/test/java/ClosedSumTest.java new file mode 100644 index 00000000..211e5711 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/closed-sum/src/test/java/ClosedSumTest.java @@ -0,0 +1,15 @@ +import com.example.sdk.models.Choice; +import com.google.gson.Gson; +import static org.junit.jupiter.api.Assertions.*; + +public final class ClosedSumTest { + @org.junit.jupiter.api.Test + void preservesArraysPrimitivesAndNull() { + Gson gson = new Gson(); + Choice.VariantArray array = assertInstanceOf(Choice.VariantArray.class, gson.fromJson("[1,2]", Choice.class)); + assertEquals(java.util.List.of(1, 2), array.value0()); + assertEquals("\"ok\"", gson.toJson(new Choice.VariantString("ok"), Choice.class)); + assertInstanceOf(Choice.VariantNull.class, gson.fromJson("null", Choice.class)); + assertEquals("null", gson.toJson(Choice.VariantNull.INSTANCE, Choice.class)); + } +} diff --git a/tests/generated-clients/jvm/java/cases/object-union/gradle.lockfile b/tests/generated-clients/jvm/java/cases/object-union/gradle.lockfile new file mode 100644 index 00000000..7d392e6f --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/object-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:object-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/object-union/src/test/java/ObjectUnionTest.java b/tests/generated-clients/jvm/java/cases/object-union/src/test/java/ObjectUnionTest.java new file mode 100644 index 00000000..21997ad9 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/object-union/src/test/java/ObjectUnionTest.java @@ -0,0 +1,12 @@ +import com.example.sdk.models.OneOfObjects; +import com.google.gson.Gson; +import static org.junit.jupiter.api.Assertions.*; + +public final class ObjectUnionTest { + @org.junit.jupiter.api.Test + void selectsMatchingRequiredFields() { + Gson gson = new Gson(); + OneOfObjects value = gson.fromJson("{\"user\":{\"id\":1}}", OneOfObjects.class); + assertInstanceOf(OneOfObjects.VariantNestedObject.class, value); + } +} diff --git a/tests/generated-clients/jvm/java/cases/tagged-shapes/gradle.lockfile b/tests/generated-clients/jvm/java/cases/tagged-shapes/gradle.lockfile new file mode 100644 index 00000000..33a0e553 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/tagged-shapes/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:tagged-shapes:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/tagged-shapes/src/test/java/TaggedShapesTest.java b/tests/generated-clients/jvm/java/cases/tagged-shapes/src/test/java/TaggedShapesTest.java new file mode 100644 index 00000000..e1f4f22e --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/tagged-shapes/src/test/java/TaggedShapesTest.java @@ -0,0 +1,31 @@ +import com.example.sdk.models.AdjacentlyTaggedEnum; +import com.example.sdk.models.ExternallyTaggedEnum; +import com.example.sdk.models.VariantA; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class TaggedShapesTest { + @org.junit.jupiter.api.Test + void adjacentRoundTripRetainsTheCompletePayload() { + Gson gson = new Gson(); + VariantA payload = new VariantA("value", 7); + AdjacentlyTaggedEnum value = new AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA(payload); + String json = gson.toJson(value, AdjacentlyTaggedEnum.class); + assertEquals(JsonParser.parseString("{\"ty\":\"ADJACENTLY_TAGGED_ENUM_VARIANT_A\",\"data\":{\"field1\":\"value\",\"field2\":7}}"), JsonParser.parseString(json)); + var decoded = assertInstanceOf(AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA.class, gson.fromJson(json, AdjacentlyTaggedEnum.class)); + assertEquals("value", decoded.value0().getField1()); + assertEquals(7, decoded.value0().getField2()); + } + + @org.junit.jupiter.api.Test + void externalRoundTripRetainsTheCompletePayload() { + Gson gson = new Gson(); + ExternallyTaggedEnum value = new ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA(new VariantA("value", 7)); + String json = gson.toJson(value, ExternallyTaggedEnum.class); + assertEquals(JsonParser.parseString("{\"EXTERNALLY_TAGGED_ENUM_VARIANT_A\":{\"field1\":\"value\",\"field2\":7}}"), JsonParser.parseString(json)); + var decoded = assertInstanceOf(ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA.class, gson.fromJson(json, ExternallyTaggedEnum.class)); + assertEquals("value", decoded.value0().getField1()); + assertEquals(7, decoded.value0().getField2()); + } +} diff --git a/tests/generated-clients/jvm/java/cases/tagged-union/gradle.lockfile b/tests/generated-clients/jvm/java/cases/tagged-union/gradle.lockfile new file mode 100644 index 00000000..703457fd --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/tagged-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:tagged-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/tagged-union/src/test/java/TaggedUnionTest.java b/tests/generated-clients/jvm/java/cases/tagged-union/src/test/java/TaggedUnionTest.java new file mode 100644 index 00000000..e81594a1 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/tagged-union/src/test/java/TaggedUnionTest.java @@ -0,0 +1,16 @@ +import com.example.sdk.models.Setup; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class TaggedUnionTest { + @org.junit.jupiter.api.Test + void preservesDiscriminatorAndBusinessFields() { + Gson gson = new Gson(); + String json = "{\"kind\":\"SETUP_QUICK\",\"name\":\"x\",\"engine\":\"e\"}"; + Setup.VariantSetupQuick setup = assertInstanceOf(Setup.VariantSetupQuick.class, gson.fromJson(json, Setup.class)); + assertEquals("x", setup.value0().getName()); + assertEquals("e", setup.value0().getEngine()); + assertEquals(JsonParser.parseString(json), JsonParser.parseString(gson.toJson(setup, Setup.class))); + } +} diff --git a/tests/generated-clients/jvm/java/cases/typed-error-responses/gradle.lockfile b/tests/generated-clients/jvm/java/cases/typed-error-responses/gradle.lockfile new file mode 100644 index 00000000..cbe4a336 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/typed-error-responses/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:typed-error-responses:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/java/cases/typed-error-responses/src/test/java/CharsetTest.java b/tests/generated-clients/jvm/java/cases/typed-error-responses/src/test/java/CharsetTest.java new file mode 100644 index 00000000..59a77512 --- /dev/null +++ b/tests/generated-clients/jvm/java/cases/typed-error-responses/src/test/java/CharsetTest.java @@ -0,0 +1,21 @@ +import com.example.sdk.apis.CreateResourceServerError; +import com.example.sdk.apis.CreateResourceException; +import java.nio.charset.StandardCharsets; +import okhttp3.Headers; + +public final class CharsetTest { + @org.junit.jupiter.api.Test + void preservesCharsetAndHeaders() { + byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1); + Headers headers = Headers.of("Content-Type", "application/json; charset=iso-8859-1", "Retry-After", "120"); + CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw); + String message = detail.body().getMessage(); + if (!"caf\u00e9".equals(message)) { + throw new AssertionError("expected charset-aware message, got: " + message); + } + CreateResourceException error = new CreateResourceException(503, "Service Unavailable", message, headers, raw, detail); + if (!"120".equals(error.getRetryAfterHeader())) { + throw new AssertionError("expected Retry-After header"); + } + } +} diff --git a/tests/generated-clients/jvm/java/settings.gradle b/tests/generated-clients/jvm/java/settings.gradle new file mode 100644 index 00000000..289dfe4d --- /dev/null +++ b/tests/generated-clients/jvm/java/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +apply from: file("../settings-common.gradle") diff --git a/tests/generated-clients/jvm/kotlin/build.gradle b/tests/generated-clients/jvm/kotlin/build.gradle new file mode 100644 index 00000000..40528512 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/build.gradle @@ -0,0 +1 @@ +apply from: file("../build-common.gradle") diff --git a/tests/generated-clients/jvm/kotlin/cases/any-union/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/any-union/gradle.lockfile new file mode 100644 index 00000000..7db3fa1e --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/any-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:any-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/any-union/src/test/java/AnyUnionTest.java b/tests/generated-clients/jvm/kotlin/cases/any-union/src/test/java/AnyUnionTest.java new file mode 100644 index 00000000..c2ef7260 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/any-union/src/test/java/AnyUnionTest.java @@ -0,0 +1,29 @@ +import com.example.sdk.models.Config; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class AnyUnionTest { + private final Gson gson = new Gson(); + + @org.junit.jupiter.api.Test + void stringPrefersItsSpecificVariantOverAny() { + Config.VariantString value = assertInstanceOf(Config.VariantString.class, gson.fromJson("\"specific\"", Config.class)); + assertEquals("specific", value.getValue0()); + } + + @org.junit.jupiter.api.Test + void numberPrefersItsSpecificVariantOverAny() { + Config.VariantNumber value = assertInstanceOf(Config.VariantNumber.class, gson.fromJson("7.5", Config.class)); + assertEquals(7.5, value.getValue0()); + } + + @org.junit.jupiter.api.Test + void unmatchedValuesUseAnyWithoutLosingTheirPayload() { + for (String json : new String[] {"true", "{\"key\":1}"}) { + Config.VariantAny value = assertInstanceOf(Config.VariantAny.class, gson.fromJson(json, Config.class)); + assertEquals(JsonParser.parseString(json), value.getValue0()); + assertEquals(JsonParser.parseString(json), JsonParser.parseString(gson.toJson(value, Config.class))); + } + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/closed-sum/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/closed-sum/gradle.lockfile new file mode 100644 index 00000000..07c29e30 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/closed-sum/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:closed-sum:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/closed-sum/src/test/java/ClosedSumTest.java b/tests/generated-clients/jvm/kotlin/cases/closed-sum/src/test/java/ClosedSumTest.java new file mode 100644 index 00000000..e29b9033 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/closed-sum/src/test/java/ClosedSumTest.java @@ -0,0 +1,15 @@ +import com.example.sdk.models.Choice; +import com.google.gson.Gson; +import static org.junit.jupiter.api.Assertions.*; + +public final class ClosedSumTest { + @org.junit.jupiter.api.Test + void preservesArraysPrimitivesAndNull() { + Gson gson = new Gson(); + Choice.VariantArray array = assertInstanceOf(Choice.VariantArray.class, gson.fromJson("[1,2]", Choice.class)); + assertEquals(java.util.List.of(1, 2), array.getValue0()); + assertEquals("\"ok\"", gson.toJson(new Choice.VariantString("ok"), Choice.class)); + assertInstanceOf(Choice.VariantNull.class, gson.fromJson("null", Choice.class)); + assertEquals("null", gson.toJson(Choice.VariantNull.INSTANCE, Choice.class)); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/object-union/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/object-union/gradle.lockfile new file mode 100644 index 00000000..4df87064 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/object-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:object-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/object-union/src/test/java/ObjectUnionTest.java b/tests/generated-clients/jvm/kotlin/cases/object-union/src/test/java/ObjectUnionTest.java new file mode 100644 index 00000000..21997ad9 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/object-union/src/test/java/ObjectUnionTest.java @@ -0,0 +1,12 @@ +import com.example.sdk.models.OneOfObjects; +import com.google.gson.Gson; +import static org.junit.jupiter.api.Assertions.*; + +public final class ObjectUnionTest { + @org.junit.jupiter.api.Test + void selectsMatchingRequiredFields() { + Gson gson = new Gson(); + OneOfObjects value = gson.fromJson("{\"user\":{\"id\":1}}", OneOfObjects.class); + assertInstanceOf(OneOfObjects.VariantNestedObject.class, value); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/gradle.lockfile new file mode 100644 index 00000000..5ef15d73 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:tagged-shapes:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/src/test/java/TaggedShapesTest.java b/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/src/test/java/TaggedShapesTest.java new file mode 100644 index 00000000..427f9bdc --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/tagged-shapes/src/test/java/TaggedShapesTest.java @@ -0,0 +1,31 @@ +import com.example.sdk.models.AdjacentlyTaggedEnum; +import com.example.sdk.models.ExternallyTaggedEnum; +import com.example.sdk.models.VariantA; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class TaggedShapesTest { + @org.junit.jupiter.api.Test + void adjacentRoundTripRetainsTheCompletePayload() { + Gson gson = new Gson(); + VariantA payload = new VariantA("value", 7); + AdjacentlyTaggedEnum value = new AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA(payload); + String json = gson.toJson(value, AdjacentlyTaggedEnum.class); + assertEquals(JsonParser.parseString("{\"ty\":\"ADJACENTLY_TAGGED_ENUM_VARIANT_A\",\"data\":{\"field1\":\"value\",\"field2\":7}}"), JsonParser.parseString(json)); + var decoded = assertInstanceOf(AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA.class, gson.fromJson(json, AdjacentlyTaggedEnum.class)); + assertEquals("value", decoded.getValue0().getField1()); + assertEquals(7, decoded.getValue0().getField2()); + } + + @org.junit.jupiter.api.Test + void externalRoundTripRetainsTheCompletePayload() { + Gson gson = new Gson(); + ExternallyTaggedEnum value = new ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA(new VariantA("value", 7)); + String json = gson.toJson(value, ExternallyTaggedEnum.class); + assertEquals(JsonParser.parseString("{\"EXTERNALLY_TAGGED_ENUM_VARIANT_A\":{\"field1\":\"value\",\"field2\":7}}"), JsonParser.parseString(json)); + var decoded = assertInstanceOf(ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA.class, gson.fromJson(json, ExternallyTaggedEnum.class)); + assertEquals("value", decoded.getValue0().getField1()); + assertEquals(7, decoded.getValue0().getField2()); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/tagged-union/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/tagged-union/gradle.lockfile new file mode 100644 index 00000000..9731ffb3 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/tagged-union/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:tagged-union:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/tagged-union/src/test/java/TaggedUnionTest.java b/tests/generated-clients/jvm/kotlin/cases/tagged-union/src/test/java/TaggedUnionTest.java new file mode 100644 index 00000000..d7c959de --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/tagged-union/src/test/java/TaggedUnionTest.java @@ -0,0 +1,16 @@ +import com.example.sdk.models.Setup; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +public final class TaggedUnionTest { + @org.junit.jupiter.api.Test + void preservesDiscriminatorAndBusinessFields() { + Gson gson = new Gson(); + String json = "{\"kind\":\"SETUP_QUICK\",\"name\":\"x\",\"engine\":\"e\"}"; + Setup.VariantSetupQuick setup = assertInstanceOf(Setup.VariantSetupQuick.class, gson.fromJson(json, Setup.class)); + assertEquals("x", setup.getValue0().getName()); + assertEquals("e", setup.getValue0().getEngine()); + assertEquals(JsonParser.parseString(json), JsonParser.parseString(gson.toJson(setup, Setup.class))); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/gradle.lockfile new file mode 100644 index 00000000..e1b7652c --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:typed-error-responses:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/src/test/java/CharsetTest.java b/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/src/test/java/CharsetTest.java new file mode 100644 index 00000000..944db9ff --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/typed-error-responses/src/test/java/CharsetTest.java @@ -0,0 +1,21 @@ +import com.example.sdk.apis.CreateResourceException; +import com.example.sdk.apis.CreateResourceServerError; +import java.nio.charset.StandardCharsets; +import okhttp3.Headers; + +public final class CharsetTest { + @org.junit.jupiter.api.Test + void preservesCharsetAndHeaders() { + byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1); + Headers headers = Headers.of("Content-Type", "application/json; charset=iso-8859-1", "Retry-After", "120"); + CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw); + String message = detail.body().getMessage(); + if (!"caf\u00e9".equals(message)) { + throw new AssertionError("expected charset-aware message, got: " + message); + } + CreateResourceException error = new CreateResourceException(detail, 503, "Service Unavailable", message, headers, raw); + if (!"120".equals(error.retryAfterHeader())) { + throw new AssertionError("expected Retry-After header"); + } + } +} diff --git a/tests/generated-clients/jvm/kotlin/settings.gradle b/tests/generated-clients/jvm/kotlin/settings.gradle new file mode 100644 index 00000000..289dfe4d --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +apply from: file("../settings-common.gradle") diff --git a/tests/generated-clients/jvm/settings-common.gradle b/tests/generated-clients/jvm/settings-common.gradle new file mode 100644 index 00000000..51ceaeba --- /dev/null +++ b/tests/generated-clients/jvm/settings-common.gradle @@ -0,0 +1,11 @@ +rootProject.name = "${settingsDir.name}-client-tests" +include(":clients") +project(":clients").projectDir = new File(settingsDir, "generated") +new File(settingsDir, "../fixtures.txt").eachLine { line -> + if (line.trim() && !line.startsWith("#")) { + def name = line.tokenize()[0] + include(":clients:${name}", ":cases:${name}") + project(":clients:${name}").projectDir = new File(settingsDir, "generated/${name}") + project(":cases:${name}").projectDir = new File(settingsDir, "cases/${name}") + } +} diff --git a/tests/generated-clients/rust/README.md b/tests/generated-clients/rust/README.md index 206a69e3..4e2d97de 100644 --- a/tests/generated-clients/rust/README.md +++ b/tests/generated-clients/rust/README.md @@ -47,4 +47,5 @@ errors, thiserror, and Snafu; ureq retains its synchronous propagation check. The root `just test-all` and `just coverage-lcov` cover the generator workspace, not these projects. CI runs a separate Rust Client Tests matrix, and `just ci` includes `just golden-rust::test`. Golden comparison and all-golden compilation -remain separate checks. JVM runtime tests are unchanged by this separation. +remain separate checks. JVM consumer tests have their own Gradle projects under +`tests/generated-clients/jvm/` and run through `just golden-jvm::test`. diff --git a/tests/jvm_http_error_runtime.rs b/tests/jvm_http_error_runtime.rs deleted file mode 100644 index b37b191a..00000000 --- a/tests/jvm_http_error_runtime.rs +++ /dev/null @@ -1,690 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use openapi_nexus::generators::java::okhttp::JavaOkhttpCodeGenerator; -use openapi_nexus::generators::kotlin::okhttp::KotlinOkhttpCodeGenerator; -use openapi_nexus::test_utils::{generate_files, read_fixture}; - -fn empty_config() -> toml::value::Table { - toml::value::Table::new() -} - -struct TempDir { - path: PathBuf, -} - -impl TempDir { - fn new(name: &str) -> Self { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after UNIX_EPOCH") - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "openapi-nexus-{name}-{}-{nanos}", - std::process::id() - )); - fs::create_dir_all(&path).expect("temporary project directory should be created"); - Self { path } - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -struct LockDir { - path: PathBuf, -} - -impl LockDir { - fn acquire(name: &str) -> Self { - let path = std::env::temp_dir().join(format!("openapi-nexus-{name}.lock")); - let deadline = Instant::now() + Duration::from_secs(300); - loop { - match fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == ErrorKind::AlreadyExists && Instant::now() < deadline => { - thread::sleep(Duration::from_millis(250)); - } - Err(err) => panic!("failed to acquire {name} lock at {}: {err}", path.display()), - } - } - } -} - -impl Drop for LockDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -fn write_generated_project(files: &HashMap, root: &Path) { - for (relative_path, contents) in files { - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("generated parent directory should be created"); - } - fs::write(path, contents).expect("generated file should be written"); - } -} - -const CHARSET_SMOKE_JAVA: &str = r#" -import com.example.sdk.apis.CreateResourceServerError; -import com.example.sdk.apis.CreateResourceException; -import java.nio.charset.StandardCharsets; -import okhttp3.Headers; - -public final class CharsetSmoke { - public static void main(String[] args) { - byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1); - Headers headers = Headers.of( - "Content-Type", "application/json; charset=iso-8859-1", - "Retry-After", "120" - ); - CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw); - String message = detail.body().getMessage(); - if (!"caf\u00e9".equals(message)) { - throw new AssertionError("expected charset-aware message, got: " + message); - } - CreateResourceException error = new CreateResourceException( - 503, "Service Unavailable", message, headers, raw, detail - ); - if (!"120".equals(error.getRetryAfterHeader())) { - throw new AssertionError("expected Retry-After header"); - } - } -} -"#; - -const CHARSET_SMOKE_KOTLIN_JAVA: &str = r#" -import com.example.sdk.apis.CreateResourceException; -import com.example.sdk.apis.CreateResourceServerError; -import java.nio.charset.StandardCharsets; -import okhttp3.Headers; - -public final class CharsetSmoke { - public static void main(String[] args) { - byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1); - Headers headers = Headers.of( - "Content-Type", "application/json; charset=iso-8859-1", - "Retry-After", "120" - ); - CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw); - String message = detail.body().getMessage(); - if (!"caf\u00e9".equals(message)) { - throw new AssertionError("expected charset-aware message, got: " + message); - } - CreateResourceException error = new CreateResourceException( - detail, 503, "Service Unavailable", message, headers, raw - ); - if (!"120".equals(error.retryAfterHeader())) { - throw new AssertionError("expected Retry-After header"); - } - } -} -"#; - -const CLOSED_SUM_SMOKE: &str = r#" -import com.example.sdk.models.Choice; -import com.google.gson.Gson; - -public final class ClosedSumSmoke { - public static void main(String[] args) { - Gson gson = new Gson(); - Choice choice = gson.fromJson("[1,2]", Choice.class); - if (!(choice instanceof Choice.VariantArray array) || array.value0().size() != 2) { - throw new AssertionError("expected typed array union case"); - } - String choiceJson = gson.toJson(new Choice.VariantString("ok"), Choice.class); - if (!"\"ok\"".equals(choiceJson)) { - throw new AssertionError("expected typed primitive union serialization, got " + choiceJson); - } - - Choice nullChoice = gson.fromJson("null", Choice.class); - if (!(nullChoice instanceof Choice.VariantNull)) { - throw new AssertionError("expected typed null union case"); - } - String nullJson = gson.toJson(Choice.VariantNull.INSTANCE, Choice.class); - if (!"null".equals(nullJson)) { - throw new AssertionError("expected typed null union serialization, got " + nullJson); - } - - } -} -"#; - -const OBJECT_UNION_SMOKE: &str = r#" -import com.example.sdk.models.OneOfObjects; -import com.google.gson.Gson; - -public final class ObjectUnionSmoke { - public static void main(String[] args) { - Gson gson = new Gson(); - OneOfObjects value = gson.fromJson("{\"user\":{\"id\":1}}", OneOfObjects.class); - if (!(value instanceof OneOfObjects.VariantNestedObject)) { - throw new AssertionError("expected nested object union case"); - } - } -} -"#; - -const ANY_UNION_SMOKE: &str = r#" -import com.example.sdk.models.Config; -import com.google.gson.Gson; - -public final class AnyUnionSmoke { - public static void main(String[] args) { - Gson gson = new Gson(); - Config booleanValue = gson.fromJson("true", Config.class); - if (!(booleanValue instanceof Config.VariantAny)) { - throw new AssertionError("expected Any union case for boolean"); - } - Config objectValue = gson.fromJson("{\"key\":1}", Config.class); - if (!(objectValue instanceof Config.VariantAny)) { - throw new AssertionError("expected Any union case for object"); - } - } -} -"#; - -const TAGGED_SUM_SMOKE: &str = r#" -import com.example.sdk.models.Setup; -import com.google.gson.Gson; - -public final class TaggedSumSmoke { - public static void main(String[] args) { - Gson gson = new Gson(); - Setup setup = gson.fromJson( - "{\"kind\":\"SETUP_QUICK\",\"name\":\"x\",\"engine\":\"e\"}", - Setup.class - ); - if (!(setup instanceof Setup.VariantSetupQuick)) { - throw new AssertionError("expected typed discriminator case"); - } - String setupJson = gson.toJson(setup, Setup.class); - if (!setupJson.contains("\"kind\":\"SETUP_QUICK\"")) { - throw new AssertionError("expected discriminator serialization, got " + setupJson); - } - } -} -"#; - -const TAGGED_SHAPES_SMOKE: &str = r#" -import com.example.sdk.models.AdjacentlyTaggedEnum; -import com.example.sdk.models.ExternallyTaggedEnum; -import com.example.sdk.models.VariantA; -import com.google.gson.Gson; - -public final class TaggedShapesSmoke { - public static void main(String[] args) { - Gson gson = new Gson(); - VariantA payload = new VariantA("value", 7); - - AdjacentlyTaggedEnum adjacent = - new AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA(payload); - String adjacentJson = gson.toJson(adjacent, AdjacentlyTaggedEnum.class); - if (!adjacentJson.contains("\"ty\":\"ADJACENTLY_TAGGED_ENUM_VARIANT_A\"") - || !adjacentJson.contains("\"data\"")) { - throw new AssertionError("expected adjacent wire shape, got " + adjacentJson); - } - if (!(gson.fromJson(adjacentJson, AdjacentlyTaggedEnum.class) - instanceof AdjacentlyTaggedEnum.VariantAdjacentlyTaggedEnumVariantA)) { - throw new AssertionError("expected adjacent union case"); - } - - ExternallyTaggedEnum external = - new ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA(payload); - String externalJson = gson.toJson(external, ExternallyTaggedEnum.class); - if (!externalJson.contains("\"EXTERNALLY_TAGGED_ENUM_VARIANT_A\"")) { - throw new AssertionError("expected external wire shape, got " + externalJson); - } - if (!(gson.fromJson(externalJson, ExternallyTaggedEnum.class) - instanceof ExternallyTaggedEnum.VariantExternallyTaggedEnumVariantA)) { - throw new AssertionError("expected external union case"); - } - } -} -"#; - -fn add_java_charset_smoke(root: &Path) { - let smoke_dir = root.join("smoke"); - fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created"); - fs::write(smoke_dir.join("CharsetSmoke.java"), CHARSET_SMOKE_JAVA) - .expect("Java smoke test should be written"); - - let build_gradle = root.join("build.gradle"); - let mut build = fs::read_to_string(&build_gradle).expect("build.gradle should be readable"); - build.push_str( - r#" - -sourceSets { - smoke { - java { - srcDirs("smoke") - } - compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath - runtimeClasspath += output + compileClasspath - } -} - -tasks.register("charsetSmoke", JavaExec) { - dependsOn("compileSmokeJava") - classpath = sourceSets.smoke.runtimeClasspath - mainClass = "CharsetSmoke" -} -"#, - ); - fs::write(build_gradle, build).expect("build.gradle should be updated"); -} - -fn add_kotlin_charset_smoke(root: &Path) { - let smoke_dir = root.join("smoke"); - fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created"); - fs::write( - smoke_dir.join("CharsetSmoke.java"), - CHARSET_SMOKE_KOTLIN_JAVA, - ) - .expect("Kotlin smoke test should be written"); - - let build_gradle = root.join("build.gradle.kts"); - let mut build = fs::read_to_string(&build_gradle).expect("build.gradle.kts should be readable"); - build.push_str( - r#" - -sourceSets { - create("smoke") { - java.srcDirs("smoke") - compileClasspath += sourceSets["main"].output + sourceSets["main"].compileClasspath - runtimeClasspath += output + compileClasspath - } -} - -tasks.register("charsetSmoke") { - dependsOn("compileSmokeJava") - classpath = sourceSets["smoke"].runtimeClasspath - mainClass.set("CharsetSmoke") -} -"#, - ); - fs::write(build_gradle, build).expect("build.gradle.kts should be updated"); -} - -fn add_java_closed_sum_smoke(root: &Path, source: &str, class_name: &str, task_name: &str) { - let smoke_dir = root.join("smoke"); - fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created"); - fs::write(smoke_dir.join(format!("{class_name}.java")), source) - .expect("closed-sum Java smoke test should be written"); - - let build_gradle = root.join("build.gradle"); - let mut build = fs::read_to_string(&build_gradle).expect("build.gradle should be readable"); - let gradle = r#" - -sourceSets { - smoke { - java { - srcDirs("smoke") - } - compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath - runtimeClasspath += output + compileClasspath - } -} - - tasks.register("{task_name}", JavaExec) { - dependsOn("compileSmokeJava") - classpath = sourceSets.smoke.runtimeClasspath - mainClass = "{class_name}" -} -"# - .replace("{task_name}", task_name) - .replace("{class_name}", class_name); - build.push_str(&gradle); - fs::write(build_gradle, build).expect("build.gradle should be updated"); -} - -fn add_kotlin_closed_sum_smoke(root: &Path, source: &str, class_name: &str, task_name: &str) { - let smoke_dir = root.join("smoke"); - fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created"); - let source = source.replace(".value0()", ".getValue0()"); - fs::write(smoke_dir.join(format!("{class_name}.java")), source) - .expect("closed-sum Kotlin smoke test should be written"); - - let build_gradle = root.join("build.gradle.kts"); - let mut build = fs::read_to_string(&build_gradle).expect("build.gradle.kts should be readable"); - let gradle = r#" - -sourceSets { - create("smoke") { - java.srcDirs("smoke") - compileClasspath += sourceSets["main"].output + sourceSets["main"].compileClasspath - runtimeClasspath += output + compileClasspath - } -} - - tasks.register("{task_name}") { - dependsOn("compileSmokeJava") - classpath = sourceSets["smoke"].runtimeClasspath - mainClass.set("{class_name}") -} -"# - .replace("{task_name}", task_name) - .replace("{class_name}", class_name); - build.push_str(&gradle); - fs::write(build_gradle, build).expect("build.gradle.kts should be updated"); -} - -fn gradle_charset_smoke(root: &Path) -> std::process::Output { - let _lock = LockDir::acquire("gradle-charset-smoke"); - let mut cmd = Command::new("gradle"); - if let Some(java_home) = std::env::var_os("JAVA_HOME") { - cmd.env("JAVA_HOME", java_home); - } - cmd.arg("--no-daemon") - .arg("--max-workers=1") - .arg("--quiet") - .arg("charsetSmoke") - .env("GRADLE_OPTS", "-Dorg.gradle.daemon=false") - .current_dir(root) - .output() - .expect("generated JVM charset smoke should run") -} - -fn gradle_closed_sum_smoke(root: &Path, task_name: &str) -> std::process::Output { - let _lock = LockDir::acquire("gradle-closed-sum-smoke"); - let mut cmd = Command::new("gradle"); - if let Some(java_home) = std::env::var_os("JAVA_HOME") { - cmd.env("JAVA_HOME", java_home); - } - cmd.arg("--no-daemon") - .arg("--max-workers=1") - .arg("--quiet") - .arg(task_name) - .env("GRADLE_OPTS", "-Dorg.gradle.daemon=false") - .current_dir(root) - .output() - .expect("generated JVM closed-sum smoke should run") -} - -#[test] -fn java_error_detail_body_honors_response_charset() { - let fixture = read_fixture("valid/typed-error-responses.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("typed error fixture should generate Java"); - let temp = TempDir::new("typed-http-error-java"); - write_generated_project(&files, &temp.path); - add_java_charset_smoke(&temp.path); - - let output = gradle_charset_smoke(&temp.path); - assert!( - output.status.success(), - "generated Java charset smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_error_detail_body_honors_response_charset() { - let fixture = read_fixture("valid/typed-error-responses.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("typed error fixture should generate Kotlin"); - let temp = TempDir::new("typed-http-error-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_charset_smoke(&temp.path); - - let output = gradle_charset_smoke(&temp.path); - assert!( - output.status.success(), - "generated Kotlin charset smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn java_closed_sum_round_trip_preserves_union_shapes() { - let fixture = read_fixture("valid/type-aliases/closed-sum-payload-shapes.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("closed-sum fixture should generate Java"); - let temp = TempDir::new("closed-sum-java"); - write_generated_project(&files, &temp.path); - add_java_closed_sum_smoke( - &temp.path, - CLOSED_SUM_SMOKE, - "ClosedSumSmoke", - "closedSumSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "closedSumSmoke"); - assert!( - output.status.success(), - "generated Java closed-sum smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_closed_sum_round_trip_preserves_union_shapes() { - let fixture = read_fixture("valid/type-aliases/closed-sum-payload-shapes.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("closed-sum fixture should generate Kotlin"); - let temp = TempDir::new("closed-sum-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_closed_sum_smoke( - &temp.path, - CLOSED_SUM_SMOKE, - "ClosedSumSmoke", - "closedSumSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "closedSumSmoke"); - assert!( - output.status.success(), - "generated Kotlin closed-sum smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn java_untagged_object_union_selects_matching_required_fields() { - let fixture = read_fixture("valid/comprehensive-schemas.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("comprehensive fixture should generate Java"); - let temp = TempDir::new("object-union-java"); - write_generated_project(&files, &temp.path); - add_java_closed_sum_smoke( - &temp.path, - OBJECT_UNION_SMOKE, - "ObjectUnionSmoke", - "objectUnionSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "objectUnionSmoke"); - assert!( - output.status.success(), - "generated Java object-union smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_untagged_object_union_selects_matching_required_fields() { - let fixture = read_fixture("valid/comprehensive-schemas.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("comprehensive fixture should generate Kotlin"); - let temp = TempDir::new("object-union-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_closed_sum_smoke( - &temp.path, - OBJECT_UNION_SMOKE, - "ObjectUnionSmoke", - "objectUnionSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "objectUnionSmoke"); - assert!( - output.status.success(), - "generated Kotlin object-union smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn java_any_union_is_a_last_resort_case() { - let fixture = read_fixture("valid/type-aliases/union-with-any.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("Any union fixture should generate Java"); - let temp = TempDir::new("any-union-java"); - write_generated_project(&files, &temp.path); - add_java_closed_sum_smoke( - &temp.path, - ANY_UNION_SMOKE, - "AnyUnionSmoke", - "anyUnionSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "anyUnionSmoke"); - assert!( - output.status.success(), - "generated Java Any-union smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_any_union_is_a_last_resort_case() { - let fixture = read_fixture("valid/type-aliases/union-with-any.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("Any union fixture should generate Kotlin"); - let temp = TempDir::new("any-union-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_closed_sum_smoke( - &temp.path, - ANY_UNION_SMOKE, - "AnyUnionSmoke", - "anyUnionSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "anyUnionSmoke"); - assert!( - output.status.success(), - "generated Kotlin Any-union smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn java_tagged_closed_sum_round_trip_preserves_discriminator() { - let fixture = read_fixture("valid/type-aliases/discriminated-union-mixed-unit-and-allof.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("tagged closed-sum fixture should generate Java"); - let temp = TempDir::new("tagged-closed-sum-java"); - write_generated_project(&files, &temp.path); - add_java_closed_sum_smoke( - &temp.path, - TAGGED_SUM_SMOKE, - "TaggedSumSmoke", - "taggedSumSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "taggedSumSmoke"); - assert!( - output.status.success(), - "generated Java tagged closed-sum smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_tagged_closed_sum_round_trip_preserves_discriminator() { - let fixture = read_fixture("valid/type-aliases/discriminated-union-mixed-unit-and-allof.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("tagged closed-sum fixture should generate Kotlin"); - let temp = TempDir::new("tagged-closed-sum-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_closed_sum_smoke( - &temp.path, - TAGGED_SUM_SMOKE, - "TaggedSumSmoke", - "taggedSumSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "taggedSumSmoke"); - assert!( - output.status.success(), - "generated Kotlin tagged closed-sum smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn java_tagged_shapes_round_trip_through_gson() { - let fixture = read_fixture("valid/enum-repr/enum-repr.yaml"); - let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("enum representation fixture should generate Java"); - let temp = TempDir::new("tagged-shapes-java"); - write_generated_project(&files, &temp.path); - add_java_closed_sum_smoke( - &temp.path, - TAGGED_SHAPES_SMOKE, - "TaggedShapesSmoke", - "taggedShapesSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "taggedShapesSmoke"); - assert!( - output.status.success(), - "generated Java tagged-shapes smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn kotlin_tagged_shapes_round_trip_through_gson() { - let fixture = read_fixture("valid/enum-repr/enum-repr.yaml"); - let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture) - .expect("enum representation fixture should generate Kotlin"); - let temp = TempDir::new("tagged-shapes-kotlin"); - write_generated_project(&files, &temp.path); - add_kotlin_closed_sum_smoke( - &temp.path, - TAGGED_SHAPES_SMOKE, - "TaggedShapesSmoke", - "taggedShapesSmoke", - ); - - let output = gradle_closed_sum_smoke(&temp.path, "taggedShapesSmoke"); - assert!( - output.status.success(), - "generated Kotlin tagged-shapes smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} diff --git a/tests/schema_exclusive_bounds.rs b/tests/schema_exclusive_bounds.rs new file mode 100644 index 00000000..68d4687a --- /dev/null +++ b/tests/schema_exclusive_bounds.rs @@ -0,0 +1,20 @@ +use openapi_nexus::ir::types::IrSchemaKind; + +#[test] +fn oas30_boolean_exclusive_bounds_retain_true_and_false() { + for (literal, expected) in [("true", true), ("false", false), ("1", true)] { + let yaml = format!( + "openapi: 3.0.3\ninfo:\n title: Bounds\n version: '1'\npaths: {{}}\ncomponents:\n schemas:\n Bounds:\n type: object\n properties:\n value:\n type: number\n minimum: 0\n maximum: 10\n exclusiveMinimum: {literal}\n exclusiveMaximum: {literal}\n" + ); + let parsed = openapi_nexus::parser::parse_content_yaml(&yaml).expect("bound schema parses"); + let ir = openapi_nexus::ir::lower::lower(parsed).expect("bound schema lowers"); + let IrSchemaKind::Object(object) = &ir.schemas["Bounds"].kind else { + panic!("expected object"); + }; + let validation = object.properties["value"].validation.as_ref().unwrap(); + assert_eq!(validation.minimum, Some(0.0)); + assert_eq!(validation.maximum, Some(10.0)); + assert_eq!(validation.exclusive_minimum, Some(expected)); + assert_eq!(validation.exclusive_maximum, Some(expected)); + } +}