From bf249b47247c766966af0c7cc8b1a2a9c841fcaa Mon Sep 17 00:00:00 2001 From: dymchenkko Date: Mon, 25 May 2026 20:57:25 +0200 Subject: [PATCH] fix: return structured JSON for malformed request bodies JSON-body endpoints returned axum's plain-text rejection when a request body failed to deserialize. Add a Json extractor that renders these errors in the API's { errorType, description } format and apply it to all JSON-body endpoints. Closes #4439 Closes #4440 --- crates/orderbook/openapi.yml | 40 +++++++ crates/orderbook/src/api.rs | 1 + crates/orderbook/src/api/cancel_order.rs | 6 +- crates/orderbook/src/api/debug_simulation.rs | 7 +- crates/orderbook/src/api/extract.rs | 114 +++++++++++++++++++ crates/orderbook/src/api/post_order.rs | 3 +- crates/orderbook/src/api/post_quote.rs | 3 +- crates/orderbook/src/api/put_app_data.rs | 4 +- 8 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 crates/orderbook/src/api/extract.rs diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index 4e07465519..062d2f306d 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -83,6 +83,10 @@ paths: description: No route was found quoting the order. "422": description: Unable to parse request body as valid JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "429": description: Too many order placements. "500": @@ -239,6 +243,10 @@ paths: description: Order was not found. "422": description: Unable to parse request body as valid JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "/api/v1/orders/{UID}/status": get: operationId: getOrderStatus @@ -519,6 +527,10 @@ paths: $ref: "#/components/schemas/PriceEstimationError" "422": description: Unable to parse request body as valid JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "429": description: Too many order quotes. "500": @@ -660,6 +672,10 @@ paths: description: Error validating full `appData` "422": description: Unable to parse request body as valid JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": description: Error storing the full `appData` /api/v1/app_data: @@ -693,6 +709,10 @@ paths: description: Error validating full `appData` "422": description: Unable to parse request body as valid JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": description: Error storing the full `appData` "/api/v1/users/{address}/total_surplus": @@ -749,6 +769,10 @@ paths: description: > Request body failed schema validation: missing required field, wrong field type, zero `sellAmount`, or unrecognised `kind` value. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": description: Internal error. "/restricted/api/v1/debug/simulation/{uid}": @@ -1755,6 +1779,22 @@ components: description: Empty signature bytes. Used for "presign" signatures. type: string example: 0x + Error: + description: > + Error response returned when a request body cannot be deserialized into + the expected type, for example malformed JSON or a missing required + field. + type: object + properties: + errorType: + type: string + description: An identifier for the kind of error. + description: + type: string + description: A human-readable description of the error. + required: + - errorType + - description OrderPostError: type: object properties: diff --git a/crates/orderbook/src/api.rs b/crates/orderbook/src/api.rs index 66a5a6d2ce..c2cecfcf51 100644 --- a/crates/orderbook/src/api.rs +++ b/crates/orderbook/src/api.rs @@ -32,6 +32,7 @@ mod cancel_order; mod cancel_orders; mod debug_order; mod debug_simulation; +mod extract; mod get_app_data; mod get_auction; mod get_native_price; diff --git a/crates/orderbook/src/api/cancel_order.rs b/crates/orderbook/src/api/cancel_order.rs index db82862d11..e3c35287ad 100644 --- a/crates/orderbook/src/api/cancel_order.rs +++ b/crates/orderbook/src/api/cancel_order.rs @@ -1,7 +1,9 @@ use { - crate::{api::AppState, orderbook::OrderCancellationError}, + crate::{ + api::{AppState, extract::Json}, + orderbook::OrderCancellationError, + }, axum::{ - Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, diff --git a/crates/orderbook/src/api/debug_simulation.rs b/crates/orderbook/src/api/debug_simulation.rs index 7937a58751..1e3161e842 100644 --- a/crates/orderbook/src/api/debug_simulation.rs +++ b/crates/orderbook/src/api/debug_simulation.rs @@ -1,7 +1,10 @@ use { - crate::{api::AppState, dto::OrderSimulationRequest, orderbook::OrderSimulationError}, + crate::{ + api::{AppState, extract::Json}, + dto::OrderSimulationRequest, + orderbook::OrderSimulationError, + }, axum::{ - Json, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, diff --git a/crates/orderbook/src/api/extract.rs b/crates/orderbook/src/api/extract.rs new file mode 100644 index 0000000000..f0c0445ff3 --- /dev/null +++ b/crates/orderbook/src/api/extract.rs @@ -0,0 +1,114 @@ +//! Axum extractors that wrap the stock extractor and, when request +//! deserialization fails, respond with this API's structured error format +//! (`{ errorType, description }`) instead of the stock plain-text rejection, so +//! clients can parse every response from the API as JSON. + +use { + super::error, + axum::{ + extract::{FromRequest, Request}, + response::{IntoResponse, Response}, + }, + serde::{Serialize, de::DeserializeOwned}, +}; + +/// JSON extractor that wraps Axum's native one and renders deserialization +/// errors as this API's structured error response. Also serves as a response +/// type so it can fully replace [`axum::Json`] where both are used. +pub struct Json(pub T); + +impl FromRequest for Json +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match axum::Json::::from_request(req, state).await { + Ok(axum::Json(value)) => Ok(Self(value)), + Err(rejection) => Err(( + rejection.status(), + error("InvalidJson", rejection.body_text()), + ) + .into_response()), + } + } +} + +impl IntoResponse for Json { + fn into_response(self) -> Response { + axum::Json(self.0).into_response() + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header::CONTENT_TYPE}, + }, + serde::Deserialize, + }; + + #[derive(Deserialize)] + struct Dummy { + _required: u32, + } + + async fn structured_error(body: &'static str) -> (StatusCode, serde_json::Value) { + let request = Request::builder() + .method("POST") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(); + + let response = match Json::::from_request(request, &()).await { + Ok(_) => panic!("malformed body should have been rejected"), + Err(response) => response, + }; + + let status = response.status(); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/json", + "error response must be JSON" + ); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + // Reproduces cowprotocol/services#4439: an empty JSON object misses a + // required field and must yield a structured JSON error, not plain text. + #[tokio::test] + async fn missing_field_returns_structured_json_error() { + let (status, json) = structured_error("{}").await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(json["errorType"], "InvalidJson"); + assert!( + json["description"] + .as_str() + .unwrap() + .contains("missing field") + ); + } + + // Reproduces cowprotocol/services#4440: a field whose value cannot be + // deserialized into the target type (e.g. an invalid token address) must + // also yield a structured JSON error rather than plain text. + #[tokio::test] + async fn invalid_field_value_returns_structured_json_error() { + let (status, json) = structured_error(r#"{"_required": "not-a-number"}"#).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(json["errorType"], "InvalidJson"); + } + + #[tokio::test] + async fn invalid_syntax_returns_structured_json_error() { + let (status, json) = structured_error("not json").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["errorType"], "InvalidJson"); + } +} diff --git a/crates/orderbook/src/api/post_order.rs b/crates/orderbook/src/api/post_order.rs index 9db253913b..a51e6448e5 100644 --- a/crates/orderbook/src/api/post_order.rs +++ b/crates/orderbook/src/api/post_order.rs @@ -1,10 +1,9 @@ use { crate::{ - api::{AppState, error}, + api::{AppState, error, extract::Json}, orderbook::{AddOrderError, OrderReplacementError}, }, axum::{ - Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, diff --git a/crates/orderbook/src/api/post_quote.rs b/crates/orderbook/src/api/post_quote.rs index 4981e6cc64..b5ee88a98f 100644 --- a/crates/orderbook/src/api/post_quote.rs +++ b/crates/orderbook/src/api/post_quote.rs @@ -1,11 +1,10 @@ use { super::post_order::{AppDataValidationErrorWrapper, PartialValidationErrorWrapper}, crate::{ - api::{AppState, error, rich_error}, + api::{AppState, error, extract::Json, rich_error}, quoter::OrderQuoteError, }, axum::{ - Json, extract::State, response::{IntoResponse, Response}, }, diff --git a/crates/orderbook/src/api/put_app_data.rs b/crates/orderbook/src/api/put_app_data.rs index a1b5be2876..68f351b704 100644 --- a/crates/orderbook/src/api/put_app_data.rs +++ b/crates/orderbook/src/api/put_app_data.rs @@ -1,10 +1,10 @@ use { - crate::api::{AppState, internal_error_reply}, + crate::api::{AppState, extract::Json, internal_error_reply}, app_data::{AppDataDocument, AppDataHash}, axum::{ extract::{Path, State}, http::StatusCode, - response::{IntoResponse, Json, Response}, + response::{IntoResponse, Response}, }, std::sync::Arc, };