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_TYPE;
56use http::HeaderValue;
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/// If the body is missing or malformed the framework returns an HTTP **400
73/// Bad Request** response automatically — the handler is never called.
74///
75/// ```
76/// use churust_core::{Churust, TestClient};
77/// use churust_json::Json;
78/// use serde::Deserialize;
79///
80/// #[derive(Deserialize)]
81/// struct CreateUser { username: String }
82///
83/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
84/// let app = Churust::server()
85///     .routing(|r| {
86///         r.post("/users", |Json(body): Json<CreateUser>| async move {
87///             format!("created {}", body.username)
88///         });
89///     })
90///     .build();
91///
92/// let res = TestClient::new(app)
93///     .post("/users")
94///     .body(r#"{"username":"bob"}"#)
95///     .send()
96///     .await;
97/// assert_eq!(res.status().as_u16(), 200);
98/// # });
99/// ```
100///
101/// ## As a responder (return value)
102///
103/// Wrapping any `T: Serialize` in `Json` and returning it from a handler
104/// serializes `T` to JSON and sets the `Content-Type` header to
105/// `application/json`.
106///
107/// If serialization fails at runtime (extremely rare for well-formed types)
108/// the framework returns an HTTP **500 Internal Server Error**.
109///
110/// ```
111/// use churust_core::{Churust, Call, TestClient};
112/// use churust_json::Json;
113/// use serde::Serialize;
114///
115/// #[derive(Serialize)]
116/// struct Status { ok: bool }
117///
118/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
119/// let app = Churust::server()
120///     .routing(|r| {
121///         r.get("/status", |_c: Call| async { Json(Status { ok: true }) });
122///     })
123///     .build();
124///
125/// let res = TestClient::new(app).get("/status").send().await;
126/// assert_eq!(res.status().as_u16(), 200);
127/// assert_eq!(res.header("content-type"), Some("application/json"));
128/// # });
129/// ```
130///
131/// ## Pattern destructuring
132///
133/// The inner value is accessed through `.0` or via pattern destructuring:
134///
135/// ```
136/// use churust_json::Json;
137/// use serde::Deserialize;
138///
139/// #[derive(Deserialize, Debug, PartialEq)]
140/// struct Point { x: f64, y: f64 }
141///
142/// // Pattern destructuring extracts the inner value directly.
143/// let Json(point) = Json(Point { x: 1.0, y: 2.0 });
144/// assert_eq!(point, Point { x: 1.0, y: 2.0 });
145/// ```
146#[derive(Debug, Clone)]
147pub struct Json<T>(
148    /// The inner value being wrapped.
149    ///
150    /// Access it with `.0` or destructure: `let Json(value) = json_wrapper;`.
151    pub T,
152);
153
154#[async_trait]
155impl<T> FromCall for Json<T>
156where
157    T: DeserializeOwned + Send,
158{
159    async fn from_call(mut call: Call) -> Result<Self> {
160        let bytes = call.receive_bytes().await;
161        let value = serde_json::from_slice::<T>(&bytes)
162            .map_err(|e| Error::bad_request(format!("invalid JSON body: {e}")))?;
163        Ok(Json(value))
164    }
165}
166
167impl<T> IntoResponse for Json<T>
168where
169    T: Serialize,
170{
171    fn into_response(self) -> Response {
172        match serde_json::to_vec(&self.0) {
173            Ok(bytes) => Response::bytes("application/json", Bytes::from(bytes)),
174            Err(e) => Error::internal(format!("JSON serialization failed: {e}")).into_response(),
175        }
176    }
177}
178
179/// Plugin that converts plain-text error responses into structured JSON.
180///
181/// When installed, `ContentNegotiation` wraps the request pipeline with a
182/// middleware that inspects every outgoing response.  If the status is a
183/// client error (4xx) or server error (5xx) **and** the `Content-Type` is
184/// `text/plain`, it re-encodes the body as:
185///
186/// ```json
187/// {"error": "<original message>", "status": <status code>}
188/// ```
189///
190/// This ensures that API clients always receive a machine-readable JSON error
191/// body rather than an opaque string, regardless of where in the framework the
192/// error originated.
193///
194/// # Configuration
195///
196/// | Builder method | Default | Description |
197/// |---|---|---|
198/// | [`pretty`](ContentNegotiation::pretty) | `false` | Pretty-print the JSON error body |
199///
200/// # Example
201///
202/// ```
203/// use churust_core::{Churust, Call, Error, TestClient};
204/// use churust_json::ContentNegotiation;
205///
206/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
207/// let app = Churust::server()
208///     .install(ContentNegotiation::new())
209///     .routing(|r| {
210///         r.get("/fail", |_c: Call| async {
211///             Err::<&str, _>(Error::bad_request("something went wrong"))
212///         });
213///     })
214///     .build();
215///
216/// let res = TestClient::new(app).get("/fail").send().await;
217/// assert_eq!(res.status().as_u16(), 400);
218/// assert_eq!(res.header("content-type"), Some("application/json"));
219///
220/// let body: serde_json::Value = serde_json::from_slice(res.body_bytes()).unwrap();
221/// assert_eq!(body["error"], "something went wrong");
222/// assert_eq!(body["status"], 400);
223/// # });
224/// ```
225#[derive(Debug, Clone, Default)]
226pub struct ContentNegotiation {
227    pretty: bool,
228}
229
230impl ContentNegotiation {
231    /// Creates a new `ContentNegotiation` plugin with default settings.
232    ///
233    /// By default, JSON error bodies are compact (not pretty-printed).  Call
234    /// [`pretty`](Self::pretty) on the returned value to change this.
235    ///
236    /// # Example
237    ///
238    /// ```
239    /// use churust_core::{Churust, Call, Error, TestClient};
240    /// use churust_json::ContentNegotiation;
241    ///
242    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
243    /// let app = Churust::server()
244    ///     .install(ContentNegotiation::new())
245    ///     .routing(|r| {
246    ///         r.get("/boom", |_c: Call| async {
247    ///             Err::<&str, _>(Error::not_found("no such resource"))
248    ///         });
249    ///     })
250    ///     .build();
251    ///
252    /// let res = TestClient::new(app).get("/boom").send().await;
253    /// assert_eq!(res.header("content-type"), Some("application/json"));
254    /// # });
255    /// ```
256    pub fn new() -> Self {
257        Self::default()
258    }
259
260    /// Controls whether JSON error bodies are pretty-printed.
261    ///
262    /// When `pretty` is `true`, error responses are formatted with newlines and
263    /// indentation, which is helpful during development or when error responses
264    /// may be read by humans.  For production APIs, leave this at the default
265    /// `false` to keep response sizes small.
266    ///
267    /// # Parameters
268    ///
269    /// * `pretty` — `true` to enable pretty-printing; `false` (the default) for
270    ///   compact output.
271    ///
272    /// # Example
273    ///
274    /// ```
275    /// use churust_core::{Churust, Call, Error, TestClient};
276    /// use churust_json::ContentNegotiation;
277    ///
278    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
279    /// let app = Churust::server()
280    ///     .install(ContentNegotiation::new().pretty(true))
281    ///     .routing(|r| {
282    ///         r.get("/oops", |_c: Call| async {
283    ///             Err::<&str, _>(Error::internal("disk full"))
284    ///         });
285    ///     })
286    ///     .build();
287    ///
288    /// let res = TestClient::new(app).get("/oops").send().await;
289    /// assert_eq!(res.status().as_u16(), 500);
290    /// // Pretty-printed JSON contains newlines.
291    /// let text = res.text();
292    /// assert!(text.contains('\n'));
293    /// # });
294    /// ```
295    pub fn pretty(mut self, pretty: bool) -> Self {
296        self.pretty = pretty;
297        self
298    }
299}
300
301impl Plugin for ContentNegotiation {
302    fn install(self: Box<Self>, app: &mut AppBuilder) {
303        app.add_middleware_in(
304            Phase::Plugins,
305            Arc::new(JsonErrors {
306                pretty: self.pretty,
307            }),
308        );
309    }
310}
311
312struct JsonErrors {
313    pretty: bool,
314}
315
316#[async_trait]
317impl Middleware for JsonErrors {
318    async fn handle(&self, call: Call, next: Next) -> Response {
319        let mut res = next.run(call).await;
320        let is_error = res.status.is_client_error() || res.status.is_server_error();
321        let is_text = res
322            .headers
323            .get(CONTENT_TYPE)
324            .and_then(|v| v.to_str().ok())
325            .map(|s| s.starts_with("text/plain"))
326            .unwrap_or(false);
327        if is_error && is_text {
328            if let Some(buffered) = res.body.as_bytes() {
329                let msg = String::from_utf8_lossy(buffered).into_owned();
330                let body = serde_json::json!({ "error": msg, "status": res.status.as_u16() });
331                let bytes = if self.pretty {
332                    serde_json::to_vec_pretty(&body)
333                } else {
334                    serde_json::to_vec(&body)
335                }
336                .unwrap_or_default();
337                res.body = churust_core::Body::from(bytes);
338                res.headers
339                    .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
340            }
341        }
342        res
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use churust_core::{App, Churust, TestClient};
350    use http::StatusCode;
351    use serde::Deserialize;
352
353    #[derive(Serialize, Deserialize)]
354    struct Echo {
355        msg: String,
356    }
357
358    fn app() -> App {
359        Churust::server()
360            .install(ContentNegotiation::new())
361            .routing(|r| {
362                r.post("/echo", |Json(body): Json<Echo>| async move { Json(body) });
363                r.get("/boom", |_c: Call| async {
364                    Err::<&str, _>(Error::bad_request("nope"))
365                });
366            })
367            .build()
368    }
369
370    #[tokio::test]
371    async fn json_round_trips_through_handler() {
372        let client = TestClient::new(app());
373        let res = client
374            .post("/echo")
375            .header("content-type", "application/json")
376            .body(r#"{"msg":"hi"}"#)
377            .send()
378            .await;
379        assert_eq!(res.status(), StatusCode::OK);
380        assert_eq!(res.header("content-type"), Some("application/json"));
381        let parsed: Echo = serde_json::from_slice(res.body_bytes()).unwrap();
382        assert_eq!(parsed.msg, "hi");
383    }
384
385    #[tokio::test]
386    async fn invalid_json_is_400() {
387        let client = TestClient::new(app());
388        let res = client.post("/echo").body("not json").send().await;
389        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
390    }
391
392    #[tokio::test]
393    async fn errors_render_as_json() {
394        let client = TestClient::new(app());
395        let res = client.get("/boom").send().await;
396        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
397        assert_eq!(res.header("content-type"), Some("application/json"));
398        let v: serde_json::Value = serde_json::from_slice(res.body_bytes()).unwrap();
399        assert_eq!(v["error"], "nope");
400        assert_eq!(v["status"], 400);
401    }
402}