Skip to content
Draft
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
35 changes: 34 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,38 @@ jobs:
- name: Check generated code is up to date
run: make verify-generated

# The generator reports a Rust version to every consumer after a write, as the
# version needed to compile the emitted code. This job compiles every committed
# generated file on exactly that toolchain, so the reported number is measured
# rather than claimed.
#
# The floor is set by the crates the output depends on, and not by the syntax the
# emitters produce, so it moves on a dependency bump and not on a generator
# change. That makes it exactly the kind of claim that rots without a check. See
# docs/msrv.md.
#
# This job does not use ./.github/actions/setup-rust: that action installs the
# pinned build toolchain, which is much newer than this floor and would defeat
# the measurement. `make verify-msrv` installs the right one from the manifest.
generated-code-msrv:
name: generated-code-msrv
runs-on: ubuntu-latest
steps:
- *checkout-code

# Keyed separately from rust-checks because this job builds a different crate
# on a different toolchain. Sharing the key would thrash both caches.
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
with:
shared-key: generated-code-msrv
cache-targets: "true"
cache-all-crates: "true"
workspaces: crates/oapi-codegen/tests/msrv-check

- name: Compile generated code on the consumer Rust floor
run: make verify-msrv

lint-prettier:
runs-on: ubuntu-latest
needs: install-prettier
Expand Down Expand Up @@ -233,12 +265,13 @@ jobs:
name: CI Passed
runs-on: ubuntu-latest
if: always()
needs: [rust-checks, lint-prettier, cargo-deny]
needs: [rust-checks, generated-code-msrv, lint-prettier, cargo-deny]
steps:
- name: Verify all jobs passed or were skipped
run: |
results=(
"${{ needs.rust-checks.result }}"
"${{ needs.generated-code-msrv.result }}"
"${{ needs.lint-prettier.result }}"
"${{ needs.cargo-deny.result }}"
)
Expand Down
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ members = ["crates/*", "examples/*"]
version = "0.1.0"
edition = "2024"
repository = "https://github.com/alchemaxinc/oapi-codegen-rust"
# The project has no site of its own, so this is the repository. Cargo shows the
# two as separate links on a crates.io page, and an absent `homepage` reads as
# missing rather than as "the same as the repository".
homepage = "https://github.com/alchemaxinc/oapi-codegen-rust"
license = "MIT"

# The Rust version needed to build the tools in this workspace. It is not the
# version needed to compile the code the generator emits, which lands in a
# consumer's crate and carries its own floor. See `docs/msrv.md`.
rust-version = "1.97"

# Trap silent integer wraparound on spec-derived arithmetic in release builds.
[profile.release]
overflow-checks = true
Expand Down
39 changes: 35 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ update-generated: ## Refresh generated files from the coverage fixtures
update-docs: ## Refresh docs/cli.md from the clap CLI definition
UPDATE_DOCS=1 cargo test -p oapi-codegen --test cli_docs

.PHONY: update-msrv-manifest
update-msrv-manifest: ## Refresh the msrv-check manifest from the dependency report
UPDATE_MSRV_MANIFEST=1 cargo test -p oapi-codegen --test msrv_manifest

.PHONY: generate-example
generate-example: ## Regenerate the bookstore example from its OpenAPI specification
cd examples/bookstore && \
Expand All @@ -82,15 +86,42 @@ verify-generated: ## Regenerate all generated files and fail when they differ fr
$(MAKE) verify-example
$(MAKE) update-generated
$(MAKE) update-docs
@if [ -n "$$(git status --porcelain -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md)" ]; then \
$(MAKE) update-msrv-manifest
@if [ -n "$$(git status --porcelain -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md crates/oapi-codegen/tests/msrv-check/Cargo.toml)" ]; then \
echo "ERROR: generated files are out of date."; \
echo "Run 'make generate-example', 'make update-generated' and 'make update-docs'. Commit the result."; \
git status --porcelain -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md; \
git --no-pager diff -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md; \
echo "Run 'make generate-example', 'make update-generated', 'make update-docs' and 'make update-msrv-manifest'. Commit the result."; \
git status --porcelain -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md crates/oapi-codegen/tests/msrv-check/Cargo.toml; \
git --no-pager diff -- examples/bookstore/generated crates/oapi-codegen/tests/generated docs/cli.md crates/oapi-codegen/tests/msrv-check/Cargo.toml; \
exit 1; \
fi
@echo "Generated files are up to date."

# The Rust version a consumer needs to compile the emitted code. It is declared
# once, in the crate manifest, and read here rather than repeated. The generator
# reports the same number to every consumer after a write, so it has to be true.
GENERATED_MSRV := $(shell sed -nE '/^\[package\.metadata\.generated-code\]/,/^\[/{s/^[[:space:]]*rust-version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p;}' crates/oapi-codegen/Cargo.toml)

.PHONY: verify-msrv
verify-msrv: ## Compile every generated file on the Rust version a consumer needs
@test -n "$(GENERATED_MSRV)" || { \
echo "ERROR: no rust-version under [package.metadata.generated-code] in crates/oapi-codegen/Cargo.toml."; \
exit 1; \
}
rustup toolchain install $(GENERATED_MSRV) --profile minimal
@echo "Compiling every generated file on Rust $(GENERATED_MSRV)."
# `msrv-check` is not a workspace member, so it needs its own manifest path.
# Compiling is the whole assertion: the crate holds no test of its own.
#
# `--locked` because the measured floor is a property of one dependency graph,
# and the committed lockfile is what names that graph. Without the flag, a
# manifest that gained a crate resolves it and rewrites the lockfile in place,
# so the run reports a floor for a graph nobody reviewed. The manifest is
# generated, so gaining a crate needs no edit here and is the expected case.
# Run `make update-msrv-manifest` and then `cargo update` for that manifest to
# refresh both together.
cargo +$(GENERATED_MSRV) build --locked --manifest-path crates/oapi-codegen/tests/msrv-check/Cargo.toml
@echo "Generated code compiles on Rust $(GENERATED_MSRV)."

.PHONY: docs
docs: ## Generate and open Rust documentation
cargo doc --no-deps --open
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ $ oapi-codegen --config-file examples/bookstore/oapi-codegen-server.yaml example
cargo add http@1.4.2
cargo add axum@0.8.9 --features multipart
cargo add axum-extra@0.12.6 --features query
note: the generated code needs Rust 1.88 or newer.

```

Expand Down Expand Up @@ -86,6 +87,7 @@ against [`.github/openapi-versions.json`](.github/openapi-versions.json).
> They have been checked and verified by humans, and can be read if interested.

- [Installation](docs/installation.md)
- [Rust versions](docs/msrv.md)
- [Build workflow](docs/workflow.md)
- [Configuration](docs/configuration.md)
- [OpenAPI extensions](docs/extensions.md)
Expand Down
23 changes: 23 additions & 0 deletions crates/oapi-codegen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,35 @@ description = "Generate client and server boilerplate from OpenAPI 3 specificati
version.workspace = true
edition.workspace = true
repository.workspace = true
homepage.workspace = true
license.workspace = true
rust-version.workspace = true
readme = "../../README.md"
# `keywords` is capped at five entries and `categories` at five, and both must
# match the slugs crates.io knows. These are the terms a reader searches for the
# tool by, and not the terms the tool uses inside itself.
keywords = ["openapi", "codegen", "axum", "swagger", "rest"]
categories = ["development-tools", "web-programming", "command-line-utilities"]

[[bin]]
name = "oapi-codegen"
path = "src/main.rs"

# The Rust version a consumer needs to compile the code this generator *emits*.
# It is not `rust-version` above, which is the floor for building the generator
# itself. The two move independently, and a consumer only ever needs this one.
#
# The value lives here, and not as a constant in the source, because three places
# read it: `deps.rs` embeds this manifest already and parses it out, the CI job
# installs this toolchain and compiles the generated fixtures with it, and `docs/msrv.md`
# quotes it. A number with three homes goes stale in two of them.
#
# The floor comes from the dependencies the emitted code needs, and not from the
# syntax it emits. The newest construct the emitters produce is `-> impl Future`
# in a trait, which is Rust 1.75. See `docs/msrv.md` for the measurement.
[package.metadata.generated-code]
rust-version = "1.88"

[lints]
workspace = true

Expand Down
8 changes: 8 additions & 0 deletions crates/oapi-codegen/src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ pub fn report_dependencies(deps: &[Dependency]) {
for dep in deps {
eprintln!(" {}", dep.cargo_add().dimmed());
}
// The Rust floor belongs with the dependency list, because the dependencies
// are what set it. A consumer who reads one and not the other adds the crates
// and then meets a `rustc` error from inside a crate they did not name.
eprintln!(
" {} the generated code needs Rust {} or newer.",
"note:".cyan().bold(),
oapi_codegen::deps::generated_code_rust_version()
);
}

/// Ask whether to run the `cargo add` commands now. Returns `false` on EOF or a
Expand Down
77 changes: 76 additions & 1 deletion crates/oapi-codegen/src/deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const MANIFEST: &str = include_str!("../Cargo.toml");
///
/// Deriving the recommended version from our manifest (rather than a hardcoded
/// constant) keeps the report in lockstep with the versions the generated
/// goldens actually compile against, so a dependency bump here updates the
/// generated fixtures actually compile against, so a dependency bump here updates the
/// report automatically. Every crate the report can name is a (dev-)dependency
/// of this crate — enforced by `reported_crates_have_manifest_versions` — so an
/// absent entry is a programming error, not runtime input.
Expand Down Expand Up @@ -79,6 +79,56 @@ fn parse_manifest_version<'a>(manifest: &'a str, crate_name: &str) -> Option<&'a
return None;
}

/// The `Cargo.toml` table that records the Rust version a consumer needs to
/// compile the emitted code.
const GENERATED_CODE_TABLE: &str = "[package.metadata.generated-code]";

/// The Rust version a consumer needs to compile the code this generator emits.
///
/// This is not the version needed to build the generator, which is the
/// `rust-version` of the `[package]` table. A consumer runs a released binary and
/// needs no Rust to do it, so the generator's own floor never reaches them. The
/// floor that does reach them is this one, and it applies to the crate the
/// generated file lands in.
///
/// The value is read from the manifest rather than written here, so the CI job
/// that compiles the generated fixtures on this toolchain and the documentation that
/// quotes it both read one number. See `docs/msrv.md`.
pub fn generated_code_rust_version() -> &'static str {
match parse_generated_code_rust_version(MANIFEST) {
Some(version) => return version,
None => panic!(
"`{GENERATED_CODE_TABLE}` declares no `rust-version` in oapi-codegen's manifest; the generated-code Rust floor is unknown"
),
}
}

/// Read `rust-version` from the [`GENERATED_CODE_TABLE`] table of `manifest`.
///
/// The scan starts at that table header, so it cannot pick up the `[package]`
/// table's own `rust-version`. Those two are different numbers, and returning the
/// wrong one would report a floor that no measurement backs.
fn parse_generated_code_rust_version(manifest: &str) -> Option<&str> {
let table = manifest.find(GENERATED_CODE_TABLE)?;
let rest = manifest.get(table + GENERATED_CODE_TABLE.len()..)?;
for line in rest.lines() {
let line = line.trim();
// A later table header ends this one. Stopping here keeps the scan from
// reading a key that belongs to a different table.
if line.starts_with('[') {
return None;
}
let Some(value) = line.strip_prefix("rust-version") else {
continue;
};
let value = value.trim_start().strip_prefix('=')?.trim_start();
let after = value.strip_prefix('"')?;
let close = after.find('"')?;
return after.get(..close);
}
return None;
}

/// A crate the generated code references, with the version requirement and Cargo
/// features a consumer must declare in `Cargo.toml`.
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -420,6 +470,31 @@ mod tests {
assert_eq!(extra.features, vec!["cookie"]);
}

#[test]
fn generated_code_rust_version_is_read_from_the_manifest() {
let version = generated_code_rust_version();
assert!(
version.split('.').count() >= 2 && version.split('.').all(|part| return part.parse::<u32>().is_ok()),
"the generated-code Rust version must be a dotted number, and reads as `{version}`",
);
}

#[test]
fn generated_code_rust_version_is_not_the_package_rust_version() {
// The fixture declares both floors. The parse must return the
// generated-code one and not fall back to the `[package]` one.
let manifest = include_str!("../tests/fixtures/manifests/two_rust_versions.toml");
assert_eq!(parse_generated_code_rust_version(manifest), Some("1.88"));
}

#[test]
fn generated_code_rust_version_stops_at_the_next_table() {
// The fixture's generated-code table declares no `rust-version`, and the
// table after it does. Reading across the header reports that value.
let manifest = include_str!("../tests/fixtures/manifests/generated_code_table_without_rust_version.toml");
assert_eq!(parse_generated_code_rust_version(manifest), None);
}

#[test]
fn no_dependencies_for_dependency_free_output() {
assert!(required_dependencies("pub const SERVER_URL: &str = \"https://x\";").is_empty());
Expand Down
4 changes: 2 additions & 2 deletions crates/oapi-codegen/tests/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ const COMBINED_UNSUPPORTED_FIXTURES: &[&str] = &[
"combined_reserved_name_client_error",
];

/// Fixtures for name collisions. The golden-file tests do not cover these,
/// Fixtures for name collisions. The generated-fixture tests do not cover these,
/// because some must fail and others need a config option or an extension.
///
/// `type_name_collision_error` must fail. Two schema names collapse onto one Rust
Expand Down Expand Up @@ -1816,7 +1816,7 @@ fn empty_response_suffix_falls_back_to_default() {
/// names is one the generator can actually emit. This ties `required_dependencies`
/// to emitted code (not just synthetic strings), so a new crate the emitters
/// start referencing — which will also force a new dev-dependency to compile the
/// goldens — is a prompt to extend the report.
/// generated fixtures — is a prompt to extend the report.
#[test]
fn dependency_report_reflects_generated_output() {
let fixture = tests_dir().join("fixtures").join("combined_server_client.yaml");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "consumer"
version = "0.1.0"
edition = "2024"

# The table exists and declares no `rust-version`. The next table declares one, so
# a scan that reads past the header reports that neighbor's value as the
# generated-code floor.
[package.metadata.generated-code]
note = "nothing here"

[dependencies]
rust-version = "1.60"
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "consumer"
version = "0.1.0"
edition = "2024"
# The floor for building the generator itself. The generated-code parse must not
# return this one: the two answer different questions, and this value is the
# larger of the two, so a fallback to it would report a floor no measurement
# backs.
rust-version = "1.97"

[package.metadata.generated-code]
# The floor for compiling the emitted code in a consumer's crate.
rust-version = "1.88"
18 changes: 5 additions & 13 deletions crates/oapi-codegen/tests/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,24 +173,16 @@ mod generated {
}

/// Stand-in for the models crate the `server_refs` fixture's `import-mapping`
/// points its cross-file `$ref` bodies at (`crate::apimodel`). Real projects
/// generate this module from the referenced schema file; here a minimal struct
/// proves the emitted `crate::apimodel::CreateWidget` path resolves and that the
/// generated handler can decode it as a JSON body.
/// points its cross-file `$ref` bodies at (`crate::apimodel`).
///
/// The body lives in `tests/support/apimodel.rs`, because the `msrv-check` crate
/// needs the same module at its own crate root. See that file for the rest.
#[allow(
dead_code,
reason = "test-only stand-in for the models crate the server_refs fixture's import-mapping targets; only CreateWidget is constructed here"
)]
mod apimodel {
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct CreateWidget {
pub name: String,
}

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct NewThing {
pub name: String,
}
include!("support/apimodel.rs");
}

#[test]
Expand Down
Loading
Loading