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}