churust_json/lib.rs
1//! JSON support for the [Churust](https://docs.rs/churust) web framework.
2//!
3//! This crate provides two things:
4//!
5//! * [`Json<T>`] — a dual-purpose wrapper that **deserializes** a JSON request
6//! body when used as a handler argument (via [`FromCall`]) and **serializes**
7//! `T` to a `application/json` response when used as a handler return value
8//! (via [`IntoResponse`]).
9//!
10//! * [`ContentNegotiation`] — an optional [`Plugin`] that intercepts plain-text
11//! error responses (status ≥ 400) and re-encodes them as structured JSON so
12//! that API clients always receive a consistent `{"error":"…","status":N}`
13//! body.
14//!
15//! # Quick start
16//!
17//! ```
18//! use churust_core::{Churust, Call, TestClient};
19//! use churust_json::{Json, ContentNegotiation};
20//! use serde::{Deserialize, Serialize};
21//!
22//! #[derive(Serialize, Deserialize)]
23//! struct Greeting { name: String }
24//!
25//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
26//! let app = Churust::server()
27//! .install(ContentNegotiation::new())
28//! .routing(|r| {
29//! r.post("/greet", |Json(body): Json<Greeting>| async move {
30//! Json(Greeting { name: format!("Hello, {}!", body.name) })
31//! });
32//! })
33//! .build();
34//!
35//! let res = TestClient::new(app)
36//! .post("/greet")
37//! .header("content-type", "application/json")
38//! .body(r#"{"name":"Alice"}"#)
39//! .send()
40//! .await;
41//!
42//! assert_eq!(res.status().as_u16(), 200);
43//! assert_eq!(res.header("content-type"), Some("application/json"));
44//! # });
45//! ```
46
47#![deny(missing_docs)]
48
49use async_trait::async_trait;
50use bytes::Bytes;
51use churust_core::{
52 AppBuilder, Call, Error, FromCall, IntoResponse, Middleware, Next, Phase, Plugin, Response,
53 Result,
54};
55use http::header::{CONTENT_LENGTH, CONTENT_TYPE};
56use http::{HeaderValue, Method};
57use serde::de::DeserializeOwned;
58use serde::Serialize;
59use std::sync::Arc;
60
61/// A JSON extractor and responder.
62///
63/// `Json<T>` wraps any value `T` and grants it two complementary roles inside
64/// a Churust handler:
65///
66/// ## As an extractor (handler argument)
67///
68/// When placed as the **last** handler argument, `Json<T>` reads the entire
69/// request body and attempts to deserialize it as JSON into `T`. `T` must
70/// implement [`serde::de::DeserializeOwned`].
71///
72/// The request must declare a JSON content type — `application/json`, or any
73/// `+json` structured suffix. Anything else is **415 Unsupported Media Type**,
74/// which is what keeps a cross-origin HTML form (whose three possible content
75/// types send no CORS preflight) from reaching a JSON handler with the
76/// visitor's cookies attached.
77///
78/// If the body is missing or malformed the framework returns an HTTP **400
79/// Bad Request** response automatically — the handler is never called.
80///
81/// ```
82/// use churust_core::{Churust, TestClient};
83/// use churust_json::Json;
84/// use serde::Deserialize;
85///
86/// #[derive(Deserialize)]
87/// struct CreateUser { username: String }
88///
89/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
90/// let app = Churust::server()
91/// .routing(|r| {
92/// r.post("/users", |Json(body): Json<CreateUser>| async move {
93/// format!("created {}", body.username)
94/// });
95/// })
96/// .build();
97///
98/// let res = TestClient::new(app)
99/// .post("/users")
100/// .header("content-type", "application/json")
101/// .body(r#"{"username":"bob"}"#)
102/// .send()
103/// .await;
104/// assert_eq!(res.status().as_u16(), 200);
105/// # });
106/// ```
107///
108/// ## As a responder (return value)
109///
110/// Wrapping any `T: Serialize` in `Json` and returning it from a handler
111/// serializes `T` to JSON and sets the `Content-Type` header to
112/// `application/json`.
113///
114/// If serialization fails at runtime (extremely rare for well-formed types)
115/// the framework returns an HTTP **500 Internal Server Error**.
116///
117/// ```
118/// use churust_core::{Churust, Call, TestClient};
119/// use churust_json::Json;
120/// use serde::Serialize;
121///
122/// #[derive(Serialize)]
123/// struct Status { ok: bool }
124///
125/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
126/// let app = Churust::server()
127/// .routing(|r| {
128/// r.get("/status", |_c: Call| async { Json(Status { ok: true }) });
129/// })
130/// .build();
131///
132/// let res = TestClient::new(app).get("/status").send().await;
133/// assert_eq!(res.status().as_u16(), 200);
134/// assert_eq!(res.header("content-type"), Some("application/json"));
135/// # });
136/// ```
137///
138/// ## Pattern destructuring
139///
140/// The inner value is accessed through `.0` or via pattern destructuring:
141///
142/// ```
143/// use churust_json::Json;
144/// use serde::Deserialize;
145///
146/// #[derive(Deserialize, Debug, PartialEq)]
147/// struct Point { x: f64, y: f64 }
148///
149/// // Pattern destructuring extracts the inner value directly.
150/// let Json(point) = Json(Point { x: 1.0, y: 2.0 });
151/// assert_eq!(point, Point { x: 1.0, y: 2.0 });
152/// ```
153#[derive(Debug, Clone)]
154pub struct Json<T>(
155 /// The inner value being wrapped.
156 ///
157 /// Access it with `.0` or destructure: `let Json(value) = json_wrapper;`.
158 pub T,
159);
160
161#[async_trait]
162impl<T> FromCall for Json<T>
163where
164 T: DeserializeOwned + Send,
165{
166 async fn from_call(mut call: Call) -> Result<Self> {
167 require_json_content_type(&call)?;
168 let bytes = call.try_receive_bytes().await?;
169 churust_core::check_body_limit(&call, bytes.len())?;
170 let value = serde_json::from_slice::<T>(&bytes)
171 .map_err(|e| Error::bad_request(format!("invalid JSON body: {e}")))?;
172 Ok(Json(value))
173 }
174}
175
176/// Refuse a body that is not declared as JSON.
177///
178/// Not merely tidiness. The three content types an HTML form can send —
179/// `text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data` —
180/// make a *simple* cross-origin request: no preflight, so the CORS layer is
181/// never consulted, and the browser attaches the victim's cookies. Deserialising
182/// such a body as JSON makes every state-changing endpoint executable from any
183/// site the victim visits. Requiring a JSON type forces a preflight, which is
184/// what puts CORS back in the path.
185///
186/// `Form<T>` and `Multipart` have always done this; only the JSON path did not.
187///
188/// Structured suffixes (`application/problem+json`, `application/vnd.api+json`)
189/// are JSON by definition and are accepted — they cannot be produced by a form.
190fn require_json_content_type(call: &Call) -> Result<()> {
191 let raw = call
192 .header(http::header::CONTENT_TYPE.as_str())
193 .unwrap_or("");
194 // Compare the media type only: a charset parameter is legitimate.
195 let media = raw.split(';').next().unwrap_or("").trim();
196 let ok = media.eq_ignore_ascii_case("application/json")
197 || media
198 .rsplit_once('+')
199 .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("json"));
200 if ok {
201 return Ok(());
202 }
203 Err(Error::new(
204 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
205 "expected a JSON content type",
206 ))
207}
208
209impl<T> IntoResponse for Json<T>
210where
211 T: Serialize,
212{
213 fn into_response(self) -> Response {
214 match serde_json::to_vec(&self.0) {
215 Ok(bytes) => Response::bytes("application/json", Bytes::from(bytes)),
216 Err(e) => Error::internal(format!("JSON serialization failed: {e}")).into_response(),
217 }
218 }
219}
220
221/// Plugin that converts plain-text error responses into structured JSON.
222///
223/// When installed, `ContentNegotiation` wraps the request pipeline with a
224/// middleware that inspects every outgoing response. If the status is a
225/// client error (4xx) or server error (5xx) **and** the `Content-Type` is
226/// `text/plain`, it re-encodes the body as:
227///
228/// ```json
229/// {"error": "<original message>", "status": <status code>}
230/// ```
231///
232/// This ensures that API clients always receive a machine-readable JSON error
233/// body rather than an opaque string, regardless of where in the framework the
234/// error originated.
235///
236/// # Configuration
237///
238/// | Builder method | Default | Description |
239/// |---|---|---|
240/// | [`pretty`](ContentNegotiation::pretty) | `false` | Pretty-print the JSON error body |
241///
242/// # Example
243///
244/// ```
245/// use churust_core::{Churust, Call, Error, TestClient};
246/// use churust_json::ContentNegotiation;
247///
248/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
249/// let app = Churust::server()
250/// .install(ContentNegotiation::new())
251/// .routing(|r| {
252/// r.get("/fail", |_c: Call| async {
253/// Err::<&str, _>(Error::bad_request("something went wrong"))
254/// });
255/// })
256/// .build();
257///
258/// let res = TestClient::new(app).get("/fail").send().await;
259/// assert_eq!(res.status().as_u16(), 400);
260/// assert_eq!(res.header("content-type"), Some("application/json"));
261///
262/// let body: serde_json::Value = serde_json::from_slice(res.body_bytes()).unwrap();
263/// assert_eq!(body["error"], "something went wrong");
264/// assert_eq!(body["status"], 400);
265/// # });
266/// ```
267#[derive(Debug, Clone, Default)]
268pub struct ContentNegotiation {
269 pretty: bool,
270}
271
272impl ContentNegotiation {
273 /// Creates a new `ContentNegotiation` plugin with default settings.
274 ///
275 /// By default, JSON error bodies are compact (not pretty-printed). Call
276 /// [`pretty`](Self::pretty) on the returned value to change this.
277 ///
278 /// # Example
279 ///
280 /// ```
281 /// use churust_core::{Churust, Call, Error, TestClient};
282 /// use churust_json::ContentNegotiation;
283 ///
284 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
285 /// let app = Churust::server()
286 /// .install(ContentNegotiation::new())
287 /// .routing(|r| {
288 /// r.get("/boom", |_c: Call| async {
289 /// Err::<&str, _>(Error::not_found("no such resource"))
290 /// });
291 /// })
292 /// .build();
293 ///
294 /// let res = TestClient::new(app).get("/boom").send().await;
295 /// assert_eq!(res.header("content-type"), Some("application/json"));
296 /// # });
297 /// ```
298 pub fn new() -> Self {
299 Self::default()
300 }
301
302 /// Controls whether JSON error bodies are pretty-printed.
303 ///
304 /// When `pretty` is `true`, error responses are formatted with newlines and
305 /// indentation, which is helpful during development or when error responses
306 /// may be read by humans. For production APIs, leave this at the default
307 /// `false` to keep response sizes small.
308 ///
309 /// # Parameters
310 ///
311 /// * `pretty` — `true` to enable pretty-printing; `false` (the default) for
312 /// compact output.
313 ///
314 /// # Example
315 ///
316 /// ```
317 /// use churust_core::{Churust, Call, Error, TestClient};
318 /// use churust_json::ContentNegotiation;
319 ///
320 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
321 /// let app = Churust::server()
322 /// .install(ContentNegotiation::new().pretty(true))
323 /// .routing(|r| {
324 /// r.get("/oops", |_c: Call| async {
325 /// Err::<&str, _>(Error::internal("disk full"))
326 /// });
327 /// })
328 /// .build();
329 ///
330 /// let res = TestClient::new(app).get("/oops").send().await;
331 /// assert_eq!(res.status().as_u16(), 500);
332 /// // Pretty-printed JSON contains newlines.
333 /// let text = res.text();
334 /// assert!(text.contains('\n'));
335 /// # });
336 /// ```
337 pub fn pretty(mut self, pretty: bool) -> Self {
338 self.pretty = pretty;
339 self
340 }
341}
342
343impl Plugin for ContentNegotiation {
344 fn install(self: Box<Self>, app: &mut AppBuilder) {
345 app.add_middleware_in(
346 Phase::Plugins,
347 Arc::new(JsonErrors {
348 pretty: self.pretty,
349 }),
350 );
351 }
352}
353
354struct JsonErrors {
355 pretty: bool,
356}
357
358#[async_trait]
359impl Middleware for JsonErrors {
360 async fn handle(&self, call: Call, next: Next) -> Response {
361 // A `HEAD` with no handler of its own is answered by running the `GET`
362 // route and then discarding the body, and that discarding happens at
363 // the endpoint — inside this middleware, not outside it. So a `HEAD`
364 // arrives back here already emptied, and the message this plugin exists
365 // to re-encode is gone before it can be read. The method has to be
366 // remembered now because `call` is consumed by the rest of the chain.
367 let is_head = call.method() == Method::HEAD;
368 let mut res = next.run(call).await;
369 let is_error = res.status.is_client_error() || res.status.is_server_error();
370 let is_text = res
371 .headers
372 .get(CONTENT_TYPE)
373 .and_then(|v| v.to_str().ok())
374 .map(|s| s.starts_with("text/plain"))
375 .unwrap_or(false);
376 if is_error && is_text {
377 match res.body.as_bytes() {
378 // The ordinary case: a buffered plain-text message that becomes
379 // a JSON envelope. The envelope is always longer than the text
380 // it wraps, so any `Content-Length` already on the response now
381 // describes a body that no longer exists. The endpoint sets one
382 // when it strips a `HEAD`, and a handler is free to set one by
383 // hand, so it is removed here and hyper is left to frame the
384 // bytes actually being sent. Leaving the stale value behind is
385 // not a cosmetic slip: hyper's HTTP/1 encoder asserts that a
386 // supplied `Content-Length` matches the payload it is handed,
387 // panics on the mismatch in a debug build, and the connection
388 // dies with the task.
389 Some(buffered) if !buffered.is_empty() => {
390 let msg = String::from_utf8_lossy(buffered).into_owned();
391 let body = serde_json::json!({ "error": msg, "status": res.status.as_u16() });
392 let bytes = if self.pretty {
393 serde_json::to_vec_pretty(&body)
394 } else {
395 serde_json::to_vec(&body)
396 }
397 .unwrap_or_default();
398 res.headers.remove(CONTENT_LENGTH);
399 res.body = churust_core::Body::from(bytes);
400 res.headers
401 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
402 }
403 // A synthesized `HEAD`, whose body was stripped further in. Its
404 // headers must still describe the representation the matching
405 // `GET` would return, so the media type is corrected to the one
406 // this middleware would have produced, but no body is invented:
407 // re-encoding the empty string would give a `HEAD` reply the
408 // `{"error":"","status":N}` payload it never had, and would
409 // announce a message the `GET` does not contain. The stale
410 // `Content-Length` goes too. It counts the plain text, and the
411 // JSON length cannot be derived from it because escaping is not
412 // length-preserving. RFC 9110 §9.3.2 lets a `HEAD` omit a field
413 // it could only learn by generating the content, which is
414 // exactly this; it does not let it quote a size the `GET` will
415 // not deliver.
416 Some(_) if is_head => {
417 res.headers.remove(CONTENT_LENGTH);
418 res.headers
419 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
420 }
421 // An error that really did carry an empty plain-text body, or a
422 // streamed one whose bytes were never buffered. Neither has a
423 // message to lift into an envelope, so both are passed through
424 // exactly as the handler wrote them.
425 _ => {}
426 }
427 }
428 res
429 }
430}
431
432/// Call-style JSON helpers — v1 design §5.1.
433///
434/// The extractor [`Json<T>`] covers the typed-handler style. This trait covers
435/// the `call`-style one, so both halves of Churust's hybrid API can speak JSON:
436///
437/// ```
438/// use churust_core::{Call, Churust, TestClient};
439/// use churust_json::CallJson;
440/// use serde::{Deserialize, Serialize};
441/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
442/// #[derive(Deserialize, Serialize)]
443/// struct Note { text: String }
444///
445/// let app = Churust::server()
446/// .routing(|r| {
447/// r.post("/echo", |mut call: Call| async move {
448/// let note: Note = call.receive_json().await?;
449/// Ok::<_, churust_core::Error>(call.respond_json(¬e))
450/// });
451/// })
452/// .build();
453///
454/// let res = TestClient::new(app)
455/// .post("/echo")
456/// .header("content-type", "application/json")
457/// .body(r#"{"text":"hi"}"#)
458/// .send()
459/// .await;
460/// assert_eq!(res.text(), r#"{"text":"hi"}"#);
461/// # });
462/// ```
463///
464/// Lives here rather than in `churust-core` so the core keeps no `serde_json`
465/// dependency; bring it into scope with `use churust_json::CallJson`.
466#[async_trait::async_trait]
467pub trait CallJson {
468 /// Deserialize the request body as JSON.
469 ///
470 /// Consumes the body. Fails with `400 Bad Request` if it does not parse
471 /// into `T`.
472 async fn receive_json<T: serde::de::DeserializeOwned>(&mut self) -> churust_core::Result<T>;
473
474 /// Build a `200 OK` JSON response from `value`.
475 ///
476 /// Serialization failure yields a `500` rather than panicking: a type that
477 /// cannot serialize is a bug in the application, not in the request.
478 fn respond_json<T: serde::Serialize + Sync>(&self, value: &T) -> churust_core::Response;
479}
480
481#[async_trait::async_trait]
482impl CallJson for churust_core::Call {
483 async fn receive_json<T: serde::de::DeserializeOwned>(&mut self) -> churust_core::Result<T> {
484 // Same rule as the `Json<T>` extractor: the two must not disagree about
485 // what counts as a JSON request.
486 require_json_content_type(self)?;
487 // `try_receive_bytes` and the route limit, matching the `Json<T>`
488 // extractor. `receive_bytes` swallows a read error into an empty
489 // payload, which turned an over-limit body into
490 // `400 invalid JSON body: EOF while parsing a value` instead of `413`,
491 // and skipped the per-route cap entirely.
492 let bytes = self.try_receive_bytes().await?;
493 churust_core::check_body_limit(self, bytes.len())?;
494 serde_json::from_slice::<T>(&bytes)
495 .map_err(|e| churust_core::Error::bad_request(format!("invalid JSON body: {e}")))
496 }
497
498 fn respond_json<T: serde::Serialize + Sync>(&self, value: &T) -> churust_core::Response {
499 match serde_json::to_vec(value) {
500 Ok(body) => churust_core::Response::bytes("application/json", body),
501 Err(_) => churust_core::Error::internal("failed to serialize response").into_response(),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use churust_core::{App, Churust, TestClient};
510 use http::header::CONTENT_LENGTH;
511 use http::{Method, StatusCode};
512 use serde::Deserialize;
513
514 #[derive(Serialize, Deserialize)]
515 struct Echo {
516 msg: String,
517 }
518
519 fn app() -> App {
520 Churust::server()
521 .install(ContentNegotiation::new())
522 .routing(|r| {
523 r.post("/echo", |Json(body): Json<Echo>| async move { Json(body) });
524 r.get("/boom", |_c: Call| async {
525 Err::<&str, _>(Error::bad_request("nope"))
526 });
527 })
528 .build()
529 }
530
531 #[tokio::test]
532 async fn json_round_trips_through_handler() {
533 let client = TestClient::new(app());
534 let res = client
535 .post("/echo")
536 .header("content-type", "application/json")
537 .body(r#"{"msg":"hi"}"#)
538 .send()
539 .await;
540 assert_eq!(res.status(), StatusCode::OK);
541 assert_eq!(res.header("content-type"), Some("application/json"));
542 let parsed: Echo = serde_json::from_slice(res.body_bytes()).unwrap();
543 assert_eq!(parsed.msg, "hi");
544 }
545
546 #[tokio::test]
547 async fn invalid_json_is_400() {
548 // Declared as JSON and malformed: the type was right, the content was
549 // not. A body with no declared type is a different failure — see
550 // `tests/content_type.rs` — and answers 415.
551 let client = TestClient::new(app());
552 let res = client
553 .post("/echo")
554 .header("content-type", "application/json")
555 .body("not json")
556 .send()
557 .await;
558 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
559 }
560
561 #[tokio::test]
562 async fn errors_render_as_json() {
563 let client = TestClient::new(app());
564 let res = client.get("/boom").send().await;
565 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
566 assert_eq!(res.header("content-type"), Some("application/json"));
567 let v: serde_json::Value = serde_json::from_slice(res.body_bytes()).unwrap();
568 assert_eq!(v["error"], "nope");
569 assert_eq!(v["status"], 400);
570 }
571
572 /// A `HEAD` reply is synthesized from the `GET` route, so whatever this
573 /// middleware does to the `GET` representation has to be reflected in the
574 /// headers the `HEAD` carries. RFC 9110 §9.3.2 lets a `HEAD` omit fields
575 /// that are only determined while generating the content, but it never
576 /// lets it describe a representation the matching `GET` would not send.
577 #[tokio::test]
578 async fn a_head_error_reply_agrees_with_the_get_it_stands_in_for() {
579 let client = TestClient::new(app());
580 let get = client.get("/boom").send().await;
581 let head = client.request(Method::HEAD, "/boom").send().await;
582
583 assert_eq!(head.status(), get.status());
584 assert_eq!(
585 head.text(),
586 "",
587 "a HEAD response must not carry a body at all"
588 );
589 assert_eq!(
590 head.header("content-type"),
591 get.header("content-type"),
592 "HEAD advertised a different media type than the GET it stands in for"
593 );
594 if let Some(len) = head.header(CONTENT_LENGTH.as_str()) {
595 assert_eq!(
596 len.parse::<usize>().unwrap(),
597 get.body_bytes().len(),
598 "HEAD promised a size the GET does not deliver"
599 );
600 }
601 }
602}