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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,27 @@ jobs:
if: contains(matrix.generator, 'java-') || contains(matrix.generator, 'kotlin-')
- run: ${{ matrix.cmd }}

rust-client-tests:
name: Rust Client Tests (${{ matrix.backend }})
runs-on: ubuntu-latest
needs: [basics]
strategy:
fail-fast: false
matrix:
backend: [reqwest, ureq, aioduct]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@just
- uses: Swatinem/rust-cache@v2
- run: just golden-rust::prepare ${{ matrix.backend }}
- uses: Swatinem/rust-cache@v2
with:
prefix-key: rust-client-${{ matrix.backend }}
workspaces: |
tests/generated-clients/rust/${{ matrix.backend }} -> target
- run: cargo test --locked --manifest-path tests/generated-clients/rust/${{ matrix.backend }}/Cargo.toml

release:
name: Release ${{ matrix.target }}
runs-on: ${{ matrix.os }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
test-output/
examples/*/generated/
examples/*/target/
tests/generated-clients/rust/*/generated/

# mdbook build output
docs/book/
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ uuid = { version = "1.19.0", features = ["v4", "v7", "serde"] }
[package]
name = "openapi-nexus"
version.workspace = true
exclude = ["tests/golden/", "fixture-generators/"]
exclude = ["tests/golden/", "tests/generated-clients/", "fixture-generators/"]
edition.workspace = true
rust-version.workspace = true
description.workspace = true
Expand Down
4 changes: 3 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ msrv:
# Apply formatting
fmt:
cargo fmt --all
rustfmt --edition 2024 tests/generated-clients/rust/support/*.rs tests/generated-clients/rust/*/tests/*.rs

# Check formatting
fmt-check:
cargo fmt --all -- --check
rustfmt --edition 2024 --check tests/generated-clients/rust/support/*.rs tests/generated-clients/rust/*/tests/*.rs

# Run clippy with warnings as errors
clippy:
Expand Down Expand Up @@ -162,4 +164,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
ci: fmt-check clippy doc-check book msrv test-all coverage-lcov golden-check golden-rust::test
25 changes: 25 additions & 0 deletions docs/src/golden_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ just golden-kotlin::build
just golden-build-all # all languages
```

## Rust Client Runtime Tests

Generated Rust clients also have standalone consumer test projects under
`tests/generated-clients/rust/`. Unlike golden comparison, these tests call real
generated clients over local HTTP and check decoding, error metadata, response
dispatch, and strict non-nullable controls.

```bash
just golden-rust::test # reqwest, ureq, and aioduct
just golden-rust::test-reqwest # one backend
```

Preparation runs the public generator CLI against the fixtures. Each consumer
project has its own checked-in manifest, lockfile, and ordinary Rust test files;
generated clients are unmodified path dependencies in an ignored directory. One
Cargo invocation runs all cases for a backend, with a persistent dependency cache.

These projects are intentionally outside the generator workspace. `just test-all`
and generator coverage do not recursively build client crates. CI has a separate
Rust Client Tests matrix, and `just ci` includes these tests. They complement,
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.

## 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`.
Expand Down
21 changes: 19 additions & 2 deletions docs/src/ir_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,32 @@ Alias(IrTypeExpr)
pub enum IrTypeExpr {
Named(String),
Primitive(IrPrimitive),
StringLiteral(String),
StringEnum(Vec<String>),
Array(Box<IrTypeExpr>),
Map(Box<IrTypeExpr>),
Nullable(Box<IrTypeExpr>),
Union(Vec<IrTypeExpr>),
Literal(serde_json::Value),
Unknown,
Any,
}
```

Nullability belongs to the node that declares it. OpenAPI 3.0 `nullable: true`
and OpenAPI 3.1/3.2 type sets containing `null` retain that distinction in nested
arrays and maps:

| Schema meaning | IR shape |
| --- | --- |
| Map whose values may be null | `Map(Nullable(T))` |
| Array whose items may be null | `Array(Nullable(T))` |
| Array that may itself be null | `Nullable(Array(T))` |

Array aliases and references to promoted inline items preserve these wrappers.
For object properties, the outer nullable wrapper is recorded in
`IrProperty.nullable`; nested wrappers remain in `type_expr`. A nullable
container does not make its contents nullable, and nullability is independent
of whether a property is required.

## Operations

Each `IrOperation` represents one HTTP method + path:
Expand Down
16 changes: 16 additions & 0 deletions just/golden-rust.just
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,19 @@ build: build-reqwest build-ureq build-aioduct
# Compile-check Rust aioduct goldens
@build-aioduct:
./scripts/golden-build-rust.sh aioduct

# Generate fresh clients and run their consumer test projects
test: test-reqwest test-ureq test-aioduct

# Prepare generated dependencies for direct Cargo/IDE use
@prepare BACKEND:
bash ./scripts/generate-rust-client-fixtures.sh {{ BACKEND }}

@test-reqwest: (prepare "reqwest")
cargo test --locked --manifest-path tests/generated-clients/rust/reqwest/Cargo.toml

@test-ureq: (prepare "ureq")
cargo test --locked --manifest-path tests/generated-clients/rust/ureq/Cargo.toml

@test-aioduct: (prepare "aioduct")
cargo test --locked --manifest-path tests/generated-clients/rust/aioduct/Cargo.toml
23 changes: 23 additions & 0 deletions scripts/generate-rust-client-fixtures.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

backend="${1:?Usage: generate-rust-client-fixtures.sh <reqwest|ureq|aioduct>}"
case "$backend" in
reqwest|ureq|aioduct) ;;
*) echo "unsupported Rust backend: $backend" >&2; exit 1 ;;
esac

root="$(cd "$(dirname "$0")/.." && pwd)"
project="$root/tests/generated-clients/rust/$backend"

for fixture in typed-error-responses rust-error-payload-shapes nested-nullable; do
output="$project/generated/$fixture"
# These fixed fixture directories contain only disposable generated output.
# 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" \
--input "$root/tests/fixtures/valid/$fixture.yaml" \
--output "$output" \
--generators "rust-$backend"
done
90 changes: 62 additions & 28 deletions src/generators/typescript/fetch/sigil_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,12 +294,7 @@ fn build_field_wire(
};
let inner_ty = type_expr_to_typename_wire(&prop.type_expr, current_schema, convertible);
let field_ty = if prop.nullable {
nullable_field_type_name(
&prop.type_expr,
inner_ty,
type_expr_str_wire(&prop.type_expr, convertible),
unknown_aliases,
)
nullable_field_type_name(&prop.type_expr, inner_ty, unknown_aliases)
} else {
inner_ty
};
Expand Down Expand Up @@ -328,12 +323,7 @@ fn build_field_camel(
};
let inner_ty = type_expr_to_typename_for_schema(&prop.type_expr, current_schema);
let field_ty = if prop.nullable {
nullable_field_type_name(
&prop.type_expr,
inner_ty,
type_expr_str(&prop.type_expr),
unknown_aliases,
)
nullable_field_type_name(&prop.type_expr, inner_ty, unknown_aliases)
} else {
inner_ty
};
Expand Down Expand Up @@ -527,18 +517,12 @@ fn type_name_is_unknown(type_name: &TypeName) -> bool {
fn nullable_field_type_name(
expr: &IrTypeExpr,
inner_ty: TypeName,
rendered_inner: String,
unknown_aliases: &HashSet<String>,
) -> TypeName {
if type_expr_collapses_to_unknown(expr, unknown_aliases) {
inner_ty
} else if contains_any_named_ref(expr) {
union_typename(vec![inner_ty, TypeName::primitive("null")])
} else {
TypeName::raw(&simplify_union_strings(vec![
rendered_inner,
"null".to_string(),
]))
union_typename(vec![inner_ty, TypeName::primitive("null")])
}
}

Expand Down Expand Up @@ -2047,12 +2031,7 @@ fn build_field(
};
let inner_ty = type_expr_to_typename_for_schema(&prop.type_expr, current_schema);
let field_ty = if prop.nullable {
nullable_field_type_name(
&prop.type_expr,
inner_ty,
type_expr_str(&prop.type_expr),
unknown_aliases,
)
nullable_field_type_name(&prop.type_expr, inner_ty, unknown_aliases)
} else {
inner_ty
};
Expand Down Expand Up @@ -2089,9 +2068,6 @@ fn type_expr_to_typename_with_current(expr: &IrTypeExpr, current_schema: Option<
IrTypeExpr::Array(inner) => {
TypeName::readonly_array(type_expr_to_typename_nested(inner, current_schema))
}
IrTypeExpr::Nullable(inner) if !contains_any_named_ref(inner) => {
TypeName::raw(&type_expr_str(expr))
}
IrTypeExpr::Nullable(inner) => union_typename(vec![
type_expr_to_typename_with_current(inner, current_schema),
TypeName::primitive("null"),
Expand Down Expand Up @@ -2142,6 +2118,18 @@ fn type_expr_to_typename_nested(expr: &IrTypeExpr, current_schema: Option<&str>)
IrTypeExpr::Array(inner) => {
TypeName::array(type_expr_to_typename_nested(inner, current_schema))
}
// Array element unions must stay structured so the renderer can add
// parentheses; a raw `T | null` would render as `T | null[]`.
IrTypeExpr::Nullable(inner) => union_typename(vec![
type_expr_to_typename_nested(inner, current_schema),
TypeName::primitive("null"),
]),
IrTypeExpr::Union(members) => union_typename(
members
.iter()
.map(|member| type_expr_to_typename_nested(member, current_schema))
.collect(),
),
other => type_expr_to_typename_with_current(other, current_schema),
}
}
Expand Down Expand Up @@ -2223,3 +2211,49 @@ fn primitive_to_ts(p: &IrPrimitive) -> &'static str {
IrPrimitive::Boolean => "boolean",
}
}

#[cfg(test)]
mod render_tests {
use super::*;
use sigil_stitch::assert_rendered;

#[test]
fn nullable_and_union_array_types_keep_precedence_and_imports() {
let string = IrTypeExpr::Primitive(IrPrimitive::String);
let cases = [
(
IrTypeExpr::Nullable(Box::new(IrTypeExpr::Array(Box::new(IrTypeExpr::Array(
Box::new(string.clone()),
))))),
"export type Values = readonly string[][] | null;\n",
),
(
IrTypeExpr::Array(Box::new(IrTypeExpr::Nullable(Box::new(IrTypeExpr::Array(
Box::new(string.clone()),
))))),
"export type Values = readonly (string[] | null)[];\n",
),
(
IrTypeExpr::Array(Box::new(IrTypeExpr::Union(vec![
string,
IrTypeExpr::Primitive(IrPrimitive::Number),
]))),
"export type Values = readonly (string | number)[];\n",
),
(
IrTypeExpr::Array(Box::new(IrTypeExpr::Nullable(Box::new(IrTypeExpr::Named(
"Detail".to_string(),
))))),
"import type { Detail } from './Detail';\n\nexport type Values = readonly (Detail | null)[];\n",
),
];
for (expr, expected) in cases {
let ty = type_expr_to_typename(&expr);
let block = sigil_quote!(TypeScript {
export type Values = $T(ty);
})
.expect("array type render test quote builds");
assert_rendered!(TypeScript::new(), width = 100, block, expected);
}
}
}
Loading
Loading