Skip to content

Commit

Permalink
urlencoded extractor (#255)
Browse files Browse the repository at this point in the history
  • Loading branch information
bosukas authored Apr 18, 2024
1 parent cf770e6 commit 0f891d0
Show file tree
Hide file tree
Showing 20 changed files with 544 additions and 0 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ We contributors to Pavex:
* Karl Lindfors (@darkkeh)
* Harry Barber (@hlbarber)
* Jan Ehrhardt (@jehrhardt)
* Lukas Slanius (@bosukas)
14 changes: 14 additions & 0 deletions doc_examples/guide/request_data/urlencoded/project-extraction.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
```rust title="src/urlencoded/routes.rs" hl_lines="10"
use pavex::http::StatusCode;
use pavex::request::body::UrlEncodedBody;
#[derive(serde::Deserialize)]
pub struct HomeListing {
address: String,
price: u64,
}
pub fn handler(params: &UrlEncodedBody<HomeListing>) -> StatusCode {
// [...]
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
```rust title="src/blueprint.rs" hl_lines="6"
use pavex::blueprint::Blueprint;
use pavex::request::body::UrlEncodedBody;
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
UrlEncodedBody::register(&mut bp); // (1)!
// [...]
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
```rust title="src/urlencoded/routes.rs" hl_lines="2"
// [...]
#[derive(serde::Deserialize)]
pub struct HomeListing {
address: String,
price: u64,
}
```
2 changes: 2 additions & 0 deletions doc_examples/guide/request_data/urlencoded/project/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/target
Cargo.lock
13 changes: 13 additions & 0 deletions doc_examples/guide/request_data/urlencoded/project/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "urlencoded"
version = "0.1.0"
edition = "2021"

[dependencies]
pavex = { path = "../../../../../libs/pavex" }
pavex_cli_client = { path = "../../../../../libs/pavex_cli_client" }
serde = { version = "1", features = ["derive"] }
cargo_px_env = "0.1"

[workspace]
members = [".", "server_sdk"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "urlencoded_server_sdk"
version = "0.1.0"
edition = "2021"

[package.metadata.px.generate]
generator_type = "cargo_workspace_binary"
generator_name = "urlencoded"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use pavex::blueprint::Blueprint;
use pavex::request::body::UrlEncodedBody;

pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
UrlEncodedBody::register(&mut bp); // (1)!
pavex::request::body::BufferedBody::register(&mut bp);
pavex::request::body::BodySizeLimit::register(&mut bp);

bp.nest(crate::urlencoded::blueprint());
bp
}
7 changes: 7 additions & 0 deletions doc_examples/guide/request_data/urlencoded/project/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![allow(dead_code)]
#![allow(unused_variables)]

pub use blueprint::blueprint;

mod blueprint;
pub mod urlencoded;
13 changes: 13 additions & 0 deletions doc_examples/guide/request_data/urlencoded/project/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use std::error::Error;

use cargo_px_env::generated_pkg_manifest_path;
use urlencoded::blueprint;
use pavex_cli_client::Client;

fn main() -> Result<(), Box<dyn Error>> {
let generated_dir = generated_pkg_manifest_path()?.parent().unwrap().into();
Client::new()
.generate(blueprint(), generated_dir)
.execute()?;
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use pavex::blueprint::router::GET;
use pavex::blueprint::Blueprint;
use pavex::f;

pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.route(GET, "/search", f!(super::handler));
bp
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub use blueprint::blueprint;
pub use routes::handler;

mod blueprint;
mod routes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use pavex::http::StatusCode;
use pavex::request::body::UrlEncodedBody;

#[derive(serde::Deserialize)]
pub struct HomeListing {
address: String,
price: u64,
}

pub fn handler(params: &UrlEncodedBody<HomeListing>) -> StatusCode {
StatusCode::OK
}
17 changes: 17 additions & 0 deletions doc_examples/guide/request_data/urlencoded/tutorial.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
starter_project_folder: "project"
commands:
- command: "cargo px c"
expected_outcome: "success"
snippets:
- name: "installation"
source_path: "src/blueprint.rs"
ranges: [ "0..6", "11..12" ]
hl_lines: [ 6 ]
- name: "extraction"
source_path: "src/urlencoded/routes.rs"
ranges: [ "0..10", "11..12" ]
hl_lines: [ 10 ]
- name: "struct_with_attr"
source_path: "src/urlencoded/routes.rs"
ranges: [ "3..8" ]
hl_lines: [ 2 ]
1 change: 1 addition & 0 deletions docs/guide/request_data/body/deserializers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ They're the family of extractors you'll use most often in your Pavex application
Out of the box, Pavex supports the following formats:

* [JSON](json.md)
* [URL Encoded](urlencoded.md)

## Tower of abstractions

Expand Down
92 changes: 92 additions & 0 deletions docs/guide/request_data/body/deserializers/urlencoded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# UrlEncoded

[`UrlEncodedBody<T>`][UrlEncodedBody] buffers the body in memory and deserializes it as URL-encoded,
according to the type `T` you specify.

## Registration

To use [`UrlEncodedBody<T>`][UrlEncodedBody] in your application you need to register a constructor for it.
You can use [`UrlEncodedBody::register`][UrlEncodedBody::register] to register the default constructor
and error handler:

--8<-- "doc_examples/guide/request_data/urlencoded/project-installation.snap"

1. You also need to register a constructor for [`BufferedBody`][BufferedBody]!
Check out the [BufferedBody guide](../byte_wrappers.md) for more details.

If you're using the default [`ApiKit`](../../../dependency_injection/core_concepts/kits.md),
you don't need to register a constructor for [`BufferedBody`][BufferedBody] manually:
it's already included in the kit.

## Extraction

Inject [`UrlEncodedBody<T>`][UrlEncodedBody] as an input in your components to access the parsed body:

--8<-- "doc_examples/guide/request_data/urlencoded/project-extraction.snap"

## Deserialization

The newly defined struct must be **deserializable**—i.e. it must implement
the [`serde::Deserialize`][serde::Deserialize] trait.
You can derive [`serde::Deserialize`][serde::Deserialize] in most cases.

--8<-- "doc_examples/guide/request_data/urlencoded/project-struct_with_attr.snap"

## Unsupported field types

[`UrlEncodedBody<T>`][UrlEncodedBody] doesn't support deserializing nested structures.
For example, the following can't be deserialized from the wire using [`UrlEncodedBody<T>`][UrlEncodedBody]:

```rust
#[derive(serde::Deserialize)]
pub struct UpdateUserBody {
address: Address
}

#[derive(serde::Deserialize)]
pub struct Address {
street: String,
city: String,
}
```

If you need to deserialize nested structures from an urlencoded body,
you might want to look into writing your own extractor on top of [`serde_qs`](https://crates.io/crates/serde_qs).

## Avoiding allocations

If you want to minimize memory usage, you can try to avoid unnecessary heap memory allocations when deserializing
string-like fields from the body of the incoming request.
Pavex supports this use case—**you can borrow from the request body**.

### Percent-encoding

It is not always possible to avoid allocations when handling an urlencoded body.
A urlencoded body must comply with the restriction of the URI specification:
you can only use [a limited set of characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2).
If you want to use a character not allowed in a URI, you
must [percent-encode it](https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding).
For example, if you want to use a space in a field name or a field value, you must encode it as `%20`.
A string like `John Doe` becomes `John%20Doe` when percent-encoded.

[`UrlEncodedBody<T>`][UrlEncodedBody] automatically decodes percent-encoded strings for you. But that comes at a cost:
Pavex _must_ allocate a new `String` if the route parameter is percent-encoded.

### Cow

We recommend using [`Cow<'_, str>`][Cow] as your field type for string-like parameters.
It borrows from the request's path if possible, it allocates a new `String` if it can't be avoided.

[`Cow<'_, str>`][Cow] strikes a balance between performance and robustness: you don't have to worry about a runtime
error if the route parameter
is percent-encoded, but you tried to use `&str` as its field type.

[BufferedBody]: ../../../../api_reference/pavex/request/body/struct.BufferedBody.html

[UrlEncodedBody]: ../../../../api_reference/pavex/request/body/struct.UrlEncodedBody.html

[UrlEncodedBody::register]: ../../../../api_reference/pavex/request/body/struct.UrlEncodedBody.html#method.register

[serde::Deserialize]: https://docs.rs/serde/latest/serde/trait.Deserialize.html

[Cow]: https://doc.rust-lang.org/std/borrow/enum.Cow.html
59 changes: 59 additions & 0 deletions libs/pavex/src/request/body/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,37 @@ impl ExtractBufferedBodyError {
}
}

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
/// The error returned by [`UrlEncodedBody::extract`] when the extraction fails.
///
/// [`UrlEncodedBody::extract`]: crate::request::body::urlencoded::UrlEncodedBody::extract
pub enum ExtractUrlEncodedBodyError {
#[error(transparent)]
/// See [`MissingUrlEncodedContentType`] for details.
MissingContentType(#[from] MissingUrlEncodedContentType),
#[error(transparent)]
/// See [`UrlEncodedContentTypeMismatch`] for details.
ContentTypeMismatch(#[from] UrlEncodedContentTypeMismatch),
#[error(transparent)]
/// See [`UrlEncodedBodyDeserializationError`] for details.
DeserializationError(#[from] UrlEncodedBodyDeserializationError),
}

impl ExtractUrlEncodedBodyError {
/// Convert an [`ExtractUrlEncodedBodyError`] into an HTTP response.
pub fn into_response(&self) -> Response {
match self {
ExtractUrlEncodedBodyError::MissingContentType(_)
| ExtractUrlEncodedBodyError::ContentTypeMismatch(_) => {
Response::unsupported_media_type()
}
ExtractUrlEncodedBodyError::DeserializationError(_) => Response::bad_request(),
}
.set_typed_body(format!("{}", self))
}
}

#[derive(Debug, thiserror::Error)]
#[error("The request body is larger than the maximum size limit enforced by this server.")]
#[non_exhaustive]
Expand Down Expand Up @@ -109,3 +140,31 @@ pub struct JsonContentTypeMismatch {
/// The actual value of the `Content-Type` header for this request.
pub actual: String,
}

#[derive(Debug, thiserror::Error)]
#[error(
"The `Content-Type` header is missing. This endpoint expects requests with a `Content-Type` header set to `application/x-www-form-urlencoded`"
)]
#[non_exhaustive]
/// The `Content-Type` header is missing, while we expected it to be set to `application/x-www-form-urlencoded`.
pub struct MissingUrlEncodedContentType;

#[derive(Debug, thiserror::Error)]
#[error(
"The `Content-Type` header was set to `{actual}`. This endpoint expects requests with a `Content-Type` header set to `application/x-www-form-urlencoded`"
)]
#[non_exhaustive]
/// The `Content-Type` header not set to `application/x-www-form-urlencoded`.
pub struct UrlEncodedContentTypeMismatch {
/// The actual value of the `Content-Type` header for this request.
pub actual: String,
}

#[derive(Debug, thiserror::Error)]
#[error("Failed to deserialize the body as a urlencoded form.\n{source}")]
#[non_exhaustive]
/// Something went wrong when deserializing the request body into the specified type.
pub struct UrlEncodedBodyDeserializationError {
#[source]
pub(super) source: serde_html_form::de::Error,
}
2 changes: 2 additions & 0 deletions libs/pavex/src/request/body/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ pub use buffered_body::BufferedBody;
pub use json::JsonBody;
pub use limit::BodySizeLimit;
pub use raw_body::RawIncomingBody;
pub use urlencoded::UrlEncodedBody;

mod buffered_body;
pub mod errors;
mod json;
mod limit;
mod raw_body;
mod urlencoded;
Loading

0 comments on commit 0f891d0

Please sign in to comment.