CodeSampleX

Sample

axum 0.8.9: Test axum handlers and routing without binding a port by driving the whole Router through tower's ServiceExt::oneshot, and get the exact status for each way a request can be wrong

Verified sample for cargo axum 0.8.9: Test axum handlers and routing without binding a port by driving the whole Router through tower's ServiceExt::oneshot…

sha256:ad2f9d5347cb52c3acc083de3c1608225248a42c41031bf843e1163d738b6e70

This network offers one thing: a sample that builds. It ran the sample in a sandbox and kept the signed receipt. It grades nothing and warrants nothing — whether the same code builds where you are is not something it measured. How many distinct signing keys filed a passing contract receipt. One is the author alone; more than one means somebody else built it too. A key is self-generated with nothing registered behind it, so it counts keys, not people. MIT-0

Execution evidence

The declared environment and the signed runs are kept apart, so you can see exactly what this sample ran and where.

Evidence basis
Signed contract pass
Verification receipts
4
Signing keys that built it
3
Declared environment rust 1 linux x64 rust 1 rust cargo

Verification-run environments

Environment Contract Stages Run
rust 1 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · cargo@1
2026-08-14
rust 1 · linux alpine/x64 · docker ed25519:2175b912ea1c23b1 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · cargo@1
2026-08-18
rust 1 · linux alpine/x64 · docker ed25519:c1973797be207ac4 FAIL compile:SKIPPED · contract:FAIL · load:SKIPPED · resolve:PASS
CONTAINER_RUN · cargo@1rust:1-alpine@sha256:a10e64dd139b…
2026-09-08
rust 1 · linux alpine/x64 · docker ed25519:c1973797be207ac4 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · cargo@1rust:1-alpine@sha256:a10e64dd139b…
2026-09-15

Case

HOW
Goal
Test axum handlers and routing without binding a port by driving the whole Router through tower's ServiceExt::oneshot, and get the exact status for each way a request can be wrong
Packages
Symbols
  • tower::ServiceExt::oneshot
  • axum::Router::route
  • axum::Router::with_state
  • axum::extract::Path
  • axum::extract::Query
  • axum::extract::State
  • axum::Json
  • axum::body::Body
  • http_body_util::BodyExt::collect
  • axum::debug_handler
Environment
rust 1
Created
2026-08-14T13:27:02Z

Contract

  1. assert a GET driven through the Router by oneshot returns 200 with the exact JSON body that the Path and State extractors produce, with no listener and no port
  2. assert axum's Json response sets a bare application/json content-type with no charset parameter
  3. assert its content-length equals the length of the collected body
  4. assert HEAD on a GET route returns 200 with an empty body and still reports the content-length a GET would have sent
  5. assert a well-formed POST body reaches the handler and returns 201 with the handler's JSON
  6. assert syntactically broken JSON is 400 BAD REQUEST, not 422, and carries the parse error with the serde field path in it
  7. assert that rejection body is text/plain rather than JSON
  8. assert JSON that parses but has the wrong type for a field is 422 UNPROCESSABLE ENTITY with the deserialize message
  9. assert a missing required field is the same 422
  10. assert a request with no Content-Type is 415 UNSUPPORTED MEDIA TYPE carrying the rejection message rather than the handler's 201
  11. assert the Content-Type check accepts a charset parameter and an application/...+json vendor type but refuses text/json exactly like a missing header
  12. assert an unmatched route is 404 with an empty body and no response headers at all, not even content-length
  13. assert a wrong method on a matched path is 405 with Allow: GET,HEAD and content-length 0
  14. assert Allow describes the matched route rather than the router, listing only POST for a POST-only path
  15. assert OPTIONS is not handled for you and gets the same 405
  16. assert extractors before the body run in argument order, so two handlers differing only in Path before Query answer the same broken request with different rejections
  17. assert the body extractor's own rejection only appears once every extractor ahead of it succeeded
  18. assert a handler with State before Json compiles clean
  19. assert swapping them fails with E0277 on Handler, with the span on the route() registration and nothing pointing at the handler's own line, and with the message never containing "body", "order" or "last argument"
  20. assert that error's one suggestion is the note recommending #[axum::debug_handler] and that it offers nothing else, carrying no help: line at all
  21. assert #[axum::debug_handler] on the same handler leads with an error naming the rule that Json consumes the body and must be the last argument, underlining that argument in the handler's signature
  22. assert axum 0.8 panics while building a Router from the 0.7 ":id" capture syntax, naming {capture} as the replacement
  23. assert "*path" panics the same way, naming {*wildcard}
  24. assert the {id} and {*path} spellings build

Files

  • Cargo.lock
  • Cargo.toml
  • csx.json
  • rustc_probe/json_then_state.rs
  • rustc_probe/json_then_state_debug.rs
  • rustc_probe/state_then_json.rs
  • src/main.rs

Download the source artifact (tar.gz)

Source

Cargo.lock
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"

[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
 "axum-core",
 "axum-macros",
 "bytes",
 "form_urlencoded",
 "futures-util",
 "http",
 "http-body",
 "http-body-util",
 "hyper",
 "hyper-util",
 "itoa",
 "matchit",
 "memchr",
 "mime",
 "percent-encoding",
 "pin-project-lite",
 "serde_core",
 "serde_json",
 "serde_path_to_error",
 "serde_urlencoded",
 "sync_wrapper",
 "tokio",
 "tower",
 "tower-layer",
 "tower-service",
 "tracing",
]

[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "http-body-util",
 "mime",
 "pin-project-lite",
 "sync_wrapper",
 "tower-layer",
 "tower-service",
 "tracing",
]

[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.119",
]

[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"

[[package]]
name = "csx-sample-axum-oneshot"
version = "1.0.0"
dependencies = [
 "axum",
 "http-body-util",
 "serde",
 "serde_json",
 "tokio",
 "tower",
]

[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
 "percent-encoding",
]

[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
 "futures-core",
]

[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"

[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"

[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
 "futures-core",
 "futures-task",
 "pin-project-lite",
 "slab",
]

[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
 "bytes",
 "itoa",
]

[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "pin-project-lite",
]

[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"

[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"

[[package]]
name = "hyper"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
 "atomic-waker",
 "bytes",
 "futures-channel",
 "futures-core",
 "http",
 "http-body",
 "httparse",
 "httpdate",
 "itoa",
 "pin-project-lite",
 "smallvec",
 "tokio",
]

[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
 "bytes",
 "http",
 "http-body",
 "hyper",
 "pin-project-lite",
 "tokio",
 "tower-service",
]

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"

[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"

[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"

[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"

[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"

[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
 "libc",
 "wasi",
 "windows-sys",
]

[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"

[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
 "proc-macro2",
]

[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
 "serde_core",
 "serde_derive",
]

[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 3.0.3",
]

[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
 "itoa",
 "memchr",
 "serde",
 "serde_core",
 "zmij",
]

[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
 "itoa",
 "serde",
 "serde_core",
]

[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
 "form_urlencoded",
 "itoa",
 "ryu",
 "serde",
]

[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"

[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"

[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
 "libc",
 "windows-sys",
]

[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"

[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
 "libc",
 "mio",
 "pin-project-lite",
 "socket2",
 "tokio-macros",
 "windows-sys",
]

[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 3.0.3",
]

[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
 "futures-core",
 "futures-util",
 "pin-project-lite",
 "sync_wrapper",
 "tokio",
 "tower-layer",
 "tower-service",
 "tracing",
]

[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"

[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"

[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
 "log",
 "pin-project-lite",
 "tracing-core",
]

[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
 "once_cell",
]

[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
 "windows-link",
]

[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml
[package]
name = "csx-sample-axum-oneshot"
version = "1.0.0"
edition = "2021"
license = "MIT-0"

[dependencies]
# "macros" is not on by default and is the difference between a diagnostic that
# names the problem and one that does not. main() measures both.
axum = { version = "0.8.9", features = ["macros"] }

# tower 0.5 declares no `default` feature at all, so `tower = "0.5.3"` gives you
# a crate with no ServiceExt in it and `use tower::ServiceExt;` is an unresolved
# import. Measured with cargo 1.97.1, that error arrives with a note and a help:
#   error[E0432]: unresolved import `tower::ServiceExt`
#      |     use tower::ServiceExt;
#      |         ^^^^^^^^^^^^^^^^^ no `ServiceExt` in the root
#   note: found an item that was configured out
#      | #[cfg(feature = "util")]
#      |       ---------------- the item is gated behind the `util` feature
#   help: a similar name exists in the module
#      |  use tower::Service;
# The note holds the answer and the help under it is a trap: tower::Service is a
# different trait, it needs no feature, so taking the help makes the import
# compile and leaves you without oneshot(). oneshot() is on ServiceExt.
#
# In a real crate this belongs in [dev-dependencies]; it is a normal dependency
# here only because this sample's contract is `cargo run` rather than a test.
tower = { version = "0.5.3", features = ["util"] }

# The response body is an axum::body::Body, which is a Stream and not bytes.
# BodyExt::collect is what turns it into something you can compare against, and
# it lives in http-body-util rather than in axum or hyper.
http-body-util = "0.1.5"

serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"

# "rt" only. oneshot() drives the Router in-process, so there is no listener,
# no socket and nothing for the I/O driver to do - the runtime in main() is
# built without enable_all() to prove it.
tokio = { version = "1.53.1", features = ["rt"] }
csx.json
{"case":{"caseId":"case:sha256:05de6ec9ce7df7be1f25e1b4e33b7dbd4429459644dde8470591f8a8f12398bd","constraints":{"runtime":"rust"},"contract":["assert a GET driven through the Router by oneshot returns 200 with the exact JSON body that the Path and State extractors produce, with no listener and no port","assert axum's Json response sets a bare application/json content-type with no charset parameter","assert its content-length equals the length of the collected body","assert HEAD on a GET route returns 200 with an empty body and still reports the content-length a GET would have sent","assert a well-formed POST body reaches the handler and returns 201 with the handler's JSON","assert syntactically broken JSON is 400 BAD REQUEST, not 422, and carries the parse error with the serde field path in it","assert that rejection body is text/plain rather than JSON","assert JSON that parses but has the wrong type for a field is 422 UNPROCESSABLE ENTITY with the deserialize message","assert a missing required field is the same 422","assert a request with no Content-Type is 415 UNSUPPORTED MEDIA TYPE carrying the rejection message rather than the handler's 201","assert the Content-Type check accepts a charset parameter and an application/...+json vendor type but refuses text/json exactly like a missing header","assert an unmatched route is 404 with an empty body and no response headers at all, not even content-length","assert a wrong method on a matched path is 405 with Allow: GET,HEAD and content-length 0","assert Allow describes the matched route rather than the router, listing only POST for a POST-only path","assert OPTIONS is not handled for you and gets the same 405","assert extractors before the body run in argument order, so two handlers differing only in Path before Query answer the same broken request with different rejections","assert the body extractor's own rejection only appears once every extractor ahead of it succeeded","assert a handler with State before Json compiles clean","assert swapping them fails with E0277 on Handler, with the span on the route() registration and nothing pointing at the handler's own line, and with the message never containing \"body\", \"order\" or \"last argument\"","assert that error's one suggestion is the note recommending #[axum::debug_handler] and that it offers nothing else, carrying no help: line at all","assert #[axum::debug_handler] on the same handler leads with an error naming the rule that Json consumes the body and must be the last argument, underlining that argument in the handler's signature","assert axum 0.8 panics while building a Router from the 0.7 \":id\" capture syntax, naming {capture} as the replacement","assert \"*path\" panics the same way, naming {*wildcard}","assert the {id} and {*path} spellings build"],"goal":"Test axum handlers and routing without binding a port by driving the whole Router through tower's ServiceExt::oneshot, and get the exact status for each way a request can be wrong","kind":"HOW","packages":["pkg:cargo/axum@0.8.9","pkg:cargo/tower@0.5.3","pkg:cargo/http-body-util@0.1.5","pkg:cargo/serde@1.0.229","pkg:cargo/serde_json@1.0.151","pkg:cargo/tokio@1.53.1"],"schemaVersion":1,"symbols":["tower::ServiceExt::oneshot","axum::Router::route","axum::Router::with_state","axum::extract::Path","axum::extract::Query","axum::extract::State","axum::Json","axum::body::Body","http_body_util::BodyExt::collect","axum::debug_handler"]},"contractCommand":["cargo","run","--offline","--quiet"],"environment":{"arch":"x64","ecosystem":"cargo","executionContext":"rust","language":"rust","os":"linux","packageManager":"cargo","runtime":"rust","runtimeVersion":"1","schemaVersion":1},"license":"MIT-0","packages":["pkg:cargo/axum@0.8.9","pkg:cargo/tower@0.5.3","pkg:cargo/http-body-util@0.1.5","pkg:cargo/serde@1.0.229","pkg:cargo/serde_json@1.0.151","pkg:cargo/tokio@1.53.1"],"schemaVersion":1,"symbols":["tower::ServiceExt::oneshot","axum::Router::route","axum::Router::with_state","axum::extract::Path","axum::extract::Query","axum::extract::State","axum::Json","axum::body::Body","http_body_util::BodyExt::collect","axum::debug_handler"],"verifierAdapter":"cargo@1"}
rustc_probe/json_then_state.rs
//! Json before State. Json is the extractor that consumes the request body, so
//! it has to be the last argument; everything before it is read from the head
//! of the request alone. This file must not compile.

use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};

async fn swapped(Json(_body): Json<String>, State(_state): State<u32>) {}

pub fn build() -> Router<u32> {
    Router::new().route("/items", post(swapped))
}
rustc_probe/json_then_state_debug.rs
//! The same broken handler as json_then_state.rs with #[axum::debug_handler]
//! on it. Same code, same failure, completely different diagnostic - this is
//! the one that names the rule instead of the trait.

use axum::extract::State;
use axum::{Json, Router};
use axum::routing::post;

#[axum::debug_handler]
async fn swapped(Json(_body): Json<String>, State(_state): State<u32>) {}

pub fn build() -> Router<u32> {
    Router::new().route("/items", post(swapped))
}
rustc_probe/state_then_json.rs
//! The control for json_then_state.rs: the same two extractors, the body one
//! last, registered on the same Router. This file must compile clean.

use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};

async fn ordered(State(_state): State<u32>, Json(_body): Json<String>) {}

pub fn build() -> Router<u32> {
    Router::new().route("/items", post(ordered))
}
src/main.rs
//! Calling an axum handler as a function proves nothing. The routing, the
//! extractors, the rejections and the status codes all live in the Router, and
//! a hand-called handler skips every one of them. tower's ServiceExt::oneshot
//! feeds one Request through the whole Router in process and hands back the
//! Response: no listener, no port, no reserved port number, no I/O driver. The
//! runtime in main() is built with a plain .build() and no enable_all() so
//! that last part is checkable rather than claimed.
//!
//! Measured against axum 0.8.9 and rustc 1.97.1. Five traps, in the order they
//! hit you:
//!
//! 1. tower 0.5 declares no default features, so `tower = "0.5"` is a crate
//!    with no ServiceExt in it at all. See Cargo.toml.
//! 2. axum 0.8 changed path captures from `:id` to `{id}`. The old spelling is
//!    a panic while the Router is being built, not a route that quietly stops
//!    matching.
//! 3. "axum answers a bad JSON body with 422" is three different answers.
//!    Broken syntax is 400, a body that parses but does not fit the target
//!    type is 422, and a missing Content-Type is 415. One assertion for "bad
//!    input" is wrong on two of the three.
//! 4. The 404 from an unmatched route carries no headers whatsoever. Not an
//!    empty body with a content-length of zero: no content-length either.
//! 5. The extractor that consumes the body has to be the last argument.
//!    State or Path after Json is a compile error whose message never contains
//!    the word "order", and the extractors before it are tried in the order
//!    you wrote them, so their order decides which rejection the caller sees.
//!    rustc_probe/ holds the three variants and main() runs rustc on them.

use std::panic::AssertUnwindSafe;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;

use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
use axum::response::Response;
use axum::routing::{get, post};
use axum::{Json, Router};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use tower::ServiceExt;

// ------------------------------------------------------------- the service

/// Anything a handler needs that is not in the request. It is Clone because
/// axum hands each handler its own copy; in a real service this is where the
/// connection pool goes, behind an Arc.
#[derive(Clone)]
struct AppState {
    prefix: &'static str,
}

#[derive(Serialize)]
struct Item {
    id: String,
    name: String,
}

#[derive(Deserialize)]
struct NewItem {
    name: String,
    /// Required, and there is no #[serde(default)] on it. Leaving it out of
    /// the request body is the 422 below.
    #[allow(dead_code)]
    tags: Vec<String>,
}

#[derive(Deserialize)]
#[allow(dead_code)]
struct Filter {
    limit: u32,
}

/// State is read from the request head, Path from the matched route, so both
/// are FromRequestParts and either order compiles.
async fn get_item(State(state): State<AppState>, Path(id): Path<String>) -> Json<Item> {
    let name = format!("{}-{id}", state.prefix);
    Json(Item { id, name })
}

/// Json is last because it is the extractor that consumes the body. Swapping
/// these two arguments is rustc_probe/json_then_state.rs.
async fn create_item(
    State(state): State<AppState>,
    Json(body): Json<NewItem>,
) -> (StatusCode, Json<serde_json::Value>) {
    let name = format!("{}-{}", state.prefix, body.name);
    (StatusCode::CREATED, Json(serde_json::json!({ "name": name })))
}

/// Path before Query, with the body extractor last in both. These two handlers
/// are identical except for the order of their first two arguments, which is
/// the whole point of them.
async fn path_first(
    Path(id): Path<u32>,
    Query(_filter): Query<Filter>,
    Json(_body): Json<NewItem>,
) -> String {
    format!("path-first {id}")
}

async fn query_first(
    Query(_filter): Query<Filter>,
    Path(id): Path<u32>,
    Json(_body): Json<NewItem>,
) -> String {
    format!("query-first {id}")
}

fn app() -> Router {
    Router::new()
        .route("/items/{id}", get(get_item))
        .route("/items", post(create_item))
        .route("/path-first/{id}", post(path_first))
        .route("/query-first/{id}", post(query_first))
        // Until the state is supplied this is a Router<AppState>, and only
        // Router<()> is a Service. Forgetting with_state is a trait-bound
        // error at the oneshot() call site, a long way from the omission.
        .with_state(AppState { prefix: "widget" })
}

// -------------------------------------------------------------- the harness

/// One request in, the finished response out. This is the entire test harness.
async fn call(app: &Router, request: Request<Body>) -> (StatusCode, HeaderMap, String) {
    // oneshot() takes the service by value, which is why every call clones the
    // Router. Router is Clone so that a server can hand one out per
    // connection; a test gets to use the same property.
    let response: Response = app
        .clone()
        .oneshot(request)
        .await
        .expect("a Router's error type is Infallible, so this Result is a formality");

    let status = response.status();
    let headers = response.headers().clone();
    // The response body is a stream, not bytes, even when every byte of it is
    // already in memory. BodyExt::collect is the step people miss; it comes
    // from http-body-util rather than from axum or hyper. axum::body::to_bytes
    // does the same job and takes an explicit size limit.
    let bytes = response
        .into_body()
        .collect()
        .await
        .expect("an in-memory body cannot fail to collect")
        .to_bytes();
    (
        status,
        headers,
        String::from_utf8(bytes.to_vec()).expect("every body here is utf-8"),
    )
}

fn head(headers: &HeaderMap, name: header::HeaderName) -> Option<String> {
    headers
        .get(name)
        .map(|value| value.to_str().expect("header is ascii").to_string())
}

fn get_req(uri: &str) -> Request<Body> {
    Request::builder()
        .uri(uri)
        .body(Body::empty())
        .expect("valid request")
}

/// A POST with an optional Content-Type, because whether that header is
/// present changes the status code by itself.
fn post_req(uri: &str, content_type: Option<&str>, body: &'static str) -> Request<Body> {
    let mut builder = Request::builder().method(Method::POST).uri(uri);
    if let Some(content_type) = content_type {
        builder = builder.header(header::CONTENT_TYPE, content_type);
    }
    builder.body(Body::from(body)).expect("valid request")
}

// ------------------------------------------------------------- rustc probe

/// Compiles one file out of rustc_probe/ against the axum rlib cargo already
/// built for this run, and hands back rustc's exit status and stderr. No
/// network and no nested cargo: it reuses the artifacts in the target
/// directory, so it works under --network=none and takes no build lock.
fn compile_probe(file: &str) -> (bool, String) {
    let target = std::env::var_os("CARGO_TARGET_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| FsPath::new(env!("CARGO_MANIFEST_DIR")).join("target"));

    let (deps, axum_rlib) = ["debug", "release"]
        .iter()
        .map(|profile| target.join(profile).join("deps"))
        .find_map(|dir| newest_rlib(&dir, "libaxum-").map(|rlib| (dir, rlib)))
        .unwrap_or_else(|| panic!("no libaxum-*.rlib under {}", target.display()));

    let out_dir = std::env::temp_dir().join("csx-axum-probe");
    std::fs::create_dir_all(&out_dir).expect("probe output directory");

    let source = FsPath::new(env!("CARGO_MANIFEST_DIR"))
        .join("rustc_probe")
        .join(file);

    let output = Command::new("rustc")
        .args(["--edition", "2021", "--crate-type", "lib", "--emit", "metadata"])
        .arg("--out-dir")
        .arg(&out_dir)
        // axum by path, everything axum itself needs by search path. The
        // proc-macro crate behind #[axum::debug_handler] is found here too.
        .arg("-L")
        .arg(format!("dependency={}", deps.display()))
        .arg("--extern")
        .arg(format!("axum={}", axum_rlib.display()))
        .arg(&source)
        .output()
        .expect("rustc is on PATH inside the toolchain image");

    (
        output.status.success(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

/// The newest matching rlib. A clean target directory holds exactly one, but
/// changing a feature in Cargo.toml leaves the previous build sitting next to
/// the new one, and probing the stale rlib produces an error about the feature
/// you just turned on rather than the one the probe is about.
fn newest_rlib(dir: &FsPath, prefix: &str) -> Option<PathBuf> {
    std::fs::read_dir(dir)
        .ok()?
        .flatten()
        .filter(|entry| {
            let name = entry.file_name();
            let name = name.to_string_lossy();
            name.starts_with(prefix) && name.ends_with(".rlib")
        })
        .max_by_key(|entry| {
            entry
                .metadata()
                .and_then(|meta| meta.modified())
                .expect("rlib metadata")
        })
        .map(|entry| entry.path())
}

/// Run `f` and hand back its panic message instead of letting the panic reach
/// the process. The hook is swapped out first because the panics here are
/// deliberate, and a backtrace printed by a passing contract is a false alarm
/// for whoever reads the log next.
fn panic_message<T>(f: impl FnOnce() -> T) -> Result<T, String> {
    let previous_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(|_| {}));
    let outcome = std::panic::catch_unwind(AssertUnwindSafe(f));
    std::panic::set_hook(previous_hook);
    outcome.map_err(|payload| {
        payload
            .downcast_ref::<String>()
            .cloned()
            .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
            .expect("axum's routing panics carry string payloads")
    })
}

// -------------------------------------------------------------------- main

fn main() {
    // No enable_all(). oneshot() never touches a socket or a timer, so the
    // runtime needs nothing but a scheduler, and tokio's "rt" feature alone is
    // enough to build one. A test that needs enable_all() to pass is a test
    // that is still doing real I/O somewhere.
    let rt = tokio::runtime::Builder::new_current_thread()
        .build()
        .expect("a current-thread runtime with no drivers");

    rt.block_on(async {
        let app = app();

        // ---- a GET route with a Path extractor ------------------------
        //
        // Nothing was stubbed: the router matched, State was cloned in, Path
        // deserialized the captured segment, and Json serialized the reply and
        // set the header. The body is compared byte for byte because a handler
        // that returns the right shape with the wrong field names still
        // returns 200.
        let (status, headers, body) = call(&app, get_req("/items/42")).await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, r#"{"id":"42","name":"widget-42"}"#);
        // axum's Json sets a bare application/json, with no charset parameter.
        assert_eq!(
            head(&headers, header::CONTENT_TYPE).as_deref(),
            Some("application/json"),
        );
        // A real content-length, computed from the real body.
        assert_eq!(
            head(&headers, header::CONTENT_LENGTH).as_deref(),
            Some(body.len().to_string().as_str()),
        );

        // A GET route answers HEAD as well, with the body dropped and the
        // headers kept. content-length still describes the body that a GET
        // would have returned, so a test that reads it instead of measuring
        // the bytes cannot tell the two verbs apart.
        let (status, head_headers, body) = call(
            &app,
            Request::builder()
                .method(Method::HEAD)
                .uri("/items/42")
                .body(Body::empty())
                .expect("valid request"),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "");
        assert_eq!(head(&head_headers, header::CONTENT_LENGTH).as_deref(), Some("30"));

        // ---- the POST body, and its three rejections ------------------
        //
        // The success case first, so the failures below are failures of the
        // input rather than of the route.
        let (status, headers, body) = call(
            &app,
            post_req("/items", Some("application/json"), r#"{"name":"a","tags":["t"]}"#),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED);
        assert_eq!(body, r#"{"name":"widget-a"}"#);
        assert_eq!(
            head(&headers, header::CONTENT_TYPE).as_deref(),
            Some("application/json"),
        );

        // Truncated JSON. This is 400 BAD REQUEST, not 422: serde_json could
        // not finish parsing, so there was never a value to check against
        // NewItem. The "name:" in the middle of the message is the field path
        // that axum threads through serde_path_to_error, which is why the
        // sentence reads as though two messages were spliced together.
        let (status, headers, body) = call(&app, post_req("/items", Some("application/json"), r#"{"name":"#)).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(
            body,
            "Failed to parse the request body as JSON: name: EOF while parsing a value at line 1 column 8",
        );
        // The rejection body is plain text. A JSON API's error responses are
        // not JSON unless you replace the rejection yourself, so a client that
        // parses every body as JSON breaks on exactly the responses it most
        // needs to read.
        assert_eq!(
            head(&headers, header::CONTENT_TYPE).as_deref(),
            Some("text/plain; charset=utf-8"),
        );

        // Well-formed JSON that does not fit the type. This is the 422, and it
        // is the only one of the three that is: the document parsed, the shape
        // was refused.
        let (status, _, body) = call(
            &app,
            post_req("/items", Some("application/json"), r#"{"name":123,"tags":[]}"#),
        )
        .await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(
            body,
            "Failed to deserialize the JSON body into the target type: name: invalid type: integer `123`, expected a string at line 1 column 11",
        );

        // A missing required field lands in the same bucket, which is the
        // useful half of the split: 400 means the caller's serializer is
        // broken, 422 means the caller sent the wrong document.
        let (status, _, body) = call(
            &app,
            post_req("/items", Some("application/json"), r#"{"name":"a"}"#),
        )
        .await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(
            body,
            "Failed to deserialize the JSON body into the target type: missing field `tags` at line 1 column 12",
        );

        // No Content-Type at all is a third status again, and it is the one
        // that catches people writing requests by hand: the body is perfect
        // and the handler never runs.
        let (status, _, body) = call(&app, post_req("/items", None, r#"{"name":"a","tags":[]}"#)).await;
        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
        assert_eq!(body, "Expected request with `Content-Type: application/json`");

        // That check is not string equality on the header, and it is not a
        // search for "json" either. axum parses the header as a mime type and
        // accepts it when the type is `application` and the subtype is `json`
        // or carries a `+json` suffix, so a charset parameter and a vendor type
        // both get through.
        for content_type in ["application/json; charset=utf-8", "application/vnd.csx+json"] {
            let (status, _, _) = call(
                &app,
                post_req("/items", Some(content_type), r#"{"name":"a","tags":[]}"#),
            )
            .await;
            assert_eq!(status, StatusCode::CREATED, "{content_type} should be accepted");
        }

        // The `application` half is load-bearing, which is where the rule stops
        // matching intuition: text/json reads like JSON to a person and is
        // refused exactly like a header that was never sent.
        let (status, _, body) = call(
            &app,
            post_req("/items", Some("text/json"), r#"{"name":"a","tags":[]}"#),
        )
        .await;
        assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
        assert_eq!(body, "Expected request with `Content-Type: application/json`");

        // ---- an unmatched route ---------------------------------------
        //
        // 404 with an empty body, and with nothing else either. The default
        // fallback is a bare status code, so there is no content-length and no
        // content-type to assert on - a test that checks for content-length: 0
        // is asserting a header that was never sent.
        let (status, headers, body) = call(&app, get_req("/nope")).await;
        assert_eq!(status, StatusCode::NOT_FOUND);
        assert_eq!(body, "");
        assert!(headers.is_empty(), "the default 404 sends no headers: {headers:?}");

        // ---- the right path, the wrong method -------------------------
        //
        // 405, and this one does carry headers: Allow lists what the path does
        // answer to. HEAD is in the list because the GET route serves it, and
        // the separator has no space after the comma, so splitting on ", "
        // gives you one entry named "GET,HEAD".
        let (status, headers, body) = call(
            &app,
            Request::builder()
                .method(Method::DELETE)
                .uri("/items/42")
                .body(Body::empty())
                .expect("valid request"),
        )
        .await;
        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
        assert_eq!(head(&headers, header::ALLOW).as_deref(), Some("GET,HEAD"));
        assert_eq!(head(&headers, header::CONTENT_LENGTH).as_deref(), Some("0"));
        assert_eq!(body, "");

        // The POST-only route lists only POST, so Allow describes the route
        // rather than the router.
        let (status, headers, _) = call(
            &app,
            Request::builder()
                .method(Method::PUT)
                .uri("/items")
                .body(Body::empty())
                .expect("valid request"),
        )
        .await;
        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
        assert_eq!(head(&headers, header::ALLOW).as_deref(), Some("POST"));

        // OPTIONS is not handled for you. It gets the same 405 with the
        // same Allow header, and Allow is not the header a browser preflight
        // reads - that is Access-Control-Allow-Methods, which nothing here
        // sets. The method list is right there under a name the browser
        // ignores, so the preflight fails on both counts.
        let (status, headers, _) = call(
            &app,
            Request::builder()
                .method(Method::OPTIONS)
                .uri("/items")
                .body(Body::empty())
                .expect("valid request"),
        )
        .await;
        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
        assert_eq!(head(&headers, header::ALLOW).as_deref(), Some("POST"));

        // ---- extractor order, at run time -----------------------------
        //
        // Everything before the body extractor is run in argument order and
        // the first rejection wins, so two handlers that differ only in the
        // order of their first two arguments answer the same broken request
        // differently. Both requests below are wrong in all three ways at
        // once: an id that is not a u32, no limit in the query string, and a
        // body that is not JSON.
        let (status, _, body) = call(&app, post_req("/path-first/abc", Some("application/json"), "{")).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(body, "Invalid URL: Cannot parse `abc` to a `u32`");

        let (status, _, body) = call(&app, post_req("/query-first/abc", Some("application/json"), "{")).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(body, "Failed to deserialize query string: missing field `limit`");

        // Which also means the body extractor's rejection is the one you see
        // least often: it only runs once everything ahead of it succeeded.
        let (status, _, body) = call(&app, post_req("/path-first/7?limit=3", Some("application/json"), "{")).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(
            body,
            "Failed to parse the request body as JSON: EOF while parsing an object at line 1 column 1",
        );
    });

    // ---- extractor order, at compile time -----------------------------
    //
    // The rule is not a lint and not a run-time check: an extractor that reads
    // the body implements FromRequest, everything else implements
    // FromRequestParts, and Handler is only implemented for functions whose
    // last argument is the FromRequest one. Correct order first, as the
    // control.
    let (compiled, stderr) = compile_probe("state_then_json.rs");
    assert!(compiled, "State before Json must compile: {stderr}");
    assert_eq!(stderr, "", "and compile without a warning");

    // Swap those two arguments and it stops compiling. The message is about a
    // missing trait implementation on the function item, it points at the
    // route() call rather than at the handler, and it does not contain the
    // words "body", "order" or "last".
    let (compiled, stderr) = compile_probe("json_then_state.rs");
    assert!(!compiled, "Json before State must not compile");
    assert!(
        stderr.starts_with(
            "error[E0277]: the trait bound `fn(Json<String>, State<u32>) -> \
             impl Future<Output = ()> {swapped}: Handler<_, _>` is not satisfied\n"
        ),
        "{stderr}",
    );
    for absent in ["body", "order", "last argument"] {
        assert!(!stderr.contains(absent), "expected {absent:?} to be missing from: {stderr}");
    }
    // The span sits on the route() line and nothing in the message points at
    // the handler's own line: the unsatisfied bound is at the registration, so
    // the argument that is in the wrong place is never underlined.
    assert!(stderr.contains("json_then_state.rs:12:40"), "{stderr}");
    assert!(!stderr.contains("json_then_state.rs:9:"), "{stderr}");

    // One suggestion in the whole message, and it costs a feature: "macros" is
    // not in axum 0.8.9's default list, which is why Cargo.toml has to ask for
    // it, so a default install is told to reach for an attribute it does not
    // have. Nothing else is offered - the message carries no help: line at all.
    assert!(stderr.contains("= note: Consider using `#[axum::debug_handler]` to improve the error message\n"));
    assert!(!stderr.contains("help:"), "{stderr}");

    // With the attribute on the same broken handler, rustc leads with a
    // sentence that names the rule and underlines the offending argument.
    // Same code, same failure, and the second error is still the unreadable
    // one - debug_handler adds a diagnostic rather than replacing it.
    let (compiled, stderr) = compile_probe("json_then_state_debug.rs");
    assert!(!compiled, "the annotated handler must not compile either");
    assert!(
        stderr.starts_with(
            "error: `Json<_>` consumes the request body and thus must be the last \
             argument to the handler function\n"
        ),
        "{stderr}",
    );
    // The underline is in the handler's signature rather than at the route:
    // line 10 is `async fn swapped`, column 31 its `Json<String>`.
    assert!(stderr.contains("json_then_state_debug.rs:10:31"), "{stderr}");
    assert!(stderr.contains("Handler<_, _>` is not satisfied"));

    // ---- the axum 0.7 path syntax -------------------------------------
    //
    // A router built with the old capture syntax does not fail to match at
    // run time; it never gets built. The panic is raised by route() itself and
    // names the replacement, so upgrading is mechanical once you have seen it
    // once - and impossible to miss, because the process dies at startup.
    let colon = panic_message(|| app_with_route("/items/:id"));
    assert_eq!(
        colon.err().expect("`:id` panics under axum 0.8"),
        "Path segments must not start with `:`. For capture groups, use `{capture}`. \
         If you meant to literally match a segment starting with a colon, call \
         `without_v07_checks` on the router.",
    );

    // Wildcards moved in the same release, with the brace on the outside of
    // the star rather than replacing it.
    let star = panic_message(|| app_with_route("/assets/*path"));
    assert_eq!(
        star.err().expect("`*path` panics under axum 0.8"),
        "Path segments must not start with `*`. For wildcard capture, use `{*wildcard}`. \
         If you meant to literally match a segment starting with an asterisk, call \
         `without_v07_checks` on the router.",
    );

    // And the new spellings build.
    app_with_route("/items/{id}");
    app_with_route("/assets/{*path}");

    println!(
        "CONTRACT PASS: oneshot drives the real Router with no port; \
         400/422/415 are three different bad bodies; a body extractor must be last"
    );
}

/// Builds and throws away a one-route Router. The interesting part is that
/// route() either returns or panics; the Router itself is never used.
fn app_with_route(path: &str) {
    let _router: Router = Router::new().route(path, get(|| async { "x" }));
}

Origin Seeder

anonymous