diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 543f44400..c974793dd 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -33,6 +33,7 @@ features = [ "compress-zstd", "cookies", "secure-cookies", + "beautify-errors" ] [package.metadata.cargo_check_external_types] @@ -89,6 +90,9 @@ cookies = ["dep:cookie"] # Secure & signed cookies secure-cookies = ["cookies", "cookie/secure"] +# Field names in deserialization errors +beautify-errors = ["dep:serde_path_to_error"] + # HTTP/2 support (including h2c). http2 = ["actix-http/http2"] @@ -160,6 +164,7 @@ regex = { version = "1.5.5", optional = true } regex-lite = "0.1" serde = "1.0" serde_json = "1.0" +serde_path_to_error = { version = "0.1", optional = true } serde_urlencoded = "0.7" smallvec = "1.6.1" socket2 = "0.5" @@ -211,6 +216,10 @@ required-features = ["compress-gzip"] name = "on-connect" required-features = [] +[[example]] +name = "error" +required-features = ["beautify-errors"] + [[bench]] name = "server" harness = false diff --git a/actix-web/examples/error.rs b/actix-web/examples/error.rs new file mode 100644 index 000000000..445652989 --- /dev/null +++ b/actix-web/examples/error.rs @@ -0,0 +1,77 @@ +use actix_web::{ + get, middleware, post, + web::{Json, Query}, + App, HttpServer, +}; +use serde::Deserialize; + +#[get("/optional")] +async fn optional_query_params(maybe_qs: Option>) -> String { + format!("you asked for the optional query params: {:#?}", maybe_qs) +} + +#[get("/mandatory")] +async fn mandatory_query_params(qs: Query) -> String { + format!("you asked for the mandatory query params: {:#?}", qs) +} + +#[post("/optional")] +async fn optional_payload( + maybe_qs: Option>, + maybe_payload: Option>, +) -> String { + format!( + "you asked for the optional query params: {:#?} and optional body: {:#?}", + maybe_qs, maybe_payload + ) +} + +#[post("/mandatory")] +async fn mandatory_payload(qs: Query, payload: Json) -> String { + format!( + "you asked for the mandatory query params: {:#?} and mandatory body: {:#?}", + qs, payload + ) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + HttpServer::new(|| { + App::new() + .wrap(middleware::Logger::default()) + .service(optional_query_params) + .service(mandatory_query_params) + .service(optional_payload) + .service(mandatory_payload) + }) + .bind("127.0.0.1:8080")? + .workers(1) + .run() + .await +} + +#[derive(Debug, Deserialize)] +pub struct OptionalFilters { + limit: Option, + active: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MandatoryFilters { + limit: i32, + active: bool, +} + +#[derive(Debug, Deserialize)] +pub struct OptionalPayload { + name: Option, + age: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MandatoryPayload { + name: String, + age: i32, +} diff --git a/actix-web/examples/error_postman.json b/actix-web/examples/error_postman.json new file mode 100644 index 000000000..26310020b --- /dev/null +++ b/actix-web/examples/error_postman.json @@ -0,0 +1,848 @@ +{ + "info": { + "_postman_id": "1147f102-8d16-40e4-8642-5f0679879e59", + "name": "actix-web", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "field in deserialize errors", + "item": [ + { + "name": "optional filters", + "item": [ + { + "name": "without filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/optional", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ] + } + }, + "response": [] + }, + { + "name": "with a single filter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/optional?limit=1", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/optional?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with a single invalid filter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/optional?limit=wrong&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/optional?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "mandatory filters", + "item": [ + { + "name": "without filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/mandatory", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ] + } + }, + "response": [] + }, + { + "name": "with a single filter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=1", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with a single invalid filter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=wrong&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "optional filters and payload", + "item": [ + { + "name": "without filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ] + } + }, + "response": [] + }, + { + "name": "with a single filter", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=1", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with a single invalid filter", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=wrong&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters and invalid payload", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": \"wrong\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters and invalid payload", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": \"wrong\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/optional?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "optional" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "mandatory filters and payload", + "item": [ + { + "name": "without filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ] + } + }, + "response": [] + }, + { + "name": "with a single filter", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=1", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with a single invalid filter", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=wrong&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": 13\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + }, + { + "name": "with both filters and invalid payload", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": \"wrong\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=1&active=true", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "active", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "with both invalid filters and invalid payload", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John\",\n \"age\": \"wrong\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "127.0.0.1:8080/mandatory?limit=wrong&active=wrong", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8080", + "path": [ + "mandatory" + ], + "query": [ + { + "key": "limit", + "value": "wrong" + }, + { + "key": "active", + "value": "wrong" + } + ] + } + }, + "response": [] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/actix-web/src/types/json.rs b/actix-web/src/types/json.rs index 6b75c0cfe..18c9326e5 100644 --- a/actix-web/src/types/json.rs +++ b/actix-web/src/types/json.rs @@ -17,6 +17,8 @@ use serde::{de::DeserializeOwned, Serialize}; #[cfg(feature = "__compress")] use crate::dev::Decompress; +#[cfg(feature = "beautify-errors")] +use crate::web::map_deserialize_error; use crate::{ body::EitherBody, error::{Error, JsonPayloadError}, @@ -427,11 +429,28 @@ impl Future for JsonBody { buf.extend_from_slice(&chunk); } } + #[cfg(not(feature = "beautify-errors"))] None => { let json = serde_json::from_slice::(buf) .map_err(JsonPayloadError::Deserialize)?; return Poll::Ready(Ok(json)); } + #[cfg(feature = "beautify-errors")] + None => { + let mut deserializer = serde_json::Deserializer::from_slice(buf); + let json = + serde_path_to_error::deserialize(&mut deserializer).map_err(|e| { + JsonPayloadError::Deserialize( + ::custom( + map_deserialize_error( + &e.path().to_string(), + &e.inner().to_string(), + ), + ), + ) + })?; + return Poll::Ready(Ok(json)); + } } }, JsonBody::Error(e) => Poll::Ready(Err(e.take().unwrap())), diff --git a/actix-web/src/types/mod.rs b/actix-web/src/types/mod.rs index cabe53d6a..56fcd146e 100644 --- a/actix-web/src/types/mod.rs +++ b/actix-web/src/types/mod.rs @@ -21,3 +21,11 @@ pub use self::{ query::{Query, QueryConfig}, readlines::Readlines, }; + +#[cfg(feature = "beautify-errors")] +pub fn map_deserialize_error(field: &str, original: &str) -> String { + if field == "." { + return original.to_string(); + } + format!("'{}': {}", field, original) +} diff --git a/actix-web/src/types/query.rs b/actix-web/src/types/query.rs index e71b886f2..d8b54a65e 100644 --- a/actix-web/src/types/query.rs +++ b/actix-web/src/types/query.rs @@ -4,7 +4,11 @@ use std::{fmt, ops, sync::Arc}; use actix_utils::future::{err, ok, Ready}; use serde::de::DeserializeOwned; +#[cfg(feature = "beautify-errors")] +use url::form_urlencoded::parse; +#[cfg(feature = "beautify-errors")] +use crate::web::map_deserialize_error; use crate::{dev::Payload, error::QueryPayloadError, Error, FromRequest, HttpRequest}; /// Extract typed information from the request's query. @@ -61,7 +65,6 @@ use crate::{dev::Payload, error::QueryPayloadError, Error, FromRequest, HttpRequ pub struct Query(pub T); impl Query { - /// Unwrap into inner `T` value. pub fn into_inner(self) -> T { self.0 } @@ -78,11 +81,30 @@ impl Query { /// assert_eq!(numbers.get("two"), Some(&2)); /// assert!(numbers.get("three").is_none()); /// ``` + #[cfg(not(feature = "beautify-errors"))] pub fn from_query(query_str: &str) -> Result { serde_urlencoded::from_str::(query_str) .map(Self) .map_err(QueryPayloadError::Deserialize) } + #[cfg(feature = "beautify-errors")] + pub fn from_query(query_str: &str) -> Result + where + T: de::DeserializeOwned, + { + let deserializer = serde_urlencoded::Deserializer::new(parse(query_str.as_bytes())); + let qs = serde_path_to_error::deserialize(deserializer) + .map(Self) + .map_err(|e| { + QueryPayloadError::Deserialize( + ::custom(map_deserialize_error( + &e.path().to_string(), + &e.inner().to_string(), + )), + ) + })?; + Ok(qs) + } } impl ops::Deref for Query { @@ -110,6 +132,7 @@ impl FromRequest for Query { type Error = Error; type Future = Ready>; + #[cfg(not(feature = "beautify-errors"))] #[inline] fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { let error_handler = req @@ -136,6 +159,41 @@ impl FromRequest for Query { err(e) }) } + + #[cfg(feature = "beautify-errors")] + #[inline] + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let error_handler = req + .app_data::() + .and_then(|c| c.err_handler.clone()); + + let deserializer = + serde_urlencoded::Deserializer::new(parse(req.query_string().as_bytes())); + return serde_path_to_error::deserialize(deserializer) + .map(|val| ok(Query(val))) + .unwrap_or_else(move |e| { + let e = QueryPayloadError::Deserialize( + ::custom(map_deserialize_error( + &e.path().to_string(), + &e.inner().to_string(), + )), + ); + + log::debug!( + "Failed during Query extractor deserialization. \ + Request path: {:?}", + req.path() + ); + + let e = if let Some(error_handler) = error_handler { + (error_handler)(e, req) + } else { + e.into() + }; + + err(e) + }); + } } /// Query extractor configuration.