Skip to main content

apimock_server/
response_handler.rs

1use http_body_util::{BodyExt, Empty, Full};
2use hyper::{
3    HeaderMap, StatusCode,
4    body::{Body, Bytes},
5    header::{
6        ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_LENGTH, HeaderName,
7        HeaderValue, ORIGIN, VARY,
8    },
9    http::response::Builder,
10};
11
12use std::{collections::HashMap, str::FromStr};
13
14use super::{
15    constant::DEFAULT_RESPONSE_HEADERS, response::error_response::internal_server_error_response,
16};
17use crate::types::BoxBody;
18
19#[derive(Clone, Default)]
20pub enum BodyKind {
21    #[default]
22    Empty,
23    Text(String),
24    Binary(Vec<u8>),
25}
26
27#[derive(Default)]
28pub struct ResponseHandler {
29    response_builder: Builder,
30    status: Option<StatusCode>,
31    headers: HashMap<String, Option<String>>,
32    body_kind: BodyKind,
33}
34
35impl ResponseHandler {
36    /// build response
37    pub fn into_response(
38        self,
39        request_headers: &HeaderMap,
40        cors_allow_credentials_origins: &[String],
41    ) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
42        // - body + content-length
43        let response = match self.body_kind {
44            BodyKind::Text(s) => self
45                .response_builder
46                .body(Full::new(Bytes::from(s.to_owned())).boxed()),
47            BodyKind::Binary(b) => self
48                .response_builder
49                .body(Full::new(Bytes::from(b)).boxed()),
50            BodyKind::Empty => self.response_builder.body(Empty::new().boxed()),
51        };
52
53        let mut response = match response {
54            Ok(x) => x,
55            Err(err) => {
56                return internal_server_error_response(
57                    &format!("failed to create response: {}", err),
58                    request_headers,
59                    cors_allow_credentials_origins,
60                );
61            }
62        };
63
64        // - http status code
65        *response.status_mut() = if let Some(status) = self.status {
66            status
67        } else {
68            StatusCode::OK
69        };
70
71        // - content-length
72        let content_length = response.body().size_hint().exact().unwrap_or_default();
73
74        let headers = response.headers_mut();
75
76        headers.insert(CONTENT_LENGTH, HeaderValue::from(content_length));
77
78        // - the other default headers
79        for (header_key, header_value) in
80            default_response_headers(request_headers, cors_allow_credentials_origins).iter()
81        {
82            headers.insert(header_key, header_value.to_owned());
83        }
84
85        // - additional custom headers passed from caller
86        for (header_key, header_value) in self.headers {
87            match HeaderName::from_str(header_key.as_str()) {
88                Ok(header_key) => {
89                    match HeaderValue::from_str(header_value.unwrap_or_default().as_str()) {
90                        Ok(header_value) => {
91                            headers.insert(header_key, header_value);
92                        }
93                        Err(err) => {
94                            log::warn!(
95                                "failed to create header with the header value (header key = {}) ({})",
96                                header_key,
97                                err
98                            );
99                            headers.insert(header_key, HeaderValue::from_static(""));
100                        }
101                    }
102                }
103                Err(err) => log::warn!(
104                    "failed to create header with the header key: {} ({})",
105                    header_key,
106                    err
107                ),
108            };
109        }
110
111        Ok(response)
112    }
113
114    /// set http status code
115    pub fn with_status(mut self, status: &StatusCode) -> Self {
116        self.status = Some(status.to_owned());
117        self
118    }
119
120    /// add custom header
121    pub fn with_header(mut self, key: impl Into<String>, value: Option<impl Into<String>>) -> Self {
122        self.headers.insert(key.into(), value.map(|x| x.into()));
123        self
124    }
125
126    /// add custom headers
127    pub fn with_headers<K, V, I>(mut self, headers: I) -> Self
128    where
129        K: Into<String>,
130        V: Into<String>,
131        I: IntoIterator<Item = (K, Option<V>)>,
132    {
133        for (key, value) in headers {
134            self.headers.insert(key.into(), value.map(|x| x.into()));
135        }
136        self
137    }
138
139    /// Apply an operator's custom `respond.headers`, always **last** —
140    /// after whichever `with_text`/`with_json_body`/`with_binary_body`
141    /// call already set a default `content-type` (RFC 065's override
142    /// rule: an explicit `content-type` always wins over the derived
143    /// default, on every body source, uniformly).
144    ///
145    /// # Why one method instead of each call site's own `if let`
146    ///
147    /// Before this, every response-building function repeated
148    /// `if let Some(custom_headers) = custom_headers { response_handler
149    /// = response_handler.with_headers(custom_headers.to_owned()); }`
150    /// itself, and the two places that got the ordering wrong
151    /// (`json_response`, and `FileResponse`'s own binary-file path) each
152    /// silently let the derived content-type win instead — because
153    /// `self.headers` is a plain `HashMap`, whichever call happens last
154    /// wins for that key, and there was nothing forcing "last" to always
155    /// mean "the custom headers." Routing every call site through this
156    /// one method, called only after the body is set, makes that
157    /// ordering the only way to call it — not a convention that can
158    /// drift a third time.
159    pub fn with_custom_headers(
160        self,
161        custom_headers: Option<&HashMap<String, Option<String>>>,
162    ) -> Self {
163        match custom_headers {
164            Some(custom_headers) => self.with_headers(custom_headers.to_owned()),
165            None => self,
166        }
167    }
168
169    /// add text to body
170    pub fn with_text(mut self, text: impl Into<String>, content_type: Option<&str>) -> Self {
171        let content_type = if let Some(content_type) = content_type {
172            content_type.into()
173        } else {
174            "text/plain; charset=utf-8".to_owned()
175        };
176        self.headers
177            .insert("content-type".into(), Some(content_type));
178
179        self.body_kind = BodyKind::Text(text.into());
180        self
181    }
182
183    /// treat response as json
184    pub fn with_json_body(mut self, body: impl Into<String>) -> Self {
185        self.headers
186            .insert("content-type".into(), Some("application/json".into()));
187        self.body_kind = BodyKind::Text(body.into());
188        self
189    }
190
191    /// treat response as json
192    pub fn with_binary_body(
193        mut self,
194        body: Vec<u8>,
195        content_type: Option<impl Into<String>>,
196    ) -> Self {
197        let content_type = if let Some(content_type) = content_type {
198            content_type.into()
199        } else {
200            "application/octet-stream".to_owned()
201        };
202        self.headers
203            .insert("content-type".into(), Some(content_type));
204
205        self.body_kind = BodyKind::Binary(body);
206
207        self
208    }
209}
210
211/// default response headers key-value pairs.
212///
213/// `cors_allow_credentials_origins` is RFC 067's
214/// `[service].cors_allow_credentials_origins` — exact origin strings
215/// (beyond the implicitly-allowed loopback ones, see
216/// `is_credentialed_reflection_allowed` — private to this crate, not
217/// linked here since rustdoc's public docs can't resolve a private
218/// item) allowed credentialed reflection. Empty for a caller that has
219/// no config in scope (e.g. a fixed 204 preflight built with no
220/// request context) — degrading to the safe, non-credentialed path is
221/// correct there, never the reverse.
222pub fn default_response_headers(
223    request_headers: &HeaderMap,
224    cors_allow_credentials_origins: &[String],
225) -> HeaderMap {
226    let mut header_map_src = Vec::with_capacity(DEFAULT_RESPONSE_HEADERS.len() + 1);
227
228    // resource
229    // - the other default headers but access-control-allow-origin, vary
230    header_map_src.extend(
231        DEFAULT_RESPONSE_HEADERS
232            .iter()
233            .map(|(k, v)| (k.to_string(), v.to_string())),
234    );
235
236    // - access-control-allow-origin, vary
237    //
238    // RFC 067: a credentialed request (Cookie/Authorization present)
239    // only gets its Origin reflected — and Access-Control-Allow-Credentials:
240    // true — when that origin is allowed (see
241    // `is_credentialed_reflection_allowed`). An origin the operator
242    // never named gets exactly the same `origin = None` path a
243    // non-credentialed request takes below: `ACAO: *`, no credentials.
244    // The response is still served either way — refusing credentialed
245    // cross-origin *reads* of it is the browser's job, not this
246    // server's, and erroring here would also break the many requests
247    // that carry a `Cookie` incidentally and need no CORS at all.
248    let origin = if is_likely_authenticated_request(request_headers) {
249        request_headers
250            .get(ORIGIN)
251            .and_then(|value| value.to_str().ok())
252            .filter(|origin| {
253                is_credentialed_reflection_allowed(origin, cors_allow_credentials_origins)
254            })
255            .and_then(|origin| HeaderValue::from_str(origin).ok())
256    } else {
257        None
258    };
259    let (origin, vary) = if let Some(origin) = origin {
260        header_map_src.push((
261            ACCESS_CONTROL_ALLOW_CREDENTIALS.to_string(),
262            "true".to_owned(),
263        ));
264
265        (origin, HeaderValue::from_static("Origin"))
266    } else {
267        (HeaderValue::from_static("*"), HeaderValue::from_static("*"))
268    };
269    header_map_src.push((
270        ACCESS_CONTROL_ALLOW_ORIGIN.to_string(),
271        origin.to_str().unwrap_or_default().to_owned(),
272    ));
273    header_map_src.push((
274        VARY.to_string(),
275        vary.to_str().unwrap_or_default().to_owned(),
276    ));
277
278    // header map
279    header_map_src
280        .iter()
281        .fold(HeaderMap::new(), |mut ret, (header_key, header_value)| {
282            match HeaderName::from_str(header_key) {
283                Ok(header_key) => match HeaderValue::from_str(header_value.as_str()) {
284                    Ok(header_value) => {
285                        ret.insert(header_key, header_value);
286                        ret
287                    }
288                    Err(err) => {
289                        log::warn!(
290                            "only header key set because failed to get header value: {} [key = {}] ({})",
291                            header_value.as_str(),
292                            header_key,
293                            err
294                        );
295                        ret.insert(header_key, HeaderValue::from_static(""));
296                        ret
297                    }
298                },
299                Err(err) => {
300                    log::warn!("failed to set header key: {} ({})", header_key, err);
301                    ret
302                }
303            }
304        })
305}
306
307/// guess if the request is likely related to authentication
308fn is_likely_authenticated_request(request_headers: &HeaderMap) -> bool {
309    request_headers.contains_key("cookie") || request_headers.contains_key("authorization")
310}
311
312/// RFC 067: whether `origin` gets credentialed CORS reflection.
313/// Loopback origins are allowed regardless of config (see
314/// [`is_implicit_loopback_origin`]); every other origin must appear,
315/// exactly, in `cors_allow_credentials_origins`.
316fn is_credentialed_reflection_allowed(
317    origin: &str,
318    cors_allow_credentials_origins: &[String],
319) -> bool {
320    is_implicit_loopback_origin(origin)
321        || cors_allow_credentials_origins
322            .iter()
323            .any(|allowed| allowed == origin)
324}
325
326/// RFC 067 § Design, "the convenience question": `http://localhost:*`
327/// and `http://127.0.0.1:*` are allowed credentialed reflection without
328/// any configuration — a page served from the developer's own machine
329/// is already inside the trust boundary the loopback bind assumes, so
330/// this keeps "front-end on :5173, mock on :3001" working untouched.
331///
332/// Deliberately a plain prefix-plus-suffix check, not a URL parser: the
333/// two exact hosts this recognises don't need one, and getting this
334/// wrong in the permissive direction is the whole class of bug this
335/// RFC exists to close, so the check is written to fail closed on
336/// anything not exactly `http://localhost[:<port>]` or
337/// `http://127.0.0.1[:<port>]` — in particular, `http://localhost.evil.example`
338/// does *not* match (the suffix after the prefix is neither empty nor
339/// `:<digits>`), and neither does `https://localhost` (a scheme
340/// mismatch): only what RFC 067's own examples specify.
341fn is_implicit_loopback_origin(origin: &str) -> bool {
342    for prefix in ["http://localhost", "http://127.0.0.1"] {
343        let Some(rest) = origin.strip_prefix(prefix) else {
344            continue;
345        };
346        if rest.is_empty() {
347            return true;
348        }
349        return rest
350            .strip_prefix(':')
351            .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()));
352    }
353    false
354}