From 1018568b0e3695cfa1db505f8d1f9a0ce4800087 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:08:49 +0200 Subject: [PATCH 1/8] refactor: decouple JSON extraction rejection from axum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `JsonExtractionRejection` as an s2-api-owned rejection type, replacing direct usage of `axum::extract::rejection::JsonRejection` in the public API. This gives us control over the error boundary without coupling consumers to axum's internal rejection types. The `FromRequest` impl still delegates to `axum::Json` internally — the only change is that the rejection is converted at the boundary via `From`. Zero behavioral change, zero performance impact on the success path. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 175 +++++++++++++++++++++++++++++++--- api/src/v1/stream/extract.rs | 10 +- lite/src/handlers/v1/error.rs | 6 +- 3 files changed, 170 insertions(+), 21 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index f612239e..3572ddbb 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -99,25 +99,102 @@ pub struct S2FormatHeader { #[cfg(feature = "axum")] pub mod extract { use axum::{ - extract::{ - FromRequest, OptionalFromRequest, Request, - rejection::{BytesRejection, JsonRejection}, - }, + extract::{FromRequest, OptionalFromRequest, Request, rejection::BytesRejection}, response::{IntoResponse, Response}, }; use bytes::Bytes; use serde::de::DeserializeOwned; + /// JSON extraction rejection type owned by s2-api. + /// + /// Decouples consumers from `axum::extract::rejection::JsonRejection` so the + /// underlying deserializer can be swapped without changing downstream code. + #[derive(Debug)] + #[non_exhaustive] + pub enum JsonExtractionRejection { + SyntaxError { + status: http::StatusCode, + message: String, + }, + DataError { + status: http::StatusCode, + message: String, + }, + MissingContentType, + Other { + status: http::StatusCode, + message: String, + }, + } + + impl JsonExtractionRejection { + pub fn body_text(&self) -> String { + self.to_string() + } + + pub fn status(&self) -> http::StatusCode { + match self { + Self::SyntaxError { status, .. } => *status, + Self::DataError { status, .. } => *status, + Self::MissingContentType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Other { status, .. } => *status, + } + } + } + + impl std::fmt::Display for JsonExtractionRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SyntaxError { message, .. } => f.write_str(message), + Self::DataError { message, .. } => f.write_str(message), + Self::MissingContentType => { + f.write_str("Expected request with `Content-Type: application/json`") + } + Self::Other { message, .. } => f.write_str(message), + } + } + } + + impl std::error::Error for JsonExtractionRejection {} + + impl IntoResponse for JsonExtractionRejection { + fn into_response(self) -> Response { + (self.status(), self.body_text()).into_response() + } + } + + impl From for JsonExtractionRejection { + fn from(rej: axum::extract::rejection::JsonRejection) -> Self { + use axum::extract::rejection::JsonRejection::*; + match rej { + JsonDataError(e) => Self::DataError { + status: e.status(), + message: e.body_text(), + }, + JsonSyntaxError(e) => Self::SyntaxError { + status: e.status(), + message: e.body_text(), + }, + MissingJsonContentType(_) => Self::MissingContentType, + other => Self::Other { + status: other.status(), + message: other.body_text(), + }, + } + } + } + impl FromRequest for super::Json where S: Send + Sync, T: DeserializeOwned, { - type Rejection = JsonRejection; + type Rejection = JsonExtractionRejection; async fn from_request(req: Request, state: &S) -> Result { - let axum::Json(value) = - as FromRequest>::from_request(req, state).await?; + let axum::Json(value) = as FromRequest>::from_request(req, state) + .await + .map_err(JsonExtractionRejection::from)?; Ok(Self(value)) } } @@ -127,7 +204,7 @@ pub mod extract { S: Send + Sync, T: DeserializeOwned, { - type Rejection = JsonRejection; + type Rejection = JsonExtractionRejection; async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else { @@ -137,15 +214,20 @@ pub mod extract { .as_ref() .is_some_and(crate::mime::is_json) { - Err(JsonRejection::MissingJsonContentType(Default::default()))?; + return Err(JsonExtractionRejection::MissingContentType); } - let bytes = Bytes::from_request(req, state) - .await - .map_err(JsonRejection::BytesRejection)?; + let bytes = Bytes::from_request(req, state).await.map_err(|e| { + JsonExtractionRejection::Other { + status: e.status(), + message: e.body_text(), + } + })?; if bytes.is_empty() { return Ok(None); } - let value = axum::Json::::from_bytes(&bytes)?.0; + let value = axum::Json::::from_bytes(&bytes) + .map_err(JsonExtractionRejection::from)? + .0; Ok(Some(Self(value))) } } @@ -159,7 +241,7 @@ pub mod extract { S: Send + Sync, T: DeserializeOwned, { - type Rejection = JsonRejection; + type Rejection = JsonExtractionRejection; async fn from_request(req: Request, state: &S) -> Result { match as OptionalFromRequest>::from_request(req, state).await { @@ -203,4 +285,69 @@ pub mod extract { Ok(super::Proto(T::decode(bytes)?)) } } + + /// Classify a JSON deserialization error via axum's pipeline and return the + /// status code from our rejection type. Used in tests to verify error + /// classification is consistent. + pub fn classify_json_error( + json: &[u8], + ) -> Result { + axum::Json::::from_bytes(json) + .map(|axum::Json(v)| v) + .map_err(JsonExtractionRejection::from) + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::v1::stream::AppendInput; + + /// Verify that our rejection wrapper preserves axum's status code + /// classification for a variety of invalid JSON payloads. This same + /// table will be reused when switching to sonic-rs in PR 2. + #[test] + fn json_error_classification() { + let cases: &[(&[u8], http::StatusCode)] = &[ + // Syntax errors → 400 + (b"not json", http::StatusCode::BAD_REQUEST), + // `{}` is valid JSON but missing `records` — axum reports data error + // before checking trailing chars. + (b"{} trailing", http::StatusCode::UNPROCESSABLE_ENTITY), + (b"", http::StatusCode::BAD_REQUEST), + (b"{truncated", http::StatusCode::BAD_REQUEST), + // Data errors → 422 + (b"{}", http::StatusCode::UNPROCESSABLE_ENTITY), + ( + br#"{"records": "nope"}"#, + http::StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + br#"{"records": [{"body": 123}]}"#, + http::StatusCode::UNPROCESSABLE_ENTITY, + ), + ]; + + for (input, expected_status) in cases { + let err = classify_json_error::(input).expect_err(&format!( + "expected error for {:?}", + String::from_utf8_lossy(input) + )); + assert_eq!( + err.status(), + *expected_status, + "wrong status for {:?}: got {}, body: {}", + String::from_utf8_lossy(input), + err.status(), + err.body_text(), + ); + } + } + + #[test] + fn valid_json_parses_successfully() { + let input = br#"{"records": [], "match_seq_num": null}"#; + let result = classify_json_error::(input); + assert!(result.is_ok()); + } + } } diff --git a/api/src/v1/stream/extract.rs b/api/src/v1/stream/extract.rs index 7b9ce684..d531c463 100644 --- a/api/src/v1/stream/extract.rs +++ b/api/src/v1/stream/extract.rs @@ -1,6 +1,5 @@ use axum::{ - Json, - extract::{FromRequest, FromRequestParts, Request, rejection::JsonRejection}, + extract::{FromRequest, FromRequestParts, Request}, response::{IntoResponse, Response}, }; use futures::StreamExt as _; @@ -13,7 +12,10 @@ use tokio_util::{codec::FramedRead, io::StreamReader}; use super::{AppendInput, AppendInputStreamError, AppendRequest, ReadRequest, proto, s2s}; use crate::{ - data::{Format, Proto, extract::ProtoRejection}, + data::{ + Format, Json, Proto, + extract::{JsonExtractionRejection, ProtoRejection}, + }, mime::JsonOrProto, v1::stream::sse::LastEventId, }; @@ -23,7 +25,7 @@ pub enum AppendRequestRejection { #[error(transparent)] HeaderRejection(#[from] HeaderRejection), #[error(transparent)] - JsonRejection(#[from] JsonRejection), + JsonRejection(#[from] JsonExtractionRejection), #[error(transparent)] ProtoRejection(#[from] ProtoRejection), #[error(transparent)] diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index d983d546..c845b053 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -1,9 +1,9 @@ use axum::{ - extract::rejection::{JsonRejection, PathRejection, QueryRejection}, + extract::rejection::{PathRejection, QueryRejection}, response::{IntoResponse, Response}, }; use s2_api::{ - data::extract::ProtoRejection, + data::extract::{JsonExtractionRejection, ProtoRejection}, v1::{ self as v1t, error::{ErrorCode, ErrorInfo, ErrorResponse, StandardError}, @@ -27,7 +27,7 @@ pub enum ServiceError { #[error(transparent)] QueryRejection(#[from] QueryRejection), #[error(transparent)] - JsonRejection(#[from] JsonRejection), + JsonRejection(#[from] JsonExtractionRejection), #[error(transparent)] ProtoRejection(#[from] ProtoRejection), #[error(transparent)] From 27237f1c650b99627a989ff84725e43991abd9f6 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:15:26 +0200 Subject: [PATCH 2/8] fix: restrict classify_json_error to test-only, fix dns test - Move `classify_json_error` into `#[cfg(test)]` module to avoid leaking axum-coupled helper through the public API (greptile review feedback) - Install rustls CryptoProvider in `dns_error_message_is_clear` test to fix pre-existing panic with rustls 0.23+ Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 19 ++++++++----------- sdk/src/api.rs | 1 + 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index 3572ddbb..a56fdf93 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -286,22 +286,19 @@ pub mod extract { } } - /// Classify a JSON deserialization error via axum's pipeline and return the - /// status code from our rejection type. Used in tests to verify error - /// classification is consistent. - pub fn classify_json_error( - json: &[u8], - ) -> Result { - axum::Json::::from_bytes(json) - .map(|axum::Json(v)| v) - .map_err(JsonExtractionRejection::from) - } - #[cfg(test)] mod tests { use super::*; use crate::v1::stream::AppendInput; + fn classify_json_error( + json: &[u8], + ) -> Result { + axum::Json::::from_bytes(json) + .map(|axum::Json(v)| v) + .map_err(JsonExtractionRejection::from) + } + /// Verify that our rejection wrapper preserves axum's status code /// classification for a variety of invalid JSON payloads. This same /// table will be reused when switching to sonic-rs in PR 2. diff --git a/sdk/src/api.rs b/sdk/src/api.rs index 59b054b4..4d409424 100644 --- a/sdk/src/api.rs +++ b/sdk/src/api.rs @@ -1247,6 +1247,7 @@ mod tests { #[tokio::test] async fn dns_error_message_is_clear() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let config = crate::types::S2Config::new("test-token".to_owned()) .with_endpoints( crate::types::S2Endpoints::new( From 8d8c954ebbcb93e9745ef71e9c02d36403c86d53 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:25:04 +0200 Subject: [PATCH 3/8] fix: flatten rejection variants to avoid double allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All variants now store `{ status, message }` uniformly. The message is captured once in the `From` bridge and reused directly in `body_text()`, `Display`, and `IntoResponse` — no re-allocation. `IntoResponse` moves the String instead of cloning. `body_text()` does one clone (matches axum's existing behavior where consumers call it). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 57 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index a56fdf93..49166a64 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -109,6 +109,11 @@ pub mod extract { /// /// Decouples consumers from `axum::extract::rejection::JsonRejection` so the /// underlying deserializer can be swapped without changing downstream code. + /// JSON extraction rejection type owned by s2-api. + /// + /// Every variant stores its status code and pre-rendered message so that + /// `body_text()`, `Display`, and `IntoResponse` all return the same + /// string without re-allocating. #[derive(Debug)] #[non_exhaustive] pub enum JsonExtractionRejection { @@ -120,7 +125,10 @@ pub mod extract { status: http::StatusCode, message: String, }, - MissingContentType, + MissingContentType { + status: http::StatusCode, + message: String, + }, Other { status: http::StatusCode, message: String, @@ -128,16 +136,23 @@ pub mod extract { } impl JsonExtractionRejection { + /// Return the pre-rendered error message. No allocation — returns a + /// clone of the already-stored `String`. pub fn body_text(&self) -> String { - self.to_string() + match self { + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::MissingContentType { message, .. } + | Self::Other { message, .. } => message.clone(), + } } pub fn status(&self) -> http::StatusCode { match self { - Self::SyntaxError { status, .. } => *status, - Self::DataError { status, .. } => *status, - Self::MissingContentType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - Self::Other { status, .. } => *status, + Self::SyntaxError { status, .. } + | Self::DataError { status, .. } + | Self::MissingContentType { status, .. } + | Self::Other { status, .. } => *status, } } } @@ -145,12 +160,10 @@ pub mod extract { impl std::fmt::Display for JsonExtractionRejection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::SyntaxError { message, .. } => f.write_str(message), - Self::DataError { message, .. } => f.write_str(message), - Self::MissingContentType => { - f.write_str("Expected request with `Content-Type: application/json`") - } - Self::Other { message, .. } => f.write_str(message), + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::MissingContentType { message, .. } + | Self::Other { message, .. } => f.write_str(message), } } } @@ -159,7 +172,15 @@ pub mod extract { impl IntoResponse for JsonExtractionRejection { fn into_response(self) -> Response { - (self.status(), self.body_text()).into_response() + let status = self.status(); + // Destructure to move the String — no clone. + let message = match self { + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::MissingContentType { message, .. } + | Self::Other { message, .. } => message, + }; + (status, message).into_response() } } @@ -175,7 +196,10 @@ pub mod extract { status: e.status(), message: e.body_text(), }, - MissingJsonContentType(_) => Self::MissingContentType, + MissingJsonContentType(e) => Self::MissingContentType { + status: e.status(), + message: e.body_text(), + }, other => Self::Other { status: other.status(), message: other.body_text(), @@ -214,7 +238,10 @@ pub mod extract { .as_ref() .is_some_and(crate::mime::is_json) { - return Err(JsonExtractionRejection::MissingContentType); + return Err(JsonExtractionRejection::MissingContentType { + status: http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + message: "Expected request with `Content-Type: application/json`".into(), + }); } let bytes = Bytes::from_request(req, state).await.map_err(|e| { JsonExtractionRejection::Other { From 8db679588d3f3cc0327124003674decbedd38582 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:26:49 +0200 Subject: [PATCH 4/8] chore: trim doc comments Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index 49166a64..a86c82f2 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -105,15 +105,7 @@ pub mod extract { use bytes::Bytes; use serde::de::DeserializeOwned; - /// JSON extraction rejection type owned by s2-api. - /// - /// Decouples consumers from `axum::extract::rejection::JsonRejection` so the - /// underlying deserializer can be swapped without changing downstream code. - /// JSON extraction rejection type owned by s2-api. - /// - /// Every variant stores its status code and pre-rendered message so that - /// `body_text()`, `Display`, and `IntoResponse` all return the same - /// string without re-allocating. + /// Rejection type for JSON extraction, owned by s2-api. #[derive(Debug)] #[non_exhaustive] pub enum JsonExtractionRejection { @@ -136,8 +128,6 @@ pub mod extract { } impl JsonExtractionRejection { - /// Return the pre-rendered error message. No allocation — returns a - /// clone of the already-stored `String`. pub fn body_text(&self) -> String { match self { Self::SyntaxError { message, .. } From 40c0aa4cc702106bc8e20ced6d9dd65bc9619155 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:29:23 +0200 Subject: [PATCH 5/8] perf: use Cow<'static, str> for rejection messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static messages (MissingContentType) are now zero-alloc. Dynamic messages from axum's body_text() are stored as Cow::Owned. Display writes directly from the Cow — no intermediate allocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index a86c82f2..d2b4b7b2 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -98,6 +98,8 @@ pub struct S2FormatHeader { #[cfg(feature = "axum")] pub mod extract { + use std::borrow::Cow; + use axum::{ extract::{FromRequest, OptionalFromRequest, Request, rejection::BytesRejection}, response::{IntoResponse, Response}, @@ -111,19 +113,19 @@ pub mod extract { pub enum JsonExtractionRejection { SyntaxError { status: http::StatusCode, - message: String, + message: Cow<'static, str>, }, DataError { status: http::StatusCode, - message: String, + message: Cow<'static, str>, }, MissingContentType { status: http::StatusCode, - message: String, + message: Cow<'static, str>, }, Other { status: http::StatusCode, - message: String, + message: Cow<'static, str>, }, } @@ -133,7 +135,7 @@ pub mod extract { Self::SyntaxError { message, .. } | Self::DataError { message, .. } | Self::MissingContentType { message, .. } - | Self::Other { message, .. } => message.clone(), + | Self::Other { message, .. } => message.clone().into_owned(), } } @@ -163,14 +165,7 @@ pub mod extract { impl IntoResponse for JsonExtractionRejection { fn into_response(self) -> Response { let status = self.status(); - // Destructure to move the String — no clone. - let message = match self { - Self::SyntaxError { message, .. } - | Self::DataError { message, .. } - | Self::MissingContentType { message, .. } - | Self::Other { message, .. } => message, - }; - (status, message).into_response() + (status, self.to_string()).into_response() } } @@ -180,19 +175,19 @@ pub mod extract { match rej { JsonDataError(e) => Self::DataError { status: e.status(), - message: e.body_text(), + message: e.body_text().into(), }, JsonSyntaxError(e) => Self::SyntaxError { status: e.status(), - message: e.body_text(), + message: e.body_text().into(), }, MissingJsonContentType(e) => Self::MissingContentType { status: e.status(), - message: e.body_text(), + message: e.body_text().into(), }, other => Self::Other { status: other.status(), - message: other.body_text(), + message: other.body_text().into(), }, } } @@ -230,13 +225,15 @@ pub mod extract { { return Err(JsonExtractionRejection::MissingContentType { status: http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - message: "Expected request with `Content-Type: application/json`".into(), + message: Cow::Borrowed( + "Expected request with `Content-Type: application/json`", + ), }); } let bytes = Bytes::from_request(req, state).await.map_err(|e| { JsonExtractionRejection::Other { status: e.status(), - message: e.body_text(), + message: e.body_text().into(), } })?; if bytes.is_empty() { From fc1fd561ba7d7d5eedc529b866d96c69fb995564 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:33:40 +0200 Subject: [PATCH 6/8] perf: move Cow in IntoResponse instead of to_string() into_response() now destructures and moves the Cow, calling into_owned() which is a no-op for Cow::Owned (just unwraps). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/data.rs b/api/src/data.rs index d2b4b7b2..f743bbfe 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -165,7 +165,13 @@ pub mod extract { impl IntoResponse for JsonExtractionRejection { fn into_response(self) -> Response { let status = self.status(); - (status, self.to_string()).into_response() + let message = match self { + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::MissingContentType { message, .. } + | Self::Other { message, .. } => message, + }; + (status, message.into_owned()).into_response() } } From 1226648b64c1761bc2834695e58b5736e29f21d0 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:36:42 +0200 Subject: [PATCH 7/8] chore: mark From bridge as temporary Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/data.rs b/api/src/data.rs index f743bbfe..63268c2a 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -175,6 +175,7 @@ pub mod extract { } } + // TODO: remove when we stop delegating to axum::Json. impl From for JsonExtractionRejection { fn from(rej: axum::extract::rejection::JsonRejection) -> Self { use axum::extract::rejection::JsonRejection::*; From dcc1011e9eb765bd6ac8ea95c8fd644552f7e638 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 23 Mar 2026 15:41:14 +0200 Subject: [PATCH 8/8] perf: zero-alloc MissingContentType and body_text() returns &str MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MissingContentType is now a unit variant with a static message const — zero heap allocation on construction, display, and response rendering - body_text() returns &str instead of String — no clone needed - IntoResponse matches on Cow: Borrowed passes &str directly, Owned moves the String — no intermediate allocation in either case Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/data.rs | 46 +++++++++++++++++----------------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/api/src/data.rs b/api/src/data.rs index 63268c2a..a78bb3c4 100644 --- a/api/src/data.rs +++ b/api/src/data.rs @@ -119,23 +119,22 @@ pub mod extract { status: http::StatusCode, message: Cow<'static, str>, }, - MissingContentType { - status: http::StatusCode, - message: Cow<'static, str>, - }, + MissingContentType, Other { status: http::StatusCode, message: Cow<'static, str>, }, } + const MISSING_CONTENT_TYPE_MSG: &str = "Expected request with `Content-Type: application/json`"; + impl JsonExtractionRejection { - pub fn body_text(&self) -> String { + pub fn body_text(&self) -> &str { match self { Self::SyntaxError { message, .. } | Self::DataError { message, .. } - | Self::MissingContentType { message, .. } - | Self::Other { message, .. } => message.clone().into_owned(), + | Self::Other { message, .. } => message, + Self::MissingContentType => MISSING_CONTENT_TYPE_MSG, } } @@ -143,20 +142,15 @@ pub mod extract { match self { Self::SyntaxError { status, .. } | Self::DataError { status, .. } - | Self::MissingContentType { status, .. } | Self::Other { status, .. } => *status, + Self::MissingContentType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE, } } } impl std::fmt::Display for JsonExtractionRejection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SyntaxError { message, .. } - | Self::DataError { message, .. } - | Self::MissingContentType { message, .. } - | Self::Other { message, .. } => f.write_str(message), - } + f.write_str(self.body_text()) } } @@ -165,13 +159,15 @@ pub mod extract { impl IntoResponse for JsonExtractionRejection { fn into_response(self) -> Response { let status = self.status(); - let message = match self { + match self { Self::SyntaxError { message, .. } | Self::DataError { message, .. } - | Self::MissingContentType { message, .. } - | Self::Other { message, .. } => message, - }; - (status, message.into_owned()).into_response() + | Self::Other { message, .. } => match message { + Cow::Borrowed(s) => (status, s).into_response(), + Cow::Owned(s) => (status, s).into_response(), + }, + Self::MissingContentType => (status, MISSING_CONTENT_TYPE_MSG).into_response(), + } } } @@ -188,10 +184,7 @@ pub mod extract { status: e.status(), message: e.body_text().into(), }, - MissingJsonContentType(e) => Self::MissingContentType { - status: e.status(), - message: e.body_text().into(), - }, + MissingJsonContentType(_) => Self::MissingContentType, other => Self::Other { status: other.status(), message: other.body_text().into(), @@ -230,12 +223,7 @@ pub mod extract { .as_ref() .is_some_and(crate::mime::is_json) { - return Err(JsonExtractionRejection::MissingContentType { - status: http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - message: Cow::Borrowed( - "Expected request with `Content-Type: application/json`", - ), - }); + return Err(JsonExtractionRejection::MissingContentType); } let bytes = Bytes::from_request(req, state).await.map_err(|e| { JsonExtractionRejection::Other {