Skip to content

Commit

Permalink
feat: include path in query deser error type
Browse files Browse the repository at this point in the history
  • Loading branch information
robjtede committed Aug 10, 2024
1 parent 46d1b5c commit 0ba44a4
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 22 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
repository = "https://github.com/robjtede/actix-web-lab"
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.75"
rust-version = "1.76"

[workspace.lints.rust]
rust_2018_idioms = { level = "deny", priority = 10 }
Expand Down
2 changes: 2 additions & 0 deletions actix-web-lab/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add `extract::QueryDeserializeError` type.
- Re-work `Query` deserialization error handling.
- Implement `Clone` for `extract::Path<T: Clone>`.
- The `Deref` implementation for `header::CacheControl` now returns a slice instead of a `Vec`.
- Deprecate `middleware::from_fn()` now it has graduated to Actix Web.
Expand Down
2 changes: 2 additions & 0 deletions actix-web-lab/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ csv = "1.1"
derive_more = { version = "1", features = ["display", "error"] }
futures-core = "0.3.17"
futures-util = { version = "0.3.17", default-features = false, features = ["std"] }
form_urlencoded = "1"
http = "0.2.7"
impl-more = "0.1.3"
itertools = "0.13"
Expand All @@ -73,6 +74,7 @@ regex = "1.5.5"
serde = "1"
serde_html_form = "0.2"
serde_json = "1"
serde_path_to_error = "0.1"
tokio = { version = "1.23.1", features = ["sync", "macros"] }
tokio-stream = "0.1.1"
tracing = { version = "0.1.30", features = ["log"] }
Expand Down
96 changes: 96 additions & 0 deletions actix-web-lab/examples/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Demonstrates use of alternative Query extractor with better deserializer and errors.
use std::io;

use actix_web::{
error::ErrorBadRequest,
middleware::{Logger, NormalizePath},
App, HttpResponse, HttpServer, Resource, Responder,
};
use actix_web_lab::extract::{Query, QueryDeserializeError};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
enum Type {
Planet,
Moon,
}

#[derive(Debug, Deserialize, Serialize)]
struct Params {
/// Limit number of results.
count: u32,

/// Filter by object type.
#[serde(rename = "type", default)]
types: Vec<Type>,
}

/// Demonstrates multiple query parameters and getting path from deserialization errors.
async fn query(
query: Result<Query<Params>, QueryDeserializeError>,
) -> actix_web::Result<impl Responder> {
let params = match query {
Ok(Query(query)) => query,
Err(err) => return Err(ErrorBadRequest(err)),
};

tracing::debug!("filters: {params:?}");

Ok(HttpResponse::Ok().json(params))
}

/// Baseline comparison using the built-in `Query` extractor.
async fn baseline(
query: actix_web::Result<actix_web::web::Query<Params>>,
) -> actix_web::Result<impl Responder> {
let params = query?.0;

tracing::debug!("filters: {params:?}");

Ok(HttpResponse::Ok().json(params))
}

#[actix_web::main]
async fn main() -> io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));

tracing::info!("starting HTTP server at http://localhost:8080");

HttpServer::new(|| {
App::new()
.service(Resource::new("/").get(query))
.service(Resource::new("/baseline").get(baseline))
.wrap(NormalizePath::trim())
.wrap(Logger::default())
})
.bind(("127.0.0.1", 8080))?
.workers(2)
.run()
.await
}

#[cfg(test)]
mod tests {
use actix_web::{body::to_bytes, dev::Service, http::StatusCode, test, web, App};

use super::*;

#[actix_web::test]
async fn test_index() {
let app =
test::init_service(App::new().service(web::resource("/").route(web::post().to(query))))
.await;

let req = test::TestRequest::post()
.uri("/?count=5&type=planet&type=moon")
.to_request();

let res = app.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);

let body_bytes = to_bytes(res.into_body()).await.unwrap();
assert_eq!(body_bytes, r#"{"count":5,"type":["planet","moon"]}"#);
}
}
2 changes: 1 addition & 1 deletion actix-web-lab/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub use crate::{
lazy_data::LazyData,
local_data::LocalData,
path::Path,
query::Query,
query::{Query, QueryDeserializeError},
request_signature::{RequestSignature, RequestSignatureError, RequestSignatureScheme},
swap_data::SwapData,
url_encoded_form::{UrlEncodedForm, DEFAULT_URL_ENCODED_FORM_LIMIT},
Expand Down
75 changes: 55 additions & 20 deletions actix-web-lab/src/query.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! For query parameter extractor documentation, see [`Query`].
use std::fmt;
use std::future::{ready, Ready};

use actix_web::{dev::Payload, error::QueryPayloadError, Error, FromRequest, HttpRequest};
use actix_web::{dev::Payload, http::StatusCode, FromRequest, HttpRequest, ResponseError};
use derive_more::Error;
use serde::de::DeserializeOwned;
use tracing::debug;

/// Extract typed information from the request's query.
///
Expand Down Expand Up @@ -85,33 +86,67 @@ impl<T: DeserializeOwned> Query<T> {
/// assert_eq!(numbers.get("two"), Some(&2));
/// assert!(numbers.get("three").is_none());
/// ```
pub fn from_query(query_str: &str) -> Result<Self, QueryPayloadError> {
serde_html_form::from_str::<T>(query_str)
pub fn from_query(query_str: &str) -> Result<Self, QueryDeserializeError> {
let parser = form_urlencoded::parse(query_str.as_bytes());
let de = serde_html_form::Deserializer::new(parser);

serde_path_to_error::deserialize(de)
.map(Self)
.map_err(QueryPayloadError::Deserialize)
.map_err(|err| QueryDeserializeError {
path: err.path().clone(),
source: err.into_inner(),
})
}
}

/// See [here](#examples) for example of usage as an extractor.
impl<T: DeserializeOwned> FromRequest for Query<T> {
type Error = Error;
type Future = Ready<Result<Self, Error>>;
type Error = QueryDeserializeError;
type Future = Ready<Result<Self, Self::Error>>;

#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
serde_html_form::from_str::<T>(req.query_string())
.map(|val| ready(Ok(Query(val))))
.unwrap_or_else(move |err| {
let err = QueryPayloadError::Deserialize(err);

debug!(
"Failed during Query extractor deserialization. \
Request path: {:?}",
req.path()
);

ready(Err(err.into()))
})
ready(Self::from_query(req.query_string()).inspect_err(|err| {
tracing::debug!(
"Failed during Query extractor deserialization. \
Request path: \"{}\". \
Error path: \"{}\".",
req.match_name().unwrap_or(req.path()),
err.path(),
);
}))
}
}

/// Deserialization errors that can occur during parsing query strings.
#[derive(Debug, Error)]
pub struct QueryDeserializeError {
path: serde_path_to_error::Path,
source: serde::de::value::Error,
}

impl QueryDeserializeError {
/// Returns the path at which the deserialization error occurred.
pub fn path(&self) -> impl fmt::Display + '_ {
&self.path
}
}

impl fmt::Display for QueryDeserializeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Query deserialization failed")?;

if self.path.iter().len() > 0 {
write!(f, " at path: {}", &self.path)?;
}

Ok(())
}
}

impl ResponseError for QueryDeserializeError {
fn status_code(&self) -> StatusCode {
StatusCode::UNPROCESSABLE_ENTITY
}
}

Expand Down

0 comments on commit 0ba44a4

Please sign in to comment.