From d456816f49c8798917e4184a66a6be8d1a9fa1b1 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 11 Mar 2026 12:48:37 +0100 Subject: [PATCH 01/28] fix formatting (#3685) --- axum-extra/src/extract/multipart.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/axum-extra/src/extract/multipart.rs b/axum-extra/src/extract/multipart.rs index b3d86c4c..d74c0bc3 100644 --- a/axum-extra/src/extract/multipart.rs +++ b/axum-extra/src/extract/multipart.rs @@ -3,11 +3,11 @@ //! See [`Multipart`] for more details. use axum_core::{ - RequestExt, __composite_rejection as composite_rejection, - __define_rejection as define_rejection, + __composite_rejection as composite_rejection, __define_rejection as define_rejection, body::Body, extract::FromRequest, response::{IntoResponse, Response}, + RequestExt, }; use bytes::Bytes; use futures_core::stream::Stream; From 8144052e7406ddd15ea166e18be9655294842f3c Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Tue, 24 Mar 2026 18:28:17 +0100 Subject: [PATCH 02/28] fix security advisories in axum v0.8 (#3698) --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50d551d4..96c5b553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,9 +725,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" @@ -3493,9 +3493,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -5213,30 +5213,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", From 9c67c6c33a0986cdf1d6ac17e2b1048235be938f Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Tue, 24 Mar 2026 18:37:30 +0100 Subject: [PATCH 03/28] [FIX] link to query-params-with-empty-string (#3695) Co-authored-by: Yann Simon --- axum-extra/src/extract/query.rs | 2 +- axum/src/extract/query.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/axum-extra/src/extract/query.rs b/axum-extra/src/extract/query.rs index 625c583f..1ab45c01 100644 --- a/axum-extra/src/extract/query.rs +++ b/axum-extra/src/extract/query.rs @@ -46,7 +46,7 @@ use serde_core::de::DeserializeOwned; /// For handling values being empty vs missing see the [query-params-with-empty-strings][example] /// example. /// -/// [example]: https://github.com/tokio-rs/axum/blob/main/examples/query-params-with-empty-strings/src/main.rs +/// [example]: https://github.com/tokio-rs/axum/blob/v0.8.x/examples/query-params-with-empty-strings/src/main.rs /// /// While `Option` will handle empty parameters (e.g. `param=`), beware when using this with a /// `Vec`. If your list is optional, use `Vec` in combination with `#[serde(default)]` diff --git a/axum/src/extract/query.rs b/axum/src/extract/query.rs index 6fe81099..89e5e566 100644 --- a/axum/src/extract/query.rs +++ b/axum/src/extract/query.rs @@ -40,7 +40,7 @@ use serde_core::de::DeserializeOwned; /// For handling values being empty vs missing see the [query-params-with-empty-strings][example] /// example. /// -/// [example]: https://github.com/tokio-rs/axum/blob/main/examples/query-params-with-empty-strings/src/main.rs +/// [example]: https://github.com/tokio-rs/axum/blob/v0.8.x/examples/query-params-with-empty-strings/src/main.rs /// /// For handling multiple values for the same query parameter, in a `?foo=1&foo=2&foo=3` /// fashion, use [`axum_extra::extract::Query`] instead. From 2587f22a050d7bcb1fed735104002643bc1fb755 Mon Sep 17 00:00:00 2001 From: Consoli Date: Mon, 5 Jan 2026 13:45:45 -0300 Subject: [PATCH 04/28] docs: Document that `vpath` and `json` macros are feature-gated (#3615) --- axum-extra/src/response/erased_json.rs | 1 + axum-extra/src/routing/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/axum-extra/src/response/erased_json.rs b/axum-extra/src/response/erased_json.rs index aa554a7e..17d8967b 100644 --- a/axum-extra/src/response/erased_json.rs +++ b/axum-extra/src/response/erased_json.rs @@ -130,6 +130,7 @@ impl IntoResponse for ErasedJson { /// ``` /// let response = axum_extra::json!(["trailing",]); /// ``` +#[cfg_attr(docsrs, doc(cfg(feature = "erased-json")))] #[macro_export] macro_rules! json { ($($t:tt)*) => { diff --git a/axum-extra/src/routing/mod.rs b/axum-extra/src/routing/mod.rs index 45cb180e..5057302d 100644 --- a/axum-extra/src/routing/mod.rs +++ b/axum-extra/src/routing/mod.rs @@ -68,6 +68,7 @@ pub const fn __private_validate_static_path(path: &'static str) -> &'static str /// ``` /// /// This macro is available only on rust versions 1.80 and above. +#[cfg_attr(docsrs, doc(cfg(feature = "routing")))] #[rustversion::since(1.80)] #[macro_export] macro_rules! vpath { From 2b0978690c9bf852c014128fdd2714f92e496e23 Mon Sep 17 00:00:00 2001 From: Jonas Platte Date: Tue, 6 Jan 2026 11:04:40 +0100 Subject: [PATCH 05/28] Stop calling axum a 'framework' (#3616) --- ECOSYSTEM.md | 4 ++-- axum-core/Cargo.toml | 2 +- axum-extra/Cargo.toml | 2 +- axum/Cargo.toml | 4 ++-- axum/README.md | 4 ++-- axum/src/lib.rs | 4 ++-- axum/src/routing/mod.rs | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ECOSYSTEM.md b/ECOSYSTEM.md index 32fb50aa..6e94da4c 100644 --- a/ECOSYSTEM.md +++ b/ECOSYSTEM.md @@ -24,7 +24,7 @@ If your project isn't listed here and you would like it to be, please feel free - [axum-template](https://github.com/Altair-Bueno/axum-template): Layers, extractors and template engine wrappers for axum based Web MVC applications - [axum-template](https://github.com/janos-r/axum-template): GraphQL and REST API, SurrealDb, JWT auth, direct error handling, request logs - [axum-guard-logic](https://github.com/sjud/axum_guard_logic): Use AND/OR logic to extract types and check their values against `Service` inputs. -- [axum-casbin-auth](https://github.com/casbin-rs/axum-casbin-auth): Casbin access control middleware for axum framework +- [axum-casbin-auth](https://github.com/casbin-rs/axum-casbin-auth): Casbin access control middleware for axum - [aide](https://docs.rs/aide): Code-first Open API documentation generator with [axum integration](https://docs.rs/aide/latest/aide/axum/index.html). - [axum-typed-routing](https://docs.rs/axum-typed-routing/latest/axum_typed_routing/): Statically typed routing macros with OpenAPI generation using aide. - [axum-jsonschema](https://docs.rs/axum-jsonschema/): A `Json` extractor that does JSON schema validation of requests. @@ -106,7 +106,7 @@ If your project isn't listed here and you would like it to be, please feel free - [Rust on Nails](https://rust-on-nails.com/): A full stack architecture for Rust web applications - [axum-tutorial] ([website][axum-tutorial-website]): axum tutorial for beginners -- [demo-rust-axum]: Demo of Rust and axum web framework +- [demo-rust-axum]: Demo of Rust and axum - [Introduction to axum (talk)]: Talk about axum from the Copenhagen Rust Meetup - [Getting Started with Axum]: axum tutorial, GET, POST endpoints and serving files - [Using Rust, Axum, PostgreSQL, and Tokio to build a Blog] diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index 5403fd3f..7667a3fc 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -4,7 +4,7 @@ description = "Core types and traits for axum" edition = "2021" rust-version = { workspace = true } homepage = "https://github.com/tokio-rs/axum" -keywords = ["http", "web", "framework"] +keywords = ["http", "web", "routing"] license = "MIT" name = "axum-core" readme = "README.md" diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index c66d1365..6662a762 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -4,7 +4,7 @@ description = "Extra utilities for axum" edition = "2021" rust-version = { workspace = true } homepage = "https://github.com/tokio-rs/axum" -keywords = ["http", "web", "framework"] +keywords = ["http", "web", "routing"] license = "MIT" name = "axum-extra" readme = "README.md" diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 770bd0c3..03e69051 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -2,11 +2,11 @@ name = "axum" version = "0.8.8" # remember to bump the version that axum-extra depends on categories = ["asynchronous", "network-programming", "web-programming::http-server"] -description = "Web framework that focuses on ergonomics and modularity" +description = "HTTP routing and request handling library that focuses on ergonomics and modularity" edition = "2021" rust-version = { workspace = true } homepage = "https://github.com/tokio-rs/axum" -keywords = ["http", "web", "framework"] +keywords = ["http", "web", "routing"] license = "MIT" readme = "README.md" repository = "https://github.com/tokio-rs/axum" diff --git a/axum/README.md b/axum/README.md index dedce62e..bf06b82f 100644 --- a/axum/README.md +++ b/axum/README.md @@ -1,6 +1,6 @@ # axum -`axum` is a web application framework that focuses on ergonomics and modularity. +`axum` is an HTTP routing and request-handling library that focuses on ergonomics and modularity. [![Build status](https://github.com/tokio-rs/axum/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/tokio-rs/axum/actions/workflows/CI.yml) [![Crates.io](https://img.shields.io/crates/v/axum)](https://crates.io/crates/axum) @@ -17,7 +17,7 @@ More information about this crate can be found in the [crate documentation][docs - Take full advantage of the [`tower`] and [`tower-http`] ecosystem of middleware, services, and utilities. -In particular the last point is what sets `axum` apart from other frameworks. +In particular the last point is what sets `axum` apart from other libraries / frameworks. `axum` doesn't have its own middleware system but instead uses [`tower::Service`]. This means `axum` gets timeouts, tracing, compression, authorization, and more, for free. It also enables you to share middleware with diff --git a/axum/src/lib.rs b/axum/src/lib.rs index a7ed1963..fc3b1599 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -1,4 +1,4 @@ -//! axum is a web application framework that focuses on ergonomics and modularity. +//! axum is an HTTP routing and request-handling library that focuses on ergonomics and modularity. //! //! # High-level features //! @@ -9,7 +9,7 @@ //! - Take full advantage of the [`tower`] and [`tower-http`] ecosystem of //! middleware, services, and utilities. //! -//! In particular, the last point is what sets `axum` apart from other frameworks. +//! In particular, the last point is what sets `axum` apart from other libraries / frameworks. //! `axum` doesn't have its own middleware system but instead uses //! [`tower::Service`]. This means `axum` gets timeouts, tracing, compression, //! authorization, and more, for free. It also enables you to share middleware with diff --git a/axum/src/routing/mod.rs b/axum/src/routing/mod.rs index deeeadc0..c06f8778 100644 --- a/axum/src/routing/mod.rs +++ b/axum/src/routing/mod.rs @@ -204,7 +204,7 @@ where } #[doc = include_str!("../docs/routing/nest.md")] - #[doc(alias = "scope")] // Some web frameworks like actix-web use this term + #[doc(alias = "scope")] // Some other libs like actix-web use this term #[track_caller] pub fn nest(self, path: &str, router: Router) -> Self { if path.is_empty() || path == "/" { From 8019ae0786bb1297192acf7f83a383009234d78d Mon Sep 17 00:00:00 2001 From: Lethe Lee <87625844+Lethe10137@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:42:55 +0000 Subject: [PATCH 06/28] Add customizable WebSocket subprotocol selection (#3597) --- axum/src/extract/ws.rs | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/axum/src/extract/ws.rs b/axum/src/extract/ws.rs index 4e23273a..85c16bc4 100644 --- a/axum/src/extract/ws.rs +++ b/axum/src/extract/ws.rs @@ -266,11 +266,45 @@ impl WebSocketUpgrade { self } + /// Return the WebSocket subprotocols requested by the client. + /// + /// # Examples + /// + /// If the client sends the following HTTP header in the WebSocket upgrade request: + /// + /// ```txt + /// Sec-WebSocket-Protocol: soap, wamp + /// ``` + /// + /// this method returns an iterator yielding `"soap"` and `"wamp"`. + pub fn requested_protocols(&self) -> impl Iterator { + self.sec_websocket_protocol + .as_ref() + .and_then(|p| p.to_str().ok()) + .into_iter() + .flat_map(|s| s.split(',')) + .map(|s| s.trim()) + } + + /// Set the chosen WebSocket subprotocol. + /// + /// Another method, [`protocols()`][Self::protocols], also sets the chosen WebSocket + /// subprotocol. If both methods are called, only the latter call takes effect. + /// + /// # Notes + /// + /// - The chosen protocol is echoed back in the WebSocket upgrade + /// response as required by RFC 6455. Some browsers may reject a + /// value that was not present in the client's request. + pub fn set_selected_protocol(&mut self, protocol: HeaderValue) { + self.protocol = Some(protocol); + } + /// Return the selected WebSocket subprotocol, if one has been chosen. /// - /// If [`protocols()`][Self::protocols] has been called and a matching - /// protocol has been selected, the return value will be `Some` containing - /// said protocol. Otherwise, it will be `None`. + /// If [`protocols()`][Self::protocols] selects a matching protocol, or + /// [`set_selected_protocol()`][Self::set_selected_protocol] has been called, the return + /// value will be `Some` containing the selected protocol. Otherwise, it will be `None`. pub fn selected_protocol(&self) -> Option<&HeaderValue> { self.protocol.as_ref() } From 39b9f727d73fcd6239aa85ef48dfb32251013ee0 Mon Sep 17 00:00:00 2001 From: Jonas Platte Date: Sat, 10 Jan 2026 13:06:45 +0100 Subject: [PATCH 07/28] Rewrite sec-websocket-protocol handling (#3620) --- .github/workflows/CI.yml | 2 +- Cargo.toml | 2 +- axum/CHANGELOG.md | 10 +++++++ axum/README.md | 2 +- axum/src/extract/ws.rs | 61 +++++++++++++++++++++------------------- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 28d2b521..fde79f4b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -2,7 +2,7 @@ name: CI env: CARGO_TERM_COLOR: always - MSRV: '1.78' + MSRV: '1.80' on: push: diff --git a/Cargo.toml b/Cargo.toml index ee8b6b9a..77f4d379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ exclude = [ resolver = "2" [workspace.package] -rust-version = "1.78" +rust-version = "1.80" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index c0e9c470..e54b2ab8 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +# 0.8.9 + +- **added:** `WebSocketUpgrade::{requested_protocols, set_selected_protocol}` for more + flexible subprotocol selection ([#3597]) +- **changed:** Update minimum rust version to 1.80 ([#3620]) + +[#3597]: https://github.com/tokio-rs/axum/pull/3597 +[#3620]: https://github.com/tokio-rs/axum/pull/3620 + # 0.8.8 - Clarify documentation for `Router::route_layer` ([#3567]) diff --git a/axum/README.md b/axum/README.md index bf06b82f..2fb5f089 100644 --- a/axum/README.md +++ b/axum/README.md @@ -104,7 +104,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in ## Minimum supported Rust version -axum's MSRV is 1.78. +axum's MSRV is 1.80. ## Examples diff --git a/axum/src/extract/ws.rs b/axum/src/extract/ws.rs index 85c16bc4..0d9005b4 100644 --- a/axum/src/extract/ws.rs +++ b/axum/src/extract/ws.rs @@ -107,8 +107,10 @@ use hyper_util::rt::TokioIo; use sha1::{Digest, Sha1}; use std::{ borrow::Cow, + collections::BTreeSet, future::Future, pin::Pin, + str, task::{ready, Context, Poll}, }; use tokio_tungstenite::{ @@ -138,7 +140,7 @@ pub struct WebSocketUpgrade { sec_websocket_key: Option, on_upgrade: hyper::upgrade::OnUpgrade, on_failed_upgrade: F, - sec_websocket_protocol: Option, + sec_websocket_protocol: BTreeSet, } impl std::fmt::Debug for WebSocketUpgrade { @@ -242,26 +244,23 @@ impl WebSocketUpgrade { I: IntoIterator, I::Item: Into>, { - if let Some(req_protocols) = self - .sec_websocket_protocol - .as_ref() - .and_then(|p| p.to_str().ok()) - { - self.protocol = protocols - .into_iter() - // FIXME: This will often allocate a new `String` and so is less efficient than it - // could be. But that can't be fixed without breaking changes to the public API. - .map(Into::into) - .find(|protocol| { - req_protocols - .split(',') - .any(|req_protocol| req_protocol.trim() == protocol) - }) - .map(|protocol| match protocol { - Cow::Owned(s) => HeaderValue::from_str(&s).unwrap(), - Cow::Borrowed(s) => HeaderValue::from_static(s), - }); - } + self.protocol = protocols + .into_iter() + .map(Into::into) + .find(|proto| { + // FIXME: When https://github.com/hyperium/http/pull/814 + // is merged + released, we can look use + // `contains(proto.as_bytes())` without converting + // to `HeaderValue` first. + let Ok(proto) = HeaderValue::from_str(proto) else { + return false; + }; + self.sec_websocket_protocol.contains(&proto) + }) + .map(|protocol| match protocol { + Cow::Owned(s) => HeaderValue::from_str(&s).unwrap(), + Cow::Borrowed(s) => HeaderValue::from_static(s), + }); self } @@ -277,13 +276,8 @@ impl WebSocketUpgrade { /// ``` /// /// this method returns an iterator yielding `"soap"` and `"wamp"`. - pub fn requested_protocols(&self) -> impl Iterator { - self.sec_websocket_protocol - .as_ref() - .and_then(|p| p.to_str().ok()) - .into_iter() - .flat_map(|s| s.split(',')) - .map(|s| s.trim()) + pub fn requested_protocols(&self) -> impl Iterator { + self.sec_websocket_protocol.iter() } /// Set the chosen WebSocket subprotocol. @@ -501,7 +495,16 @@ where .remove::() .ok_or(ConnectionNotUpgradable)?; - let sec_websocket_protocol = parts.headers.get(header::SEC_WEBSOCKET_PROTOCOL).cloned(); + let sec_websocket_protocol = parts + .headers + .get_all(header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .flat_map(|val| val.as_bytes().split(|&b| b == b',')) + .map(|proto| { + HeaderValue::from_bytes(proto.trim_ascii()) + .expect("substring of HeaderValue is valid HeaderValue") + }) + .collect(); Ok(Self { config: Default::default(), From 417dc25280a5370fc168ede6f098c825742038fe Mon Sep 17 00:00:00 2001 From: Jonas Platte Date: Sun, 18 Jan 2026 20:20:18 +0100 Subject: [PATCH 08/28] Fix `IntoResponse` for tuples overriding error response codes (#3603) Co-authored-by: David Pedersen Co-authored-by: Yann Simon --- Cargo.lock | 1 + axum-core/Cargo.toml | 1 + axum-core/src/response/into_response.rs | 85 +++-- axum-core/src/response/into_response_parts.rs | 20 +- axum-core/src/response/mod.rs | 88 ++++++ axum-extra/src/protobuf.rs | 9 +- axum-extra/src/response/erased_json.rs | 9 +- axum/src/form.rs | 9 +- axum/src/json.rs | 3 +- axum/src/response/mod.rs | 292 +++++++++++++++++- 10 files changed, 479 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96c5b553..a37766f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,7 @@ dependencies = [ "hyper 1.7.0", "mime", "pin-project-lite", + "serde", "sync_wrapper 1.0.2", "tokio", "tower-http 0.6.6", diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index 7667a3fc..b8b0195b 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -38,6 +38,7 @@ axum = { path = "../axum", features = ["__private"] } axum-extra = { path = "../axum-extra", features = ["typed-header"] } axum-macros = { path = "../axum-macros", features = ["__private"] } hyper = "1.0.0" +serde = { version = "1.0.200", features = ["derive"] } tokio = { version = "1.25.0", features = ["macros"] } tower-http = { version = "0.6.0", features = ["limit"] } diff --git a/axum-core/src/response/into_response.rs b/axum-core/src/response/into_response.rs index 915b55ef..a311851c 100644 --- a/axum-core/src/response/into_response.rs +++ b/axum-core/src/response/into_response.rs @@ -1,4 +1,4 @@ -use super::{IntoResponseParts, Response, ResponseParts}; +use super::{ForceStatusCode, IntoResponseFailed, IntoResponseParts, Response, ResponseParts}; use crate::{body::Body, BoxError}; use bytes::{buf::Chain, Buf, Bytes, BytesMut}; use http::{ @@ -329,7 +329,9 @@ where { fn into_response(self) -> Response { let mut res = self.1.into_response(); - *res.status_mut() = self.0; + if res.extensions().get::().is_none() { + *res.status_mut() = self.0; + } res } } @@ -405,18 +407,16 @@ macro_rules! impl_into_response { let ($($ty),*, res) = self; let res = res.into_response(); - let parts = ResponseParts { res }; - - $( - let parts = match $ty.into_response_parts(parts) { + if res.extensions().get::().is_none() { + let parts = ResponseParts { res }; + let parts = match ($($ty,)*).into_response_parts(parts) { Ok(parts) => parts, - Err(err) => { - return err.into_response(); - } + Err(err) => return err.into_response(), }; - )* - - parts.res + parts.res + } else { + res + } } } @@ -430,16 +430,40 @@ macro_rules! impl_into_response { let (status, $($ty),*, res) = self; let res = res.into_response(); - let parts = ResponseParts { res }; - - $( - let parts = match $ty.into_response_parts(parts) { + if res.extensions().get::().is_none() { + let parts = ResponseParts { res }; + let mut parts = match ($($ty,)*).into_response_parts(parts) { Ok(parts) => parts, - Err(err) => { - return err.into_response(); - } + Err(err) => return err.into_response(), }; - )* + + // Don't call `(status, parts.res).into_response()` since that checks for + // `IntoResponseFailed` and skips setting the status. We've already done that + // check here so overriding the status is required if returning + // `(IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR)` + *parts.res.status_mut() = status; + parts.res + } else { + res + } + } + } + + #[allow(non_snake_case)] + impl IntoResponse for (ForceStatusCode, $($ty),*, R) + where + $( $ty: IntoResponseParts, )* + R: IntoResponse, + { + fn into_response(self) -> Response { + let (status, $($ty),*, res) = self; + + let res = res.into_response(); + let parts = ResponseParts { res }; + let parts = match ($($ty,)*).into_response_parts(parts) { + Ok(parts) => parts, + Err(err) => return err.into_response(), + }; (status, parts.res).into_response() } @@ -455,17 +479,22 @@ macro_rules! impl_into_response { let (outer_parts, $($ty),*, res) = self; let res = res.into_response(); - let parts = ResponseParts { res }; - $( - let parts = match $ty.into_response_parts(parts) { + if res.extensions().get::().is_none() { + let parts = ResponseParts { res }; + let mut parts = match ($($ty,)*).into_response_parts(parts) { Ok(parts) => parts, - Err(err) => { - return err.into_response(); - } + Err(err) => return err.into_response(), }; - )* - (outer_parts, parts.res).into_response() + // Don't call `(outer_parts, parts.res).into_response()` for the same reason we + // don't call `(status, parts.res).into_response()` in the above impl. + *parts.res.status_mut() = outer_parts.status; + parts.res.headers_mut().extend(outer_parts.headers); + parts.res.extensions_mut().extend(outer_parts.extensions); + parts.res + } else { + res + } } } diff --git a/axum-core/src/response/into_response_parts.rs b/axum-core/src/response/into_response_parts.rs index 95564823..a142b50b 100644 --- a/axum-core/src/response/into_response_parts.rs +++ b/axum-core/src/response/into_response_parts.rs @@ -241,7 +241,9 @@ macro_rules! impl_into_response_parts { let res = match $ty.into_response_parts(res) { Ok(res) => res, Err(err) => { - return Err(err.into_response()); + let mut err_res = err.into_response(); + err_res.extensions_mut().insert(super::IntoResponseFailed); + return Err(err_res); } }; )* @@ -270,3 +272,19 @@ impl IntoResponseParts for () { Ok(res) } } + +#[cfg(test)] +mod tests { + use http::StatusCode; + + use crate::response::IntoResponse; + + #[test] + fn failed_into_response_parts() { + let response = (StatusCode::CREATED, [("\n", "\n")]).into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + let response = (StatusCode::CREATED, [("\n", "\n")], ()).into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/axum-core/src/response/mod.rs b/axum-core/src/response/mod.rs index b40b2529..b00bc85e 100644 --- a/axum-core/src/response/mod.rs +++ b/axum-core/src/response/mod.rs @@ -4,6 +4,10 @@ //! //! [`axum::response`]: https://docs.rs/axum/0.8/axum/response/index.html +use std::convert::Infallible; + +use http::StatusCode; + use crate::body::Body; mod append_headers; @@ -128,3 +132,87 @@ where Self(value.into_response()) } } + +/// Response part that stops status code overrides. +/// +/// This type should be used by types implementing [`IntoResponseParts`] or +/// [`IntoResponse`] when they fail to produce the response usually expected of +/// them and return some sort of error response instead. +/// +/// It is checked used by the tuple impls of [`IntoResponse`] that have a +/// [`StatusCode`] as their first element to ignore that status code. +/// Consider the following example: +/// +/// ```no_run +/// # use axum::Json; +/// # use http::StatusCode; +/// # #[derive(serde::Serialize)] +/// # struct CreatedResponse { } +/// fn my_handler(/* ... */) -> (StatusCode, Json) { +/// // This response type's serialization may fail +/// let response = CreatedResponse { /* ... */ }; +/// (StatusCode::CREATED, Json(response)) +/// } +/// ``` +/// +/// When `response` serialization succeeds, the server responds with a status +/// code of 201 Created (overwriting `Json`s default status code of 200 OK), +/// and the expected JSON payload. +/// +/// When `response` serialization fails hoewever, `impl IntoResponse for Json` +/// return a response with status code 500 Internal Server Error, and +/// `IntoResponseFailed` as a response extension, and the 201 Created override +/// is ignored. +/// +/// This is a behavior introduced with axum 0.9.\ +/// To force a status code override even when an inner [`IntoResponseParts`] / +/// [`IntoResponse`] failed, use [`ForceStatusCode`]. +#[derive(Copy, Clone, Debug)] +pub struct IntoResponseFailed; + +impl IntoResponseParts for IntoResponseFailed { + type Error = Infallible; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + res.extensions_mut().insert(self); + Ok(res) + } +} + +/// Not sure it makes sense to return `IntoResponseFailed` as the whole response. You should +/// probably at least combine it with a status code. +/// +/// ```compile_fail +/// fn foo() +/// where +/// axum_core::response::IntoResponseFailed: axum_core::response::IntoResponse, +/// {} +/// ``` +#[allow(dead_code)] +fn into_response_failed_doesnt_impl_into_response() {} + +/// Set the status code regardless of whether [`IntoResponseFailed`] is used or not. +/// +/// See the docs for [`IntoResponseFailed`] for more details. +#[derive(Debug, Copy, Clone, Default)] +pub struct ForceStatusCode(pub StatusCode); + +impl IntoResponse for ForceStatusCode { + fn into_response(self) -> Response { + let mut res = ().into_response(); + *res.status_mut() = self.0; + res + } +} + +impl IntoResponse for (ForceStatusCode, R) +where + R: IntoResponse, +{ + fn into_response(self) -> Response { + let (ForceStatusCode(status), res) = self; + let mut res = res.into_response(); + *res.status_mut() = status; + res + } +} diff --git a/axum-extra/src/protobuf.rs b/axum-extra/src/protobuf.rs index cc68af2d..aa8991ce 100644 --- a/axum-extra/src/protobuf.rs +++ b/axum-extra/src/protobuf.rs @@ -4,7 +4,7 @@ use axum_core::__composite_rejection as composite_rejection; use axum_core::__define_rejection as define_rejection; use axum_core::{ extract::{rejection::BytesRejection, FromRequest, Request}, - response::{IntoResponse, Response}, + response::{IntoResponse, IntoResponseFailed, Response}, RequestExt, }; use bytes::BytesMut; @@ -131,7 +131,12 @@ where let mut buf = BytesMut::with_capacity(self.0.encoded_len()); match &self.0.encode(&mut buf) { Ok(()) => buf.into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + IntoResponseFailed, + err.to_string(), + ) + .into_response(), } } } diff --git a/axum-extra/src/response/erased_json.rs b/axum-extra/src/response/erased_json.rs index 17d8967b..def3d2c1 100644 --- a/axum-extra/src/response/erased_json.rs +++ b/axum-extra/src/response/erased_json.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use axum_core::response::{IntoResponse, Response}; +use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; use bytes::{BufMut, Bytes, BytesMut}; use http::{header, HeaderValue, StatusCode}; use serde_core::Serialize; @@ -78,7 +78,12 @@ impl IntoResponse for ErasedJson { bytes, ) .into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + IntoResponseFailed, + err.to_string(), + ) + .into_response(), } } } diff --git a/axum/src/form.rs b/axum/src/form.rs index ab692a64..50a532e4 100644 --- a/axum/src/form.rs +++ b/axum/src/form.rs @@ -1,6 +1,6 @@ use crate::extract::Request; use crate::extract::{rejection::*, FromRequest, RawForm}; -use axum_core::response::{IntoResponse, Response}; +use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; use axum_core::RequestExt; use http::header::CONTENT_TYPE; use http::StatusCode; @@ -117,7 +117,12 @@ where body, ) .into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + IntoResponseFailed, + err.to_string(), + ) + .into_response(), } } diff --git a/axum/src/json.rs b/axum/src/json.rs index 59f2c859..90ea13ae 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -1,7 +1,7 @@ use crate::extract::Request; use crate::extract::{rejection::*, FromRequest}; use axum_core::extract::OptionalFromRequest; -use axum_core::response::{IntoResponse, Response}; +use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; use bytes::{BufMut, Bytes, BytesMut}; use http::{ header::{self, HeaderMap, HeaderValue}, @@ -224,6 +224,7 @@ where header::CONTENT_TYPE, HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), )], + IntoResponseFailed, err.to_string(), ) .into_response(), diff --git a/axum/src/response/mod.rs b/axum/src/response/mod.rs index 70be7452..6307610f 100644 --- a/axum/src/response/mod.rs +++ b/axum/src/response/mod.rs @@ -19,7 +19,8 @@ pub use crate::Extension; #[doc(inline)] pub use axum_core::response::{ - AppendHeaders, ErrorResponse, IntoResponse, IntoResponseParts, Response, ResponseParts, Result, + AppendHeaders, ErrorResponse, IntoResponse, IntoResponseFailed, IntoResponseParts, Response, + ResponseParts, Result, }; #[doc(inline)] @@ -85,10 +86,16 @@ impl IntoResponse for NoContent { #[cfg(test)] mod tests { use crate::extract::Extension; + use crate::test_helpers::*; + use crate::Json; use crate::{routing::get, Router}; - use axum_core::response::IntoResponse; + use axum_core::response::ForceStatusCode; + use axum_core::response::{ + IntoResponse, IntoResponseFailed, IntoResponseParts, Response, ResponseParts, + }; use http::HeaderMap; use http::{StatusCode, Uri}; + use std::collections::HashMap; // just needs to compile #[allow(dead_code)] @@ -247,6 +254,287 @@ mod tests { .route("/", get(header_array_extension_mixed_body)); } + #[test] + fn status_code_tuple_doesnt_override_error() { + // sanity check where there is just one status code + assert_eq!( + StatusCode::INTERNAL_SERVER_ERROR.into_response().status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + (StatusCode::INTERNAL_SERVER_ERROR,) + .into_response() + .status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + + // non-5xx status should be changed + assert_eq!( + (StatusCode::SEE_OTHER, StatusCode::NO_CONTENT) + .into_response() + .status(), + StatusCode::SEE_OTHER + ); + let res = ( + StatusCode::SEE_OTHER, + [("location", "foo")], + StatusCode::NO_CONTENT, + ) + .into_response(); + assert_eq!(res.status(), StatusCode::SEE_OTHER); + assert_eq!(res.headers()["location"], "foo"); + + // 5xx status codes are also changed + assert_eq!( + (StatusCode::SEE_OTHER, StatusCode::INTERNAL_SERVER_ERROR) + .into_response() + .status(), + StatusCode::SEE_OTHER + ); + let res = ( + StatusCode::SEE_OTHER, + [("location", "foo")], + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response(); + assert_eq!(res.status(), StatusCode::SEE_OTHER); + assert_eq!(res.headers()["location"], "foo"); + + // the status is not changed if `IntoResponseFailed` is used + assert_eq!( + ( + StatusCode::SEE_OTHER, + (IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR) + ) + .into_response() + .status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + let res = ( + StatusCode::SEE_OTHER, + [("location", "foo")], + (IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + + // response parts from the inner response do run + let res = ( + // with status override + StatusCode::SEE_OTHER, + [("location", "foo")], + ( + [("x-bar", "bar")], + IntoResponseFailed, + [("x-foo", "foo")], + StatusCode::INTERNAL_SERVER_ERROR, + ), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + assert_eq!(res.headers()["x-foo"], "foo"); + assert_eq!(res.headers()["x-bar"], "bar"); + + let res = ( + // without status override + [("location", "foo")], + ( + [("x-bar", "bar")], + IntoResponseFailed, + [("x-foo", "foo")], + StatusCode::INTERNAL_SERVER_ERROR, + ), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + assert_eq!(res.headers()["x-foo"], "foo"); + assert_eq!(res.headers()["x-bar"], "bar"); + + // (Parts, ...) + let res = ( + Response::new(()).into_parts().0, + [("location", "foo")], + ( + [("x-bar", "bar")], + IntoResponseFailed, + [("x-foo", "foo")], + StatusCode::INTERNAL_SERVER_ERROR, + ), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + assert_eq!(res.headers()["x-foo"], "foo"); + assert_eq!(res.headers()["x-bar"], "bar"); + + // (Response<()>, ...) + let res = ( + Response::new(()), + [("location", "foo")], + ( + [("x-bar", "bar")], + IntoResponseFailed, + [("x-foo", "foo")], + StatusCode::INTERNAL_SERVER_ERROR, + ), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + assert_eq!(res.headers()["x-foo"], "foo"); + assert_eq!(res.headers()["x-bar"], "bar"); + } + + #[test] + fn into_response_parts_failing_sets_extension() { + struct Fail; + + impl IntoResponseParts for Fail { + type Error = (); + + fn into_response_parts( + self, + _res: ResponseParts, + ) -> Result { + Err(()) + } + } + + impl IntoResponse for Fail { + fn into_response(self) -> Response { + (self, ()).into_response() + } + } + + assert!(Fail + .into_response() + .extensions() + .get::() + .is_some()); + + assert!((StatusCode::INTERNAL_SERVER_ERROR, Fail, ()) + .into_response() + .extensions() + .get::() + .is_some()); + + assert!((Response::new(()).into_parts().0, Fail, ()) + .into_response() + .extensions() + .get::() + .is_some()); + + assert!((Response::new(()), Fail, ()) + .into_response() + .extensions() + .get::() + .is_some()); + } + + #[test] + fn doenst_override_status_code_when_using_into_response_failed_at_same_level() { + assert_eq!( + (StatusCode::INTERNAL_SERVER_ERROR, IntoResponseFailed, ()) + .into_response() + .status(), + StatusCode::INTERNAL_SERVER_ERROR, + ); + + #[derive(Clone)] + struct Thing; + + let res = ( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("x-foo", "foo") + .extension(Thing) + .body(()) + .unwrap() + .into_parts() + .0, + IntoResponseFailed, + (), + ) + .into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(res.headers()["x-foo"], "foo"); + assert!(res.extensions().get::().is_some()); + + // just a sanity check + assert_eq!( + (IntoResponseFailed, ()).into_response().status(), + StatusCode::OK, + ); + } + + #[test] + fn force_overriding_status_code() { + assert_eq!( + ForceStatusCode(StatusCode::IM_A_TEAPOT) + .into_response() + .status(), + StatusCode::IM_A_TEAPOT + ); + + assert_eq!( + (ForceStatusCode(StatusCode::IM_A_TEAPOT),) + .into_response() + .status(), + StatusCode::IM_A_TEAPOT + ); + + assert_eq!( + (ForceStatusCode(StatusCode::IM_A_TEAPOT), ()) + .into_response() + .status(), + StatusCode::IM_A_TEAPOT + ); + + assert_eq!( + ( + ForceStatusCode(StatusCode::IM_A_TEAPOT), + IntoResponseFailed, + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response() + .status(), + StatusCode::IM_A_TEAPOT + ); + } + + #[crate::test] + async fn status_code_tuple_doesnt_override_error_json() { + let app = Router::new() + .route( + "/", + get(|| async { + let not_json_compatible = HashMap::from([(Vec::from([1, 2, 3]), 123)]); + (StatusCode::IM_A_TEAPOT, Json(not_json_compatible)) + }), + ) + .route( + "/two", + get(|| async { + let not_json_compatible = HashMap::from([(Vec::from([1, 2, 3]), 123)]); + ( + ForceStatusCode(StatusCode::IM_A_TEAPOT), + Json(not_json_compatible), + ) + }), + ); + + let client = TestClient::new(app); + + let res = client.get("/").await; + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + + let res = client.get("/two").await; + assert_eq!(res.status(), StatusCode::IM_A_TEAPOT); + } + #[test] fn no_content() { assert_eq!( From 2773966059a3de9734d4a2cc8f63486590e28784 Mon Sep 17 00:00:00 2001 From: Antonio Souza Date: Tue, 27 Jan 2026 04:50:35 -0500 Subject: [PATCH 09/28] Fix InternalServerError docs to match current behavior (#3640) Signed-off-by: Antonio Souza --- axum-extra/src/response/error_response.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/axum-extra/src/response/error_response.rs b/axum-extra/src/response/error_response.rs index 97558c7a..6a503e92 100644 --- a/axum-extra/src/response/error_response.rs +++ b/axum-extra/src/response/error_response.rs @@ -6,9 +6,9 @@ use tracing::error; /// Convenience response to create an error response from a non-[`IntoResponse`] error /// /// This provides a method to quickly respond with an error that does not implement -/// the `IntoResponse` trait itself. This type should only be used for debugging purposes or internal -/// facing applications, as it includes the full error chain with descriptions, -/// thus leaking information that could possibly be sensitive. +/// the `IntoResponse` trait itself. Error details are logged using [`tracing::error!`] +/// and a generic `500 Internal Server Error` response is returned to the client without +/// exposing error details. /// /// ```rust /// use axum_extra::response::InternalServerError; From 39a302e87432a7b47737c395c600e21163acdbd7 Mon Sep 17 00:00:00 2001 From: Canmi <9997200@qq.com> Date: Wed, 4 Feb 2026 14:34:35 +0800 Subject: [PATCH 10/28] fix: empty_enum renamed to empty_enums (#3642) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 77f4d379..55830fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ type_complexity = "allow" await_holding_lock = "warn" dbg_macro = "warn" -empty_enum = "warn" +empty_enums = "warn" enum_glob_use = "warn" exit = "warn" filter_map_next = "warn" From 6fd77f82ca5b0991f4190042d48b871d7d8025e7 Mon Sep 17 00:00:00 2001 From: 3moredays Date: Mon, 9 Feb 2026 17:09:07 +0800 Subject: [PATCH 11/28] fix: TypedPath conflicts with OptionalFromRequestParts (#3645) --- axum-macros/CHANGELOG.md | 7 +++++++ axum-macros/src/typed_path.rs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/axum-macros/CHANGELOG.md b/axum-macros/CHANGELOG.md index 102121e7..23a8cb7f 100644 --- a/axum-macros/CHANGELOG.md +++ b/axum-macros/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# 0.5.1 + +- **fixed:** Fix compilation error when deriving `TypedPath` with + `OptionalFromRequestParts` being in scope ([#3645]) + +[#3645]: https://github.com/tokio-rs/axum/pull/3645 + # 0.5.0 *No changes since alpha.1* diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 397cc94c..19a7461f 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -144,7 +144,8 @@ fn expand_named_fields( parts: &mut ::axum::http::request::Parts, state: &S, ) -> ::std::result::Result { - ::axum::extract::Path::from_request_parts(parts, state) + <::axum::extract::Path<#ident> as ::axum::extract::FromRequestParts> + ::from_request_parts(parts, state) .await .map(|path| path.0) #map_err_rejection From 27f40159865f7fd485655a3e663d4bcb491df2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindri=20P=C3=A9tur=20Ingimundarson?= Date: Fri, 13 Feb 2026 22:05:24 +0100 Subject: [PATCH 12/28] fix: Set connect endpoint on correct field in MethodRouter (#3656) --- axum/src/routing/method_routing.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/axum/src/routing/method_routing.rs b/axum/src/routing/method_routing.rs index 338d89c4..ed07c576 100644 --- a/axum/src/routing/method_routing.rs +++ b/axum/src/routing/method_routing.rs @@ -931,7 +931,7 @@ where set_endpoint( "CONNECT", - &mut self.options, + &mut self.connect, &endpoint, filter, MethodFilter::CONNECT, @@ -1400,13 +1400,16 @@ mod tests { #[crate::test] async fn merge() { - let mut svc = get(ok).merge(post(ok)); + let mut svc = get(ok).merge(post(ok)).merge(connect(ok)); let (status, _, _) = call(Method::GET, &mut svc).await; assert_eq!(status, StatusCode::OK); let (status, _, _) = call(Method::POST, &mut svc).await; assert_eq!(status, StatusCode::OK); + + let (status, _, _) = call(Method::CONNECT, &mut svc).await; + assert_eq!(status, StatusCode::OK); } #[crate::test] From 880cc381745cbc4b9175a5e19e84899038c50e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:46:33 +0100 Subject: [PATCH 13/28] fix: silence `clippy::implicit_clone` in `TypedPath` macro expansions (#3663) --- axum-macros/src/typed_path.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 19a7461f..1d06dae6 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -113,6 +113,7 @@ fn expand_named_fields( #[automatically_derived] impl ::std::fmt::Display for #ident { #[allow(clippy::unnecessary_to_owned)] + #[allow(clippy::implicit_clone)] fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { let Self { #(#captures,)* } = self; write!( @@ -218,6 +219,7 @@ fn expand_unnamed_fields( #[automatically_derived] impl ::std::fmt::Display for #ident { #[allow(clippy::unnecessary_to_owned)] + #[allow(clippy::implicit_clone)] fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { let Self { #(#destructure_self)* } = self; write!( From 4fa5d80ab7fc17d880d19293e8838bd7e4255877 Mon Sep 17 00:00:00 2001 From: Mathieu <109525460+4Mathleu@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:47:28 +0100 Subject: [PATCH 14/28] fix(axum-extra): escape filename in Content-Disposition header (#3664) --- axum-extra/CHANGELOG.md | 7 +++ axum-extra/src/response/attachment.rs | 57 ++++++++++++++++-- .../src/response/content_disposition.rs | 46 +++++++++++++++ axum-extra/src/response/file_stream.rs | 58 ++++++++++++++++++- axum-extra/src/response/mod.rs | 3 + 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 axum-extra/src/response/content_disposition.rs diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index f6cddd36..85cb0b21 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning]. +# 0.12.6 + +- **fixed:** Escape backslashes and double quotes in `Content-Disposition` filenames + to prevent header parameter injection in `Attachment` and `FileStream` ([#3664]) + +[#3664]: https://github.com/tokio-rs/axum/pull/3664 + # 0.12.5 - **fixed:** `JsonLines` now correctly respects the default body limit ([#3591]) diff --git a/axum-extra/src/response/attachment.rs b/axum-extra/src/response/attachment.rs index b2af0aed..87a8680c 100644 --- a/axum-extra/src/response/attachment.rs +++ b/axum-extra/src/response/attachment.rs @@ -1,3 +1,4 @@ +use super::content_disposition::EscapedFilename; use axum_core::response::IntoResponse; use http::{header, HeaderMap, HeaderValue}; use tracing::error; @@ -87,11 +88,11 @@ where } let content_disposition = if let Some(filename) = self.filename { - let mut bytes = b"attachment; filename=\"".to_vec(); - bytes.extend_from_slice(filename.as_bytes()); - bytes.push(b'\"'); - - HeaderValue::from_bytes(&bytes).expect("This was a HeaderValue so this can not fail") + let filename_str = filename + .to_str() + .expect("This was a HeaderValue so this can not fail"); + let value = format!("attachment; filename=\"{}\"", EscapedFilename(filename_str)); + HeaderValue::try_from(value).expect("This was a HeaderValue so this can not fail") } else { HeaderValue::from_static("attachment") }; @@ -101,3 +102,49 @@ where (headers, self.inner).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + use axum_core::response::IntoResponse; + use http::header::CONTENT_DISPOSITION; + + #[test] + fn attachment_without_filename() { + let attachment = Attachment::new("data").into_response(); + let value = attachment.headers().get(CONTENT_DISPOSITION).unwrap(); + assert_eq!(value, "attachment"); + } + + #[test] + fn attachment_with_normal_filename() { + let attachment = Attachment::new("data") + .filename("report.pdf") + .into_response(); + let value = attachment.headers().get(CONTENT_DISPOSITION).unwrap(); + assert_eq!(value, "attachment; filename=\"report.pdf\""); + } + + #[test] + fn attachment_filename_escapes_quotes() { + // A filename containing a double quote should be escaped to prevent + // Content-Disposition parameter injection (see CVE-2023-29401) + let attachment = Attachment::new("data") + .filename("evil\"; filename*=UTF-8''pwned.txt; x=\"") + .into_response(); + let value = attachment.headers().get(CONTENT_DISPOSITION).unwrap(); + assert_eq!( + value, + "attachment; filename=\"evil\\\"; filename*=UTF-8''pwned.txt; x=\\\"\"" + ); + } + + #[test] + fn attachment_filename_escapes_backslashes() { + let attachment = Attachment::new("data") + .filename("file\\name.txt") + .into_response(); + let value = attachment.headers().get(CONTENT_DISPOSITION).unwrap(); + assert_eq!(value, "attachment; filename=\"file\\\\name.txt\""); + } +} diff --git a/axum-extra/src/response/content_disposition.rs b/axum-extra/src/response/content_disposition.rs new file mode 100644 index 00000000..a4a34884 --- /dev/null +++ b/axum-extra/src/response/content_disposition.rs @@ -0,0 +1,46 @@ +use std::fmt::{self, Write}; + +/// A wrapper type that escapes backslashes and double quotes when formatted, +/// for safe inclusion in Content-Disposition header quoted-strings. +/// +/// This prevents Content-Disposition header parameter injection +/// (similar to CVE-2023-29401). +pub(crate) struct EscapedFilename<'a>(pub &'a str); + +impl fmt::Display for EscapedFilename<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.0.chars() { + if c == '\\' || c == '"' { + f.write_char('\\')?; + } + f.write_char(c)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_special_characters() { + assert_eq!(EscapedFilename("report.pdf").to_string(), "report.pdf"); + } + + #[test] + fn escapes_double_quotes() { + assert_eq!( + EscapedFilename("evil\"; filename*=UTF-8''pwned.txt; x=\"").to_string(), + "evil\\\"; filename*=UTF-8''pwned.txt; x=\\\"", + ); + } + + #[test] + fn escapes_backslashes() { + assert_eq!( + EscapedFilename("file\\name.txt").to_string(), + "file\\\\name.txt", + ); + } +} diff --git a/axum-extra/src/response/file_stream.rs b/axum-extra/src/response/file_stream.rs index c36fe61b..bcb35a80 100644 --- a/axum-extra/src/response/file_stream.rs +++ b/axum-extra/src/response/file_stream.rs @@ -276,7 +276,10 @@ where if let Some(file_name) = self.file_name { resp = resp.header( header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{file_name}\""), + format!( + "attachment; filename=\"{}\"", + super::content_disposition::EscapedFilename(&file_name) + ), ); } @@ -601,6 +604,59 @@ mod tests { Some((start, end)) } + #[tokio::test] + async fn filename_escapes_quotes() -> Result<(), Box> { + let app = Router::new().route( + "/file", + get(|| async { + let file_content = b"data".to_vec(); + let reader = Cursor::new(file_content); + let stream = ReaderStream::new(reader); + // Filename containing double quotes that could cause parameter injection + FileStream::new(stream) + .file_name("evil\"; filename*=UTF-8''pwned.txt; x=\"") + .into_response() + }), + ); + + let response = app + .oneshot(Request::builder().uri("/file").body(Body::empty())?) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-disposition").unwrap(), + "attachment; filename=\"evil\\\"; filename*=UTF-8''pwned.txt; x=\\\"\"" + ); + Ok(()) + } + + #[tokio::test] + async fn filename_escapes_backslashes() -> Result<(), Box> { + let app = Router::new().route( + "/file", + get(|| async { + let file_content = b"data".to_vec(); + let reader = Cursor::new(file_content); + let stream = ReaderStream::new(reader); + FileStream::new(stream) + .file_name("file\\name.txt") + .into_response() + }), + ); + + let response = app + .oneshot(Request::builder().uri("/file").body(Body::empty())?) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-disposition").unwrap(), + "attachment; filename=\"file\\\\name.txt\"" + ); + Ok(()) + } + #[tokio::test] async fn response_range_empty_file() -> Result<(), Box> { let file = tempfile::NamedTempFile::new()?; diff --git a/axum-extra/src/response/mod.rs b/axum-extra/src/response/mod.rs index 5460c694..e3ec32fc 100644 --- a/axum-extra/src/response/mod.rs +++ b/axum-extra/src/response/mod.rs @@ -1,5 +1,8 @@ //! Additional types for generating responses. +#[cfg(any(feature = "attachment", feature = "file-stream"))] +mod content_disposition; + #[cfg(feature = "erased-json")] mod erased_json; From 0a24ff6b0a65a9c1a6c2af8ef4df75f638d4afc7 Mon Sep 17 00:00:00 2001 From: tottoto Date: Tue, 24 Feb 2026 06:53:43 +0900 Subject: [PATCH 15/28] Address tower-http deprecated warning (#3672) --- Cargo.lock | 40 +++++++++++++++--------------- axum/Cargo.toml | 4 +-- axum/src/handler/mod.rs | 5 +++- axum/src/routing/method_routing.rs | 9 +++++-- axum/src/routing/tests/merge.rs | 6 ++++- axum/src/routing/tests/mod.rs | 6 ++++- 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a37766f5..bc7a9b43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,7 +329,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tower-layer", "tower-service", "tracing", @@ -355,7 +355,7 @@ dependencies = [ "serde", "sync_wrapper 1.0.2", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tower-layer", "tower-service", "tracing", @@ -396,7 +396,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tower-layer", "tower-service", "tracing", @@ -1443,7 +1443,7 @@ dependencies = [ "serde_json", "tokio", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", "zstd", @@ -1466,7 +1466,7 @@ version = "0.1.0" dependencies = [ "axum", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", ] [[package]] @@ -1527,7 +1527,7 @@ dependencies = [ "axum", "serde", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1562,7 +1562,7 @@ version = "0.1.0" dependencies = [ "axum", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing-subscriber", ] @@ -1619,7 +1619,7 @@ dependencies = [ "axum", "tokio", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1678,7 +1678,7 @@ dependencies = [ "mongodb", "serde", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1689,7 +1689,7 @@ version = "0.1.0" dependencies = [ "axum", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1787,7 +1787,7 @@ dependencies = [ "reqwest 0.12.23", "tokio", "tokio-stream", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1856,7 +1856,7 @@ dependencies = [ "reqwest-eventsource", "tokio", "tokio-stream", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1868,7 +1868,7 @@ dependencies = [ "axum", "tokio", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1918,7 +1918,7 @@ dependencies = [ "serde_json", "tokio", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -1965,7 +1965,7 @@ dependencies = [ "serde", "tokio", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", "uuid", @@ -2003,7 +2003,7 @@ version = "0.1.0" dependencies = [ "axum", "tokio", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -2058,7 +2058,7 @@ dependencies = [ "headers", "tokio", "tokio-tungstenite", - "tower-http 0.6.6", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -4253,7 +4253,7 @@ dependencies = [ "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", @@ -5514,9 +5514,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", "base64 0.22.1", diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 03e69051..cbe70342 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -90,7 +90,7 @@ tracing = { version = "0.1", default-features = false, optional = true } serde = { version = "1.0.211", optional = true } [dependencies.tower-http] -version = "0.6.0" +version = "0.6.8" optional = true features = [ # all tower-http features except (de)?compression-zstd which doesn't @@ -155,7 +155,7 @@ features = [ ] [dev-dependencies.tower-http] -version = "0.6.0" +version = "0.6.8" features = [ # all tower-http features except (de)?compression-zstd which doesn't # build on `--target armv5te-unknown-linux-musleabi` diff --git a/axum/src/handler/mod.rs b/axum/src/handler/mod.rs index 36c06b8d..135891f7 100644 --- a/axum/src/handler/mod.rs +++ b/axum/src/handler/mod.rs @@ -431,7 +431,10 @@ mod tests { let svc = handle .layer(( RequestBodyLimitLayer::new(1024), - TimeoutLayer::new(Duration::from_secs(10)), + TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(10), + ), MapResponseBodyLayer::new(Body::new), )) .layer(MapRequestBodyLayer::new(Body::new)) diff --git a/axum/src/routing/method_routing.rs b/axum/src/routing/method_routing.rs index ed07c576..8cb2cf11 100644 --- a/axum/src/routing/method_routing.rs +++ b/axum/src/routing/method_routing.rs @@ -1412,6 +1412,7 @@ mod tests { assert_eq!(status, StatusCode::OK); } + #[allow(deprecated)] #[crate::test] async fn layer() { let mut svc = MethodRouter::new() @@ -1427,6 +1428,7 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); } + #[allow(deprecated)] #[crate::test] async fn route_layer() { let mut svc = MethodRouter::new() @@ -1442,7 +1444,7 @@ mod tests { assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); } - #[allow(dead_code)] + #[allow(dead_code, deprecated)] async fn building_complex_router() { let app = crate::Router::new().route( "/", @@ -1453,7 +1455,10 @@ mod tests { .merge(delete_service(ServeDir::new("."))) .fallback(|| async { StatusCode::NOT_FOUND }) .put(ok) - .layer(TimeoutLayer::new(Duration::from_secs(10))), + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(10), + )), ); let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); diff --git a/axum/src/routing/tests/merge.rs b/axum/src/routing/tests/merge.rs index b760184f..206eb8e9 100644 --- a/axum/src/routing/tests/merge.rs +++ b/axum/src/routing/tests/merge.rs @@ -127,7 +127,10 @@ async fn layer_and_handle_error() { let one = Router::new().route("/foo", get(|| async {})); let two = Router::new() .route("/timeout", get(std::future::pending::<()>)) - .layer(TimeoutLayer::new(Duration::from_millis(10))); + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_millis(10), + )); let app = one.merge(two); let client = TestClient::new(app); @@ -364,6 +367,7 @@ async fn nesting_and_seeing_the_right_uri_ors_with_multi_segment_uris() { ); } +#[allow(deprecated)] #[crate::test] async fn middleware_that_return_early() { let private = Router::new() diff --git a/axum/src/routing/tests/mod.rs b/axum/src/routing/tests/mod.rs index bcd0e96a..f556ecb6 100644 --- a/axum/src/routing/tests/mod.rs +++ b/axum/src/routing/tests/mod.rs @@ -306,7 +306,10 @@ async fn wildcard_sees_whole_url() { async fn middleware_applies_to_routes_above() { let app = Router::new() .route("/one", get(std::future::pending::<()>)) - .layer(TimeoutLayer::new(Duration::ZERO)) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::ZERO, + )) .route("/two", get(|| async {})); let client = TestClient::new(app); @@ -481,6 +484,7 @@ async fn routing_to_router_panics() { TestClient::new(Router::new().route_service("/", Router::new())); } +#[allow(deprecated)] #[crate::test] async fn route_layer() { let app = Router::new() From 7e7a1c96f0a14cb9af945c8e9dee5207eba2738a Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 10:48:52 +0100 Subject: [PATCH 16/28] Address tower-http deprecated warning --- examples/graceful-shutdown/src/main.rs | 4 ++-- examples/key-value-store/src/main.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/graceful-shutdown/src/main.rs b/examples/graceful-shutdown/src/main.rs index 533cf8f1..238316bb 100644 --- a/examples/graceful-shutdown/src/main.rs +++ b/examples/graceful-shutdown/src/main.rs @@ -7,7 +7,7 @@ use std::time::Duration; -use axum::{routing::get, Router}; +use axum::{http::StatusCode, routing::get, Router}; use tokio::net::TcpListener; use tokio::signal; use tokio::time::sleep; @@ -39,7 +39,7 @@ async fn main() { TraceLayer::new_for_http(), // Graceful shutdown will wait for outstanding requests to complete. Add a timeout so // requests don't hang forever. - TimeoutLayer::new(Duration::from_secs(10)), + TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)), )); // Create a `TcpListener` using tokio. diff --git a/examples/key-value-store/src/main.rs b/examples/key-value-store/src/main.rs index c2b3f51c..847438da 100644 --- a/examples/key-value-store/src/main.rs +++ b/examples/key-value-store/src/main.rs @@ -114,6 +114,7 @@ async fn list_keys(State(state): State) -> String { .join("\n") } +#[allow(deprecated)] fn admin_routes() -> Router { async fn delete_all_keys(State(state): State) { state.write().unwrap().db.clear(); From ec4f5bbf9ee70a8eac941da2e6c2ba87abad16f7 Mon Sep 17 00:00:00 2001 From: Andrea Bozzo Date: Thu, 26 Feb 2026 11:14:20 +0100 Subject: [PATCH 17/28] fix: Return specific error message when multipart body limit is exceeded (#3611) --- axum-extra/src/extract/multipart.rs | 23 ++++++++++++++++++++++- axum/src/extract/multipart.rs | 23 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/axum-extra/src/extract/multipart.rs b/axum-extra/src/extract/multipart.rs index d74c0bc3..1163afce 100644 --- a/axum-extra/src/extract/multipart.rs +++ b/axum-extra/src/extract/multipart.rs @@ -247,7 +247,11 @@ impl MultipartError { /// Get the response body text used for this rejection. pub fn body_text(&self) -> String { - let body = self.source.to_string(); + let body = if is_body_limit_error(&self.source) { + "Request payload is too large".to_owned() + } else { + self.source.to_string() + }; axum_core::__log_rejection!( rejection_type = Self, body_text = body, @@ -298,6 +302,22 @@ fn status_code_from_multer_error(err: &multer::Error) -> StatusCode { } } +fn is_body_limit_error(err: &multer::Error) -> bool { + match err { + multer::Error::FieldSizeExceeded { .. } | multer::Error::StreamSizeExceeded { .. } => true, + multer::Error::StreamReadFailed(err) => { + if let Some(err) = err.downcast_ref::() { + return is_body_limit_error(err); + } + err.downcast_ref::() + .and_then(|err| err.source()) + .and_then(|err| err.downcast_ref::()) + .is_some() + } + _ => false, + } +} + impl IntoResponse for MultipartError { fn into_response(self) -> Response { (self.status(), self.body_text()).into_response() @@ -403,6 +423,7 @@ mod tests { let res = client.post("/").multipart(form).await; assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(res.text().await, "Request payload is too large"); } #[tokio::test] diff --git a/axum/src/extract/multipart.rs b/axum/src/extract/multipart.rs index 086033e0..081de75d 100644 --- a/axum/src/extract/multipart.rs +++ b/axum/src/extract/multipart.rs @@ -244,7 +244,11 @@ impl MultipartError { /// Get the response body text used for this rejection. #[must_use] pub fn body_text(&self) -> String { - self.source.to_string() + if is_body_limit_error(&self.source) { + "Request payload is too large".to_owned() + } else { + self.source.to_string() + } } /// Get the status code used for this rejection. @@ -289,6 +293,22 @@ fn status_code_from_multer_error(err: &multer::Error) -> StatusCode { } } +fn is_body_limit_error(err: &multer::Error) -> bool { + match err { + multer::Error::FieldSizeExceeded { .. } | multer::Error::StreamSizeExceeded { .. } => true, + multer::Error::StreamReadFailed(err) => { + if let Some(err) = err.downcast_ref::() { + return is_body_limit_error(err); + } + err.downcast_ref::() + .and_then(|err| err.source()) + .and_then(|err| err.downcast_ref::()) + .is_some() + } + _ => false, + } +} + impl fmt::Display for MultipartError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Error parsing `multipart/form-data` request") @@ -407,6 +427,7 @@ mod tests { let res = client.post("/").multipart(form).await; assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(res.text().await, "Request payload is too large"); } #[crate::test] From fc7e86c38d7b00c0e9488ca12b014f439ee6fdcf Mon Sep 17 00:00:00 2001 From: Tomaz Canabrava Date: Thu, 19 Mar 2026 12:42:00 +0100 Subject: [PATCH 18/28] Improve typed path by disallowing deprecated variable definitions (#3618) --- axum-extra/CHANGELOG.md | 4 ++++ axum-extra/src/routing/mod.rs | 39 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 85cb0b21..2d3c5210 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -9,8 +9,12 @@ and this project adheres to [Semantic Versioning]. - **fixed:** Escape backslashes and double quotes in `Content-Disposition` filenames to prevent header parameter injection in `Attachment` and `FileStream` ([#3664]) +- `vpath!` macro now stops the compilation if your path is using deprecated + path variables in the old `107` format, such as `:var` and `*var`. the + only allowed way now is `{var}`. ([#3618]) [#3664]: https://github.com/tokio-rs/axum/pull/3664 +[#3618]: https://github.com/tokio-rs/axum/pull/3618 # 0.12.5 diff --git a/axum-extra/src/routing/mod.rs b/axum-extra/src/routing/mod.rs index 5057302d..60012d33 100644 --- a/axum-extra/src/routing/mod.rs +++ b/axum-extra/src/routing/mod.rs @@ -36,6 +36,20 @@ pub const fn __private_validate_static_path(path: &'static str) -> &'static str if path.as_bytes()[0] != b'/' { panic!("Paths must start with /"); } + + // Checks if we have a path in 107 format. + let size: usize = path.len() - 1; + let mut curr: usize = 0; + let bytes = path.as_bytes(); + while curr < size { + if bytes[curr] == b'/' && (bytes[curr + 1] == b'*' || bytes[curr + 1] == b':') { + panic!( + "You have a path with a deprecated format, move your ':var' or '*var' to '{{var}}'" + ); + } + curr += 1; + } + path } @@ -61,12 +75,35 @@ pub const fn __private_validate_static_path(path: &'static str) -> &'static str /// use axum_extra::vpath; /// /// let router = axum::Router::<()>::new() -/// .route(vpath!("/valid_path"), get(root)) +/// .route(vpath!("/valid_path/{id}"), get(root)) /// .to_owned(); /// /// async fn root() {} /// ``` /// +/// It also checks for deprecated usage of variables within the path: +/// +/// ```compile_fail +/// use axum::routing::{Router, get}; +/// use axum_extra::vpath; +/// +/// let router = axum::Router::<()>::new() +/// .route(vpath!("/users/:id"), get(root)) +/// .to_owned(); +/// +/// async fn root() {} +/// ``` +/// +/// ```compile_fail +/// use axum::routing::{Router, get}; +/// use axum_extra::vpath; +/// +/// let router = axum::Router::<()>::new() +/// .route(vpath!("/users/*id"), get(root)) +/// .to_owned(); +/// +/// async fn root() {} +/// ``` /// This macro is available only on rust versions 1.80 and above. #[cfg_attr(docsrs, doc(cfg(feature = "routing")))] #[rustversion::since(1.80)] From 9fc59efc1fa9a11f4157cff1f2d22355f01d7bc0 Mon Sep 17 00:00:00 2001 From: tottoto Date: Thu, 19 Mar 2026 23:44:37 +0900 Subject: [PATCH 19/28] Update to tokio-tungstenite 0.29 (#3689) --- Cargo.lock | 36 +++++++++++++++++++++++--- axum/Cargo.toml | 4 +-- examples/testing-websockets/Cargo.toml | 2 +- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc7a9b43..304e6888 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,7 +327,7 @@ dependencies = [ "time", "tokio", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower 0.5.2", "tower-http 0.6.8", "tower-layer", @@ -1930,7 +1930,7 @@ dependencies = [ "axum", "futures", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", ] [[package]] @@ -2057,7 +2057,7 @@ dependencies = [ "futures-util", "headers", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower-http 0.6.8", "tracing", "tracing-subscriber", @@ -5395,7 +5395,19 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", ] [[package]] @@ -5668,6 +5680,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.16", +] + [[package]] name = "typed-builder" version = "0.20.1" diff --git a/axum/Cargo.toml b/axum/Cargo.toml index cbe70342..3a2967c3 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -83,7 +83,7 @@ serde_path_to_error = { version = "0.1.8", optional = true } serde_urlencoded = { version = "0.7", optional = true } sha1 = { version = "0.10", optional = true } tokio = { package = "tokio", version = "1.44", features = ["time"], optional = true } -tokio-tungstenite = { version = "0.28.0", optional = true } +tokio-tungstenite = { version = "0.29.0", optional = true } tracing = { version = "0.1", default-features = false, optional = true } # doc dependencies @@ -137,7 +137,7 @@ serde_json = { version = "1.0", features = ["raw_value"] } time = { version = "0.3", features = ["serde-human-readable"] } tokio = { package = "tokio", version = "1.44.2", features = ["macros", "rt", "rt-multi-thread", "net", "test-util"] } tokio-stream = "0.1" -tokio-tungstenite = "0.28.0" +tokio-tungstenite = "0.29.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json"] } uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/examples/testing-websockets/Cargo.toml b/examples/testing-websockets/Cargo.toml index 04233dc9..16f1962f 100644 --- a/examples/testing-websockets/Cargo.toml +++ b/examples/testing-websockets/Cargo.toml @@ -8,4 +8,4 @@ publish = false axum = { path = "../../axum", features = ["ws"] } futures = "0.3" tokio = { version = "1.0", features = ["full"] } -tokio-tungstenite = "0.28" +tokio-tungstenite = "0.29" From 26ba7bb6f21cf8996493481a5275c01152f0aaf9 Mon Sep 17 00:00:00 2001 From: kaze Date: Tue, 24 Mar 2026 10:13:40 +0100 Subject: [PATCH 20/28] docs: consolidate state management docs in crate root (#3683) Co-authored-by: Yann Simon --- axum-core/src/extract/from_ref.rs | 3 +- axum/src/docs/routing/with_state.md | 7 +++++ axum/src/extract/state.rs | 5 +++ axum/src/lib.rs | 47 +++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/axum-core/src/extract/from_ref.rs b/axum-core/src/extract/from_ref.rs index d38d121c..cda952af 100644 --- a/axum-core/src/extract/from_ref.rs +++ b/axum-core/src/extract/from_ref.rs @@ -1,13 +1,14 @@ /// Used to do reference-to-value conversions thus not consuming the input value. /// /// This is mainly used with [`State`] to extract "substates" from a reference to main application -/// state. +/// state. See ["Sharing state with handlers"][sharing-state] for a worked example. /// /// See [`State`] for more details on how library authors should use this trait. /// /// This trait can be derived using `#[derive(FromRef)]`. /// /// [`State`]: https://docs.rs/axum/0.8/axum/extract/struct.State.html +/// [sharing-state]: https://docs.rs/axum/0.8/axum/index.html#sharing-state-with-handlers // NOTE: This trait is defined in axum-core, even though it is mainly used with `State` which is // defined in axum. That allows crate authors to use it when implementing extractors. pub trait FromRef { diff --git a/axum/src/docs/routing/with_state.md b/axum/src/docs/routing/with_state.md index 1c25178d..53d1c4e4 100644 --- a/axum/src/docs/routing/with_state.md +++ b/axum/src/docs/routing/with_state.md @@ -1,6 +1,13 @@ Provide the state for the router. State passed to this method is global and will be used for all requests this router receives. That means it is not suitable for holding state derived from a request, such as authorization data extracted in a middleware. Use [`Extension`] instead for such data. +See ["Sharing state with handlers"][sharing-state] for an overview of state patterns, +including when to use `Arc`, how to extract substates with [`FromRef`], and what the +`Router` type parameter means. + +[sharing-state]: crate#sharing-state-with-handlers +[`FromRef`]: crate::extract::FromRef + ```rust use axum::{Router, routing::get, extract::State}; diff --git a/axum/src/extract/state.rs b/axum/src/extract/state.rs index b95deb39..effddf92 100644 --- a/axum/src/extract/state.rs +++ b/axum/src/extract/state.rs @@ -7,12 +7,17 @@ use std::{ /// Extractor for state. /// +/// See ["Sharing state with handlers"][sharing-state] for an overview of all approaches to +/// sharing state, including when to use `Arc`, how `FromRef` substates work, and what the +/// `Router` type parameter means. +/// /// See ["Accessing state in middleware"][state-from-middleware] for how to /// access state in middleware. /// /// State is global and used in every request a router with state receives. /// For accessing data derived from requests, such as authorization data, see [`Extension`]. /// +/// [sharing-state]: crate#sharing-state-with-handlers /// [state-from-middleware]: crate::middleware#accessing-state-in-middleware /// [`Extension`]: crate::Extension /// diff --git a/axum/src/lib.rs b/axum/src/lib.rs index fc3b1599..d75aa185 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -183,6 +183,51 @@ //! # let _: Router = app; //! ``` //! +//! State is cloned for every request. Wrapping your state in `Arc` makes those +//! clones cheap. If all fields are already cheap to clone (for example, each field +//! is itself an `Arc` or a copy type), you can `#[derive(Clone)]` directly on the +//! struct instead. +//! +//! ### Substates with `FromRef` +//! +//! When a handler only needs part of the application state, use [`FromRef`] to extract +//! a substate. Implement the trait manually, or derive it with `#[derive(FromRef)]` +//! (requires the `macros` feature): +//! +//! ```rust +//! use axum::{Router, routing::get, extract::{State, FromRef}}; +//! +//! #[derive(Clone)] +//! struct AppState { +//! api_state: ApiState, +//! } +//! +//! #[derive(Clone)] +//! struct ApiState {} +//! +//! // Teach axum how to produce an `ApiState` from a reference to `AppState`. +//! impl FromRef for ApiState { +//! fn from_ref(app_state: &AppState) -> ApiState { +//! app_state.api_state.clone() +//! } +//! } +//! +//! let app = Router::new() +//! .route("/", get(handler)) +//! .with_state(AppState { api_state: ApiState {} }); +//! +//! // This handler receives only the `ApiState` slice; it never sees `AppState`. +//! async fn handler(State(api_state): State) {} +//! # let _: Router = app; +//! ``` +//! +//! ### The `Router` type parameter +//! +//! `Router` when `S` is not `()` means a router that is _missing_ a state of type `S`. Calling +//! [`.with_state(s)`][Router::with_state] provides that state and typically produces a +//! `Router<()>`, which is the only form that can be passed to [`serve()`]. See +//! [`Router::with_state`] for a full explanation. +//! //! You should prefer using [`State`] if possible since it's more type safe. The downside is that //! it's less dynamic than task-local variables and request extensions. //! @@ -426,6 +471,8 @@ //! [load shed]: tower::load_shed //! [`axum-core`]: http://crates.io/crates/axum-core //! [`State`]: crate::extract::State +//! [`FromRef`]: crate::extract::FromRef +//! [`Router::with_state`]: crate::routing::Router::with_state #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(test, allow(clippy::float_cmp))] From a8790fc29b0db5708cdbcae70597d37c5afe1143 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 11:04:34 +0100 Subject: [PATCH 21/28] update release notes --- axum-core/CHANGELOG.md | 6 ++++++ axum-extra/CHANGELOG.md | 2 ++ axum/CHANGELOG.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/axum-core/CHANGELOG.md b/axum-core/CHANGELOG.md index d2b090ab..174534b9 100644 --- a/axum-core/CHANGELOG.md +++ b/axum-core/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# 0.5.6 + +- **fixed:** Fix IntoResponse for tuples overriding error response codes ([#3603]) + +[#3603]: https://github.com/tokio-rs/axum/pull/3603 + # 0.5.5 Released without changes to fix docs.rs build. diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 2d3c5210..ea7d2586 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -12,9 +12,11 @@ and this project adheres to [Semantic Versioning]. - `vpath!` macro now stops the compilation if your path is using deprecated path variables in the old `107` format, such as `:var` and `*var`. the only allowed way now is `{var}`. ([#3618]) +- **fixed:** Return specific error message when multipart body limit is exceeded ([#3611]) [#3664]: https://github.com/tokio-rs/axum/pull/3664 [#3618]: https://github.com/tokio-rs/axum/pull/3618 +[#3611]: https://github.com/tokio-rs/axum/pull/3611 # 0.12.5 diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index e54b2ab8..b3871a32 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **added:** `WebSocketUpgrade::{requested_protocols, set_selected_protocol}` for more flexible subprotocol selection ([#3597]) - **changed:** Update minimum rust version to 1.80 ([#3620]) +- **fixed:** Set connect endpoint on correct field in MethodRouter ([#3656]) +- **fixed:** Return specific error message when multipart body limit is exceeded ([#3611]) [#3597]: https://github.com/tokio-rs/axum/pull/3597 [#3620]: https://github.com/tokio-rs/axum/pull/3620 +[#3656]: https://github.com/tokio-rs/axum/pull/3656 +[#3611]: https://github.com/tokio-rs/axum/pull/3611 # 0.8.8 From c3fcebb38f356ccf96da158199d4e920aa8cfda3 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 11:09:26 +0100 Subject: [PATCH 22/28] axum-core 0.5.6 --- Cargo.lock | 2 +- axum-core/Cargo.toml | 2 +- axum-extra/Cargo.toml | 2 +- axum/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 304e6888..cf9b89b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,7 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" dependencies = [ "axum", "axum-extra", diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index b8b0195b..18749fd8 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" name = "axum-core" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.5.5" # remember to bump the version that axum and axum-extra depend on +version = "0.5.6" # remember to bump the version that axum and axum-extra depend on [features] tracing = ["dep:tracing"] diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 6662a762..5b36f07f 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -77,7 +77,7 @@ with-rejection = ["dep:axum"] __private_docs = ["axum/json", "dep:serde", "dep:tower"] [dependencies] -axum-core = { path = "../axum-core", version = "0.5.2" } +axum-core = { path = "../axum-core", version = "0.5.6" } bytes = "1.1.0" futures-core = "0.3" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 3a2967c3..c6c28a45 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -52,7 +52,7 @@ __private_docs = [ __private = ["tokio", "http1", "dep:reqwest"] [dependencies] -axum-core = { path = "../axum-core", version = "0.5.5" } +axum-core = { path = "../axum-core", version = "0.5.6" } bytes = "1.0" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } http = "1.0.0" From 0ec9041a1b903778a91a23558e064a83b43674c1 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 11:10:15 +0100 Subject: [PATCH 23/28] axum 0.8.9 --- Cargo.lock | 2 +- axum-extra/Cargo.toml | 2 +- axum/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf9b89b5..4d5dd081 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,7 +292,7 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" dependencies = [ "anyhow", "axum-core", diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 5b36f07f..8b3fd8f7 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -90,7 +90,7 @@ tower-layer = "0.3" tower-service = "0.3" # optional dependencies -axum = { path = "../axum", version = "0.8.8", default-features = false, optional = true } +axum = { path = "../axum", version = "0.8.9", default-features = false, optional = true } axum-macros = { path = "../axum-macros", version = "0.5.0", optional = true } cookie = { package = "cookie", version = "0.18.0", features = ["percent-encode"], optional = true } fastrand = { version = "2.1.0", optional = true } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index c6c28a45..25a8d50f 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "axum" -version = "0.8.8" # remember to bump the version that axum-extra depends on +version = "0.8.9" # remember to bump the version that axum-extra depends on categories = ["asynchronous", "network-programming", "web-programming::http-server"] description = "HTTP routing and request handling library that focuses on ergonomics and modularity" edition = "2021" From 6e9a249a4fa45507b1157e570f9b6ec58d71cb86 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 11:11:09 +0100 Subject: [PATCH 24/28] axum-extra 0.12.6 --- Cargo.lock | 2 +- axum-extra/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d5dd081..5ee4b50d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,7 +363,7 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.5" +version = "0.12.6" dependencies = [ "axum", "axum-core", diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 8b3fd8f7..35bf15bc 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" name = "axum-extra" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.12.5" +version = "0.12.6" [features] default = ["tracing"] From e8a39ad416d1ee4f61249904309691909db2db09 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Wed, 25 Mar 2026 11:12:15 +0100 Subject: [PATCH 25/28] axum-macros 0.5.1 --- Cargo.lock | 2 +- axum-extra/Cargo.toml | 2 +- axum-macros/Cargo.toml | 2 +- axum/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ee4b50d..4ca4ef5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,7 +406,7 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" dependencies = [ "axum", "axum-extra", diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 35bf15bc..905dc0a9 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -91,7 +91,7 @@ tower-service = "0.3" # optional dependencies axum = { path = "../axum", version = "0.8.9", default-features = false, optional = true } -axum-macros = { path = "../axum-macros", version = "0.5.0", optional = true } +axum-macros = { path = "../axum-macros", version = "0.5.1", optional = true } cookie = { package = "cookie", version = "0.18.0", features = ["percent-encode"], optional = true } fastrand = { version = "2.1.0", optional = true } form_urlencoded = { version = "1.1.0", optional = true } diff --git a/axum-macros/Cargo.toml b/axum-macros/Cargo.toml index 1f433c27..21feffff 100644 --- a/axum-macros/Cargo.toml +++ b/axum-macros/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" name = "axum-macros" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.5.0" # remember to also bump the version that axum and axum-extra depends on +version = "0.5.1" # remember to also bump the version that axum and axum-extra depends on [features] default = [] diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 25a8d50f..413a5193 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -71,7 +71,7 @@ tower-layer = "0.3.2" tower-service = "0.3" # optional dependencies -axum-macros = { path = "../axum-macros", version = "0.5.0", optional = true } +axum-macros = { path = "../axum-macros", version = "0.5.1", optional = true } base64 = { version = "0.22.1", optional = true } form_urlencoded = { version = "1.1.0", optional = true } hyper = { version = "1.1.0", optional = true } From 23d7098691871ccec71ca17ea31d1d40b036c0d0 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Mon, 6 Apr 2026 18:46:13 +0200 Subject: [PATCH 26/28] Revert "axum-core 0.5.6" This reverts commit 8c6bd2dfec3e508fdcbf8de823357bb5ed71ec13. --- Cargo.lock | 2 +- axum-core/Cargo.toml | 2 +- axum-extra/Cargo.toml | 2 +- axum/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ca4ef5e..3b996773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,7 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.6" +version = "0.5.5" dependencies = [ "axum", "axum-extra", diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index 18749fd8..b8b0195b 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" name = "axum-core" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.5.6" # remember to bump the version that axum and axum-extra depend on +version = "0.5.5" # remember to bump the version that axum and axum-extra depend on [features] tracing = ["dep:tracing"] diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 905dc0a9..fdc98a33 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -77,7 +77,7 @@ with-rejection = ["dep:axum"] __private_docs = ["axum/json", "dep:serde", "dep:tower"] [dependencies] -axum-core = { path = "../axum-core", version = "0.5.6" } +axum-core = { path = "../axum-core", version = "0.5.2" } bytes = "1.1.0" futures-core = "0.3" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 413a5193..f0c216fc 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -52,7 +52,7 @@ __private_docs = [ __private = ["tokio", "http1", "dep:reqwest"] [dependencies] -axum-core = { path = "../axum-core", version = "0.5.6" } +axum-core = { path = "../axum-core", version = "0.5.5" } bytes = "1.0" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } http = "1.0.0" From 99068f5a4b309d0966777eb6e5a8ce924f204e6d Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Mon, 6 Apr 2026 18:47:42 +0200 Subject: [PATCH 27/28] Revert "Fix `IntoResponse` for tuples overriding error response codes (#3603)" This reverts commit 0e961504c27d3b43af0147023d8644cce116b025. --- Cargo.lock | 1 - axum-core/Cargo.toml | 1 - axum-core/src/response/into_response.rs | 87 ++---- axum-core/src/response/into_response_parts.rs | 20 +- axum-core/src/response/mod.rs | 88 ------ axum-extra/src/protobuf.rs | 9 +- axum-extra/src/response/erased_json.rs | 9 +- axum/src/form.rs | 9 +- axum/src/json.rs | 3 +- axum/src/response/mod.rs | 292 +----------------- 10 files changed, 39 insertions(+), 480 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b996773..08fa9e53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,7 +352,6 @@ dependencies = [ "hyper 1.7.0", "mime", "pin-project-lite", - "serde", "sync_wrapper 1.0.2", "tokio", "tower-http 0.6.8", diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index b8b0195b..7667a3fc 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -38,7 +38,6 @@ axum = { path = "../axum", features = ["__private"] } axum-extra = { path = "../axum-extra", features = ["typed-header"] } axum-macros = { path = "../axum-macros", features = ["__private"] } hyper = "1.0.0" -serde = { version = "1.0.200", features = ["derive"] } tokio = { version = "1.25.0", features = ["macros"] } tower-http = { version = "0.6.0", features = ["limit"] } diff --git a/axum-core/src/response/into_response.rs b/axum-core/src/response/into_response.rs index a311851c..915b55ef 100644 --- a/axum-core/src/response/into_response.rs +++ b/axum-core/src/response/into_response.rs @@ -1,4 +1,4 @@ -use super::{ForceStatusCode, IntoResponseFailed, IntoResponseParts, Response, ResponseParts}; +use super::{IntoResponseParts, Response, ResponseParts}; use crate::{body::Body, BoxError}; use bytes::{buf::Chain, Buf, Bytes, BytesMut}; use http::{ @@ -329,9 +329,7 @@ where { fn into_response(self) -> Response { let mut res = self.1.into_response(); - if res.extensions().get::().is_none() { - *res.status_mut() = self.0; - } + *res.status_mut() = self.0; res } } @@ -407,16 +405,18 @@ macro_rules! impl_into_response { let ($($ty),*, res) = self; let res = res.into_response(); - if res.extensions().get::().is_none() { - let parts = ResponseParts { res }; - let parts = match ($($ty,)*).into_response_parts(parts) { + let parts = ResponseParts { res }; + + $( + let parts = match $ty.into_response_parts(parts) { Ok(parts) => parts, - Err(err) => return err.into_response(), + Err(err) => { + return err.into_response(); + } }; - parts.res - } else { - res - } + )* + + parts.res } } @@ -429,41 +429,17 @@ macro_rules! impl_into_response { fn into_response(self) -> Response { let (status, $($ty),*, res) = self; - let res = res.into_response(); - if res.extensions().get::().is_none() { - let parts = ResponseParts { res }; - let mut parts = match ($($ty,)*).into_response_parts(parts) { - Ok(parts) => parts, - Err(err) => return err.into_response(), - }; - - // Don't call `(status, parts.res).into_response()` since that checks for - // `IntoResponseFailed` and skips setting the status. We've already done that - // check here so overriding the status is required if returning - // `(IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR)` - *parts.res.status_mut() = status; - parts.res - } else { - res - } - } - } - - #[allow(non_snake_case)] - impl IntoResponse for (ForceStatusCode, $($ty),*, R) - where - $( $ty: IntoResponseParts, )* - R: IntoResponse, - { - fn into_response(self) -> Response { - let (status, $($ty),*, res) = self; - let res = res.into_response(); let parts = ResponseParts { res }; - let parts = match ($($ty,)*).into_response_parts(parts) { - Ok(parts) => parts, - Err(err) => return err.into_response(), - }; + + $( + let parts = match $ty.into_response_parts(parts) { + Ok(parts) => parts, + Err(err) => { + return err.into_response(); + } + }; + )* (status, parts.res).into_response() } @@ -479,22 +455,17 @@ macro_rules! impl_into_response { let (outer_parts, $($ty),*, res) = self; let res = res.into_response(); - if res.extensions().get::().is_none() { - let parts = ResponseParts { res }; - let mut parts = match ($($ty,)*).into_response_parts(parts) { + let parts = ResponseParts { res }; + $( + let parts = match $ty.into_response_parts(parts) { Ok(parts) => parts, - Err(err) => return err.into_response(), + Err(err) => { + return err.into_response(); + } }; + )* - // Don't call `(outer_parts, parts.res).into_response()` for the same reason we - // don't call `(status, parts.res).into_response()` in the above impl. - *parts.res.status_mut() = outer_parts.status; - parts.res.headers_mut().extend(outer_parts.headers); - parts.res.extensions_mut().extend(outer_parts.extensions); - parts.res - } else { - res - } + (outer_parts, parts.res).into_response() } } diff --git a/axum-core/src/response/into_response_parts.rs b/axum-core/src/response/into_response_parts.rs index a142b50b..95564823 100644 --- a/axum-core/src/response/into_response_parts.rs +++ b/axum-core/src/response/into_response_parts.rs @@ -241,9 +241,7 @@ macro_rules! impl_into_response_parts { let res = match $ty.into_response_parts(res) { Ok(res) => res, Err(err) => { - let mut err_res = err.into_response(); - err_res.extensions_mut().insert(super::IntoResponseFailed); - return Err(err_res); + return Err(err.into_response()); } }; )* @@ -272,19 +270,3 @@ impl IntoResponseParts for () { Ok(res) } } - -#[cfg(test)] -mod tests { - use http::StatusCode; - - use crate::response::IntoResponse; - - #[test] - fn failed_into_response_parts() { - let response = (StatusCode::CREATED, [("\n", "\n")]).into_response(); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - - let response = (StatusCode::CREATED, [("\n", "\n")], ()).into_response(); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } -} diff --git a/axum-core/src/response/mod.rs b/axum-core/src/response/mod.rs index b00bc85e..b40b2529 100644 --- a/axum-core/src/response/mod.rs +++ b/axum-core/src/response/mod.rs @@ -4,10 +4,6 @@ //! //! [`axum::response`]: https://docs.rs/axum/0.8/axum/response/index.html -use std::convert::Infallible; - -use http::StatusCode; - use crate::body::Body; mod append_headers; @@ -132,87 +128,3 @@ where Self(value.into_response()) } } - -/// Response part that stops status code overrides. -/// -/// This type should be used by types implementing [`IntoResponseParts`] or -/// [`IntoResponse`] when they fail to produce the response usually expected of -/// them and return some sort of error response instead. -/// -/// It is checked used by the tuple impls of [`IntoResponse`] that have a -/// [`StatusCode`] as their first element to ignore that status code. -/// Consider the following example: -/// -/// ```no_run -/// # use axum::Json; -/// # use http::StatusCode; -/// # #[derive(serde::Serialize)] -/// # struct CreatedResponse { } -/// fn my_handler(/* ... */) -> (StatusCode, Json) { -/// // This response type's serialization may fail -/// let response = CreatedResponse { /* ... */ }; -/// (StatusCode::CREATED, Json(response)) -/// } -/// ``` -/// -/// When `response` serialization succeeds, the server responds with a status -/// code of 201 Created (overwriting `Json`s default status code of 200 OK), -/// and the expected JSON payload. -/// -/// When `response` serialization fails hoewever, `impl IntoResponse for Json` -/// return a response with status code 500 Internal Server Error, and -/// `IntoResponseFailed` as a response extension, and the 201 Created override -/// is ignored. -/// -/// This is a behavior introduced with axum 0.9.\ -/// To force a status code override even when an inner [`IntoResponseParts`] / -/// [`IntoResponse`] failed, use [`ForceStatusCode`]. -#[derive(Copy, Clone, Debug)] -pub struct IntoResponseFailed; - -impl IntoResponseParts for IntoResponseFailed { - type Error = Infallible; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.extensions_mut().insert(self); - Ok(res) - } -} - -/// Not sure it makes sense to return `IntoResponseFailed` as the whole response. You should -/// probably at least combine it with a status code. -/// -/// ```compile_fail -/// fn foo() -/// where -/// axum_core::response::IntoResponseFailed: axum_core::response::IntoResponse, -/// {} -/// ``` -#[allow(dead_code)] -fn into_response_failed_doesnt_impl_into_response() {} - -/// Set the status code regardless of whether [`IntoResponseFailed`] is used or not. -/// -/// See the docs for [`IntoResponseFailed`] for more details. -#[derive(Debug, Copy, Clone, Default)] -pub struct ForceStatusCode(pub StatusCode); - -impl IntoResponse for ForceStatusCode { - fn into_response(self) -> Response { - let mut res = ().into_response(); - *res.status_mut() = self.0; - res - } -} - -impl IntoResponse for (ForceStatusCode, R) -where - R: IntoResponse, -{ - fn into_response(self) -> Response { - let (ForceStatusCode(status), res) = self; - let mut res = res.into_response(); - *res.status_mut() = status; - res - } -} diff --git a/axum-extra/src/protobuf.rs b/axum-extra/src/protobuf.rs index aa8991ce..cc68af2d 100644 --- a/axum-extra/src/protobuf.rs +++ b/axum-extra/src/protobuf.rs @@ -4,7 +4,7 @@ use axum_core::__composite_rejection as composite_rejection; use axum_core::__define_rejection as define_rejection; use axum_core::{ extract::{rejection::BytesRejection, FromRequest, Request}, - response::{IntoResponse, IntoResponseFailed, Response}, + response::{IntoResponse, Response}, RequestExt, }; use bytes::BytesMut; @@ -131,12 +131,7 @@ where let mut buf = BytesMut::with_capacity(self.0.encoded_len()); match &self.0.encode(&mut buf) { Ok(()) => buf.into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - IntoResponseFailed, - err.to_string(), - ) - .into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), } } } diff --git a/axum-extra/src/response/erased_json.rs b/axum-extra/src/response/erased_json.rs index def3d2c1..17d8967b 100644 --- a/axum-extra/src/response/erased_json.rs +++ b/axum-extra/src/response/erased_json.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; +use axum_core::response::{IntoResponse, Response}; use bytes::{BufMut, Bytes, BytesMut}; use http::{header, HeaderValue, StatusCode}; use serde_core::Serialize; @@ -78,12 +78,7 @@ impl IntoResponse for ErasedJson { bytes, ) .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - IntoResponseFailed, - err.to_string(), - ) - .into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), } } } diff --git a/axum/src/form.rs b/axum/src/form.rs index 50a532e4..ab692a64 100644 --- a/axum/src/form.rs +++ b/axum/src/form.rs @@ -1,6 +1,6 @@ use crate::extract::Request; use crate::extract::{rejection::*, FromRequest, RawForm}; -use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; +use axum_core::response::{IntoResponse, Response}; use axum_core::RequestExt; use http::header::CONTENT_TYPE; use http::StatusCode; @@ -117,12 +117,7 @@ where body, ) .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - IntoResponseFailed, - err.to_string(), - ) - .into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), } } diff --git a/axum/src/json.rs b/axum/src/json.rs index 90ea13ae..59f2c859 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -1,7 +1,7 @@ use crate::extract::Request; use crate::extract::{rejection::*, FromRequest}; use axum_core::extract::OptionalFromRequest; -use axum_core::response::{IntoResponse, IntoResponseFailed, Response}; +use axum_core::response::{IntoResponse, Response}; use bytes::{BufMut, Bytes, BytesMut}; use http::{ header::{self, HeaderMap, HeaderValue}, @@ -224,7 +224,6 @@ where header::CONTENT_TYPE, HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), )], - IntoResponseFailed, err.to_string(), ) .into_response(), diff --git a/axum/src/response/mod.rs b/axum/src/response/mod.rs index 6307610f..70be7452 100644 --- a/axum/src/response/mod.rs +++ b/axum/src/response/mod.rs @@ -19,8 +19,7 @@ pub use crate::Extension; #[doc(inline)] pub use axum_core::response::{ - AppendHeaders, ErrorResponse, IntoResponse, IntoResponseFailed, IntoResponseParts, Response, - ResponseParts, Result, + AppendHeaders, ErrorResponse, IntoResponse, IntoResponseParts, Response, ResponseParts, Result, }; #[doc(inline)] @@ -86,16 +85,10 @@ impl IntoResponse for NoContent { #[cfg(test)] mod tests { use crate::extract::Extension; - use crate::test_helpers::*; - use crate::Json; use crate::{routing::get, Router}; - use axum_core::response::ForceStatusCode; - use axum_core::response::{ - IntoResponse, IntoResponseFailed, IntoResponseParts, Response, ResponseParts, - }; + use axum_core::response::IntoResponse; use http::HeaderMap; use http::{StatusCode, Uri}; - use std::collections::HashMap; // just needs to compile #[allow(dead_code)] @@ -254,287 +247,6 @@ mod tests { .route("/", get(header_array_extension_mixed_body)); } - #[test] - fn status_code_tuple_doesnt_override_error() { - // sanity check where there is just one status code - assert_eq!( - StatusCode::INTERNAL_SERVER_ERROR.into_response().status(), - StatusCode::INTERNAL_SERVER_ERROR - ); - assert_eq!( - (StatusCode::INTERNAL_SERVER_ERROR,) - .into_response() - .status(), - StatusCode::INTERNAL_SERVER_ERROR - ); - - // non-5xx status should be changed - assert_eq!( - (StatusCode::SEE_OTHER, StatusCode::NO_CONTENT) - .into_response() - .status(), - StatusCode::SEE_OTHER - ); - let res = ( - StatusCode::SEE_OTHER, - [("location", "foo")], - StatusCode::NO_CONTENT, - ) - .into_response(); - assert_eq!(res.status(), StatusCode::SEE_OTHER); - assert_eq!(res.headers()["location"], "foo"); - - // 5xx status codes are also changed - assert_eq!( - (StatusCode::SEE_OTHER, StatusCode::INTERNAL_SERVER_ERROR) - .into_response() - .status(), - StatusCode::SEE_OTHER - ); - let res = ( - StatusCode::SEE_OTHER, - [("location", "foo")], - StatusCode::INTERNAL_SERVER_ERROR, - ) - .into_response(); - assert_eq!(res.status(), StatusCode::SEE_OTHER); - assert_eq!(res.headers()["location"], "foo"); - - // the status is not changed if `IntoResponseFailed` is used - assert_eq!( - ( - StatusCode::SEE_OTHER, - (IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR) - ) - .into_response() - .status(), - StatusCode::INTERNAL_SERVER_ERROR - ); - let res = ( - StatusCode::SEE_OTHER, - [("location", "foo")], - (IntoResponseFailed, StatusCode::INTERNAL_SERVER_ERROR), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(res.headers().get("location").is_none()); - - // response parts from the inner response do run - let res = ( - // with status override - StatusCode::SEE_OTHER, - [("location", "foo")], - ( - [("x-bar", "bar")], - IntoResponseFailed, - [("x-foo", "foo")], - StatusCode::INTERNAL_SERVER_ERROR, - ), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(res.headers().get("location").is_none()); - assert_eq!(res.headers()["x-foo"], "foo"); - assert_eq!(res.headers()["x-bar"], "bar"); - - let res = ( - // without status override - [("location", "foo")], - ( - [("x-bar", "bar")], - IntoResponseFailed, - [("x-foo", "foo")], - StatusCode::INTERNAL_SERVER_ERROR, - ), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(res.headers().get("location").is_none()); - assert_eq!(res.headers()["x-foo"], "foo"); - assert_eq!(res.headers()["x-bar"], "bar"); - - // (Parts, ...) - let res = ( - Response::new(()).into_parts().0, - [("location", "foo")], - ( - [("x-bar", "bar")], - IntoResponseFailed, - [("x-foo", "foo")], - StatusCode::INTERNAL_SERVER_ERROR, - ), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(res.headers().get("location").is_none()); - assert_eq!(res.headers()["x-foo"], "foo"); - assert_eq!(res.headers()["x-bar"], "bar"); - - // (Response<()>, ...) - let res = ( - Response::new(()), - [("location", "foo")], - ( - [("x-bar", "bar")], - IntoResponseFailed, - [("x-foo", "foo")], - StatusCode::INTERNAL_SERVER_ERROR, - ), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(res.headers().get("location").is_none()); - assert_eq!(res.headers()["x-foo"], "foo"); - assert_eq!(res.headers()["x-bar"], "bar"); - } - - #[test] - fn into_response_parts_failing_sets_extension() { - struct Fail; - - impl IntoResponseParts for Fail { - type Error = (); - - fn into_response_parts( - self, - _res: ResponseParts, - ) -> Result { - Err(()) - } - } - - impl IntoResponse for Fail { - fn into_response(self) -> Response { - (self, ()).into_response() - } - } - - assert!(Fail - .into_response() - .extensions() - .get::() - .is_some()); - - assert!((StatusCode::INTERNAL_SERVER_ERROR, Fail, ()) - .into_response() - .extensions() - .get::() - .is_some()); - - assert!((Response::new(()).into_parts().0, Fail, ()) - .into_response() - .extensions() - .get::() - .is_some()); - - assert!((Response::new(()), Fail, ()) - .into_response() - .extensions() - .get::() - .is_some()); - } - - #[test] - fn doenst_override_status_code_when_using_into_response_failed_at_same_level() { - assert_eq!( - (StatusCode::INTERNAL_SERVER_ERROR, IntoResponseFailed, ()) - .into_response() - .status(), - StatusCode::INTERNAL_SERVER_ERROR, - ); - - #[derive(Clone)] - struct Thing; - - let res = ( - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("x-foo", "foo") - .extension(Thing) - .body(()) - .unwrap() - .into_parts() - .0, - IntoResponseFailed, - (), - ) - .into_response(); - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(res.headers()["x-foo"], "foo"); - assert!(res.extensions().get::().is_some()); - - // just a sanity check - assert_eq!( - (IntoResponseFailed, ()).into_response().status(), - StatusCode::OK, - ); - } - - #[test] - fn force_overriding_status_code() { - assert_eq!( - ForceStatusCode(StatusCode::IM_A_TEAPOT) - .into_response() - .status(), - StatusCode::IM_A_TEAPOT - ); - - assert_eq!( - (ForceStatusCode(StatusCode::IM_A_TEAPOT),) - .into_response() - .status(), - StatusCode::IM_A_TEAPOT - ); - - assert_eq!( - (ForceStatusCode(StatusCode::IM_A_TEAPOT), ()) - .into_response() - .status(), - StatusCode::IM_A_TEAPOT - ); - - assert_eq!( - ( - ForceStatusCode(StatusCode::IM_A_TEAPOT), - IntoResponseFailed, - StatusCode::INTERNAL_SERVER_ERROR, - ) - .into_response() - .status(), - StatusCode::IM_A_TEAPOT - ); - } - - #[crate::test] - async fn status_code_tuple_doesnt_override_error_json() { - let app = Router::new() - .route( - "/", - get(|| async { - let not_json_compatible = HashMap::from([(Vec::from([1, 2, 3]), 123)]); - (StatusCode::IM_A_TEAPOT, Json(not_json_compatible)) - }), - ) - .route( - "/two", - get(|| async { - let not_json_compatible = HashMap::from([(Vec::from([1, 2, 3]), 123)]); - ( - ForceStatusCode(StatusCode::IM_A_TEAPOT), - Json(not_json_compatible), - ) - }), - ); - - let client = TestClient::new(app); - - let res = client.get("/").await; - assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - - let res = client.get("/two").await; - assert_eq!(res.status(), StatusCode::IM_A_TEAPOT); - } - #[test] fn no_content() { assert_eq!( From c59208c86fded335cd85e388030ad59347b0e5ae Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Mon, 6 Apr 2026 18:57:37 +0200 Subject: [PATCH 28/28] revert axum-core changelog changes --- axum-core/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/axum-core/CHANGELOG.md b/axum-core/CHANGELOG.md index 174534b9..d2b090ab 100644 --- a/axum-core/CHANGELOG.md +++ b/axum-core/CHANGELOG.md @@ -5,12 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -# 0.5.6 - -- **fixed:** Fix IntoResponse for tuples overriding error response codes ([#3603]) - -[#3603]: https://github.com/tokio-rs/axum/pull/3603 - # 0.5.5 Released without changes to fix docs.rs build.