apimock_server/response/json_response.rs
1use std::collections::HashMap;
2
3use hyper::{HeaderMap, StatusCode};
4use serde_json::Value;
5
6use crate::{
7 response::error_response::internal_server_error_response, response_handler::ResponseHandler,
8 types::BoxBody,
9};
10
11/// JSON response — used both for `respond.json` (RFC 065, `source` is
12/// `None`) and for a `file_path` pointing at a `.json`/`.json5` file or
13/// a CSV converted to JSON (`source` names the file, for the server
14/// log only — see below).
15///
16/// Parses with the same JSON5 parser `Respond::validate` already
17/// checked this content against at load time (`apimock_routing`'s own
18/// `json5` dependency), so the `_ =>` branch below is unreachable for
19/// `respond.json` and for a `.json`/`.json5` `file_path` in ordinary
20/// operation — both are now validated before the server ever starts
21/// (RFC 065 D3). It stays reachable for CSV→JSON conversion (not
22/// content-validated at load, since the source is CSV, not JSON) and
23/// as a defensive fallback if a file changes on disk after startup.
24///
25/// # Why `source` never reaches the response body (RFC 065 D4)
26///
27/// This used to build the client-facing message as `"{file_path}:
28/// invalid json content"`, putting the server's own filesystem path in
29/// front of every client that hit this branch. The path is still
30/// useful — to whoever runs the server, not to whoever's making the
31/// request — so it goes to the server log via `log::error!` instead;
32/// the client gets a message that names the *problem*, not the
33/// server's directory layout.
34pub fn json_response(
35 json_str: &str,
36 status_code: Option<&StatusCode>,
37 custom_headers: Option<&HashMap<String, Option<String>>>,
38 request_headers: &HeaderMap,
39 source: Option<&str>,
40 cors_allow_credentials_origins: &[String],
41) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
42 match json5::from_str::<Value>(json_str) {
43 Ok(content) => {
44 let body = content.to_string();
45 let mut response_handler = ResponseHandler::default().with_json_body(body.as_str());
46 if let Some(status_code) = status_code {
47 response_handler = response_handler.with_status(status_code);
48 }
49 response_handler
50 .with_custom_headers(custom_headers)
51 .into_response(request_headers, cors_allow_credentials_origins)
52 }
53 Err(err) => {
54 log::error!(
55 "invalid json content{}: {}",
56 source.map(|s| format!(" ({})", s)).unwrap_or_default(),
57 err
58 );
59 internal_server_error_response(
60 "invalid json content",
61 request_headers,
62 cors_allow_credentials_origins,
63 )
64 }
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 //! RFC 076: this function still parses and reserialises (used by
71 //! inline `respond.json` and by `.json5` `file_path`s — converting
72 //! JSON5 is the point for both, so neither is served raw). What
73 //! must not regress is *key order*: the workspace-wide
74 //! `serde_json/preserve_order` feature is what makes that hold —
75 //! without it, this test fails by alphabetising `zebra`/`apple`.
76 use hyper::HeaderMap;
77
78 use super::json_response;
79
80 #[tokio::test]
81 async fn key_order_survives_the_parse_and_reserialise_round_trip() {
82 let response = json_response(
83 r#"{"zebra":1,"apple":2}"#,
84 None,
85 None,
86 &HeaderMap::new(),
87 None,
88 &[],
89 )
90 .unwrap();
91
92 assert_eq!(response.status(), hyper::StatusCode::OK);
93 let body = http_body_util::BodyExt::collect(response.into_body())
94 .await
95 .unwrap()
96 .to_bytes();
97 assert_eq!(
98 body.as_ref(),
99 br#"{"zebra":1,"apple":2}"#,
100 "key order must survive the round trip, not be alphabetised"
101 );
102 }
103}