Skip to main content

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(&note))
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}