Step 3 of 7
JSON in, JSON out
JSON APIs send JSON in request bodies and get JSON back. With serde (from the serde module), a handler is three moves: deserialize the body into a request type, check it, serialize the result.
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u32,
name: String,
email: String,
}
struct Response {
status: u16,
body: String,
}
fn json_response<T: Serialize>(status: u16, value: &T) -> Response {
Response { status, body: serde_json::to_string(value).expect("response types always serialize") }
}
fn create_user(body: &str, next_id: u32) -> Response {
let input: CreateUser = match serde_json::from_str(body) {
Ok(input) => input,
Err(e) => return json_response(400, &json!({ "error": format!("invalid JSON: {e}") })),
};
if !input.email.contains('@') {
return json_response(422, &json!({ "error": "email must contain @" }));
}
let user = User { id: next_id, name: input.name, email: input.email };
json_response(201, &user)
}
fn main() {
for body in [r#"{"name": "Ana", "email": "[email protected]"}"#, r#"{"name": "Bo", "email": "bo"}"#, r#"{"name": "Cy"}"#] {
let r = create_user(body, 7);
println!("{} {}", r.status, r.body);
}
}
201 {"id":7,"name":"Ana","email":"[email protected]"}
422 {"error":"email must contain @"}
400 {"error":"invalid JSON: missing field `email` at line 1 column 14"}
Piece by piece:
CreateUserandUserare separate types on purpose. These DTOs (data transfer objects) describe exactly what crosses the wire: the client doesn't send anid(the server assigns it), and a response type is where you leave out fields that must never leave the server (a password hash, internal flags). Changing your internal model then doesn't silently change your API.json_responseis a generic helper: anyT: Serializebecomes a response with a status. Errors use the same shape every time,{"error": "..."}, so clients can handle them in one place.- 400 vs 422: 400 when the body isn't valid JSON or has the wrong shape; 422 when it parses fine but breaks a business rule. (Some APIs use 400 for both; pick one convention and stick to it.)
- Real responses also carry a
Content-Type: application/jsonheader; frameworks set it for you when you return JSON.
Common mistakes
- Deserializing straight into your database model: a client could then send
"id": 1or"is_admin": trueand have it accepted. Request DTOs list only what clients may set. unwrap()on the request body. One malformed request would crash the handler (in a server, the panic usually just drops that connection, or becomes a 500 if you add a catch-panic middleware; either way the client gets no useful answer and your logs fill with panics). Bad input is a 400, not a crash.- Building JSON by hand with
format!("{{\"name\":\"{}\"}}", name): a name containing"produces broken JSON (and an injection risk). Let serde escape it.
Your task
Write create_ticket. The body looks like {"title": "Printer on fire", "priority": "high"}; priority is optional and defaults to normal. Respond with:
400and{"error":"invalid request body"}if the body doesn't parse (including an unknown priority),422and{"error":"title must not be empty"}if the title is empty after trimming,- otherwise
201with the ticket:{"id":<next_id>,"title":"<trimmed title>","priority":"high","status":"open"}.
Challenge 2
Write the two helpers every handler uses: fn json_response<T: Serialize>(status: u16, value: &T) -> Response (the value serialized as compact JSON) and fn error_response(status: u16, message: &str) -> Response (the body {"error":"<message>"}, built with json! so quotes in the message are escaped).
Challenge 3
PATCH updates only the fields the client sends. Write fn patch_task(task: &mut Task, body: &str) -> Response where the body may contain title and/or done. Answer 400 + {"error":"invalid request body"} if it doesn't parse, 422 + {"error":"title must not be empty"} for a blank title (and then change nothing, not even done), otherwise apply the present fields (trim the title) and answer 200 with the whole task as JSON.
Previous: Routing by method and path Next: Errors become status codes