Zero2Prod: Day 2 – Form, FromRequest, serde
A blog visitor can subscribe to the newsletter
- She can submit a form with name and email.
- If both name and email are valid, we should return
200 OK
- Otherwise, return
400 Bad Request
Writing tests
Build a multipart form:
let form = multipart::Form::new()
.text("name", "Oliver Nguyen")
.text("email", "oliver@example.com");
Create a new client and send the request:
let addr = spawn_app();
let client = reqwest::Client::new();
let resp = client
.post(&format!("{}/subscribe", addr))
.multipart(form)
.send()
.await
.expect("Failed to send request");
Define FormData
with #[derive(Serialize)]
#[derive(Serialize)]
struct FormData {
name: String,
email: String,
}
Add some test cases:
async fn subscribe_returns_a_400_for_invalid_form_data() {
struct TestCase {
name: String,
email: String,
expected_error: String,
}
let addr = spawn_app();
let client = reqwest::Client::new();
let testcases = vec![
TestCase {
name: "".to_string(),
email: "oliver@example.com".to_string(),
expected_error: "missing name".to_string(),
},
TestCase {
name: "Oliver Nguyen".to_string(),
email: "".to_string(),
expected_error: "missing email".to_string(),
},
TestCase {
name: "".to_string(),
email: "".to_string(),
expected_error: "missing name and email".to_string(),
},
];
for tc in testcases {
let form = FormData {
name: tc.name,
email: tc.email,
};
let resp = client
.post(&format!("{}/subscribe", addr))
// 👇 .form() will set Content-Type: x-www-form-urlencoded
.form(&form)
.send()
.await
.expect("Failed to send request");
assert_eq!(
resp.status().as_u16(),
400,
"expected 400, got {}",
resp.status()
);
// ... more checks
}
}
Implement /subscribe
- Add a new route
/subscribe
- Declare a struct
SubscribeRequest
to store the form data - Use
#[derive(serde::Deserialize)]
web::Form<SubscribeRequest>
implementsHandler<FromRequest>
App::new().route("/subscribe", web::post().to(subscribe))
#[derive(serde::Deserialize)]
pub struct SubscribeForm {
pub email: String,
pub name: String,
}
// 👇 web::Form<...> expects Content-Type: x-www-form-urlencoded
pub async fn subscribe(form: web::Form<SubscribeForm>) -> impl Responder {
match (form.name.is_empty(), form.email.is_empty()) {
(true, true) => {
return HttpResponse::BadRequest().json(json!({ "error": "missing name and email" }));
}
(false, true) => {
return HttpResponse::BadRequest().json(json!({ "error": "missing email" }));
}
(true, false) => {
return HttpResponse::BadRequest().json(json!({ "error": "missing name" }));
}
(false, false) => HttpResponse::Ok()
.json(json!({ "name": form.name, "email": form.email, "status": "ok" })),
}
}
An example implement of Serialize
trait for Vec<T>
use serde::ser::{Serialize, Serializer, SerializeSeq};
impl<T> Serialize for Vec<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = serializer.serialize_seq(Some(self.len()))?;
for element in self {
seq.serialize_element(element)?;
}
seq.end()
}
}
Form
and FromRequest
#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub struct Form<T>(pub T);
pub trait FromRequest: Sized {
type Error: Into<Error>;
type Future: Future<Output = Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future;
fn extract(req: &HttpRequest) -> Self::Future {
Self::from_request(req, &mut Payload::None)
}
}
impl<T> FromRequest for Form<T>
where
T: DeserializeOwned + 'static,
{
type Error = Error;
type Future = FormExtractFut<T>;
#[inline]
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let FormConfig { limit, err_handler } = FormConfig::from_req(req).clone();
FormExtractFut {
fut: UrlEncoded::new(req, payload).limit(limit),
req: req.clone(),
err_handler,
}
}
}
We can think about fn from_request()
as async fn from_request()
:
fn from_request(
req: &HttpRequest,
payload: &mut Payload
) -> Self::Future
// similar to this pseudo code (async is not supported in trait yet)
async fn from_request(
req: &HttpRequest,
payload: &mut Payload
) -> Result<Self, Self::Error>
FormExtractFut<T>
implements Future<T>
with fn poll()
:
impl<T> Future for FormExtractFut<T>
where
T: DeserializeOwned + 'static,
{
type Output = Result<Form<T>, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let res = ready!(Pin::new(&mut this.fut).poll(cx));
let res = match res {
Err(err) => match &this.err_handler {
Some(err_handler) => Err((err_handler)(err, &this.req)),
None => Err(err.into()),
},
Ok(item) => Ok(Form(item)),
};
Poll::Ready(res)
}
}
Notes
- Using
web::Form<SubscribeForm>
in handler will verify that the request header isx-www-form-urlencoded
. - Using
.form()
in client request will set request header asx-www-form-urlencoded
.
Parse JSON with serde
and write assertions
Using serde_json::Value
and access as resp_body["error"]
:
let resp_body: serde_json::Value = resp.json().await.expect("Failed to parse JSON");
let error_message = resp_body["error"]
.as_str()
.expect("Failed to get error message");
assert_eq!(
error_message, tc.expected_error,
"expected error message '{}', got '{}'",
tc.expected_error, error_message
);
Define and parse with a struct:
#[derive(Deserialize)]
struct SubscribeError {
error: String,
}
// Parse the response body as JSON
let resp_body: SubscribeError = resp.json().await.expect("Failed to parse JSON");
assert_eq!(
resp_body.error, tc.expected_error,
"expected error message '{}', got '{}'",
tc.expected_error, resp_body.error
);
Notes
serde
is a framework for (de)serialization, providing a set of interfaces:Serialize
,Serializer
, etc.serde_json
provides implementation for JSON- It’s efficient due to monomorphization, which generate different code for each concrete type.
- Benefits: zero-cost abstraction - we do not pay any runtime code for generic.
- Downside: the binary will be bigger, compile time is longer.
- Understanding Serde by Joshua Mcguigan
Author
I'm Oliver Nguyen. A software maker working mostly in Go and JavaScript. I enjoy learning and seeing a better version of myself each day. Occasionally spin off new open source projects. Share knowledge and thoughts during my journey. Connect with me on , , , and .