web_static_pack/
responder.rs

1//! Module containing [Responder] - service taking http request (parts) and
2//! returning http responses.
3
4use crate::{
5    body::Body,
6    content_encoding::{ContentContentEncoding, EncodingAccepted},
7    file::File,
8    pack::Pack,
9};
10use http::{
11    HeaderMap, Method, StatusCode, header,
12    response::{Builder as ResponseBuilder, Response as HttpResponse},
13};
14
15/// Http response type specialization.
16pub type Response<'a> = HttpResponse<Body<'a>>;
17
18/// Responder service, providing http response for requests, looking for
19/// [File] in [Pack].
20///
21/// There are two main methods for this type:
22/// - [Self::respond] - generates http response for successful requests and lets
23///   user handle errors manually.
24/// - [Self::respond_flatten] - like above, but generates default responses also
25///   for errors.
26///
27/// # Examples
28///
29/// ```ignore
30/// # use http::StatusCode;
31///
32/// let pack_archived = web_static_pack::loader::load(...).unwrap();
33/// let responder = web_static_pack::responder::Responder::new(pack_archived);
34///
35/// assert_eq!(
36///     responder.respond_flatten(
37///         &Method::GET,
38///         "/present",
39///         &HeaderMap::default(),
40///     ).status(),
41///     StatusCode::OK
42/// );
43/// assert_eq!(
44///     responder.respond_flatten(
45///         &Method::GET,
46///         "/missing",
47///         &HeaderMap::default(),
48///     ).status(),
49///     StatusCode::NOT_FOUND
50/// );
51///
52/// assert_eq!(
53///     responder.respond(
54///         &Method::GET,
55///         "/missing",
56///         &HeaderMap::default(),
57///     ),
58///     Err(ResponderRespondError::PackPathNotFound)
59/// );
60/// ```
61///
62/// For full example, including making a hyper server, see crate level
63/// documentation.
64#[derive(Debug)]
65pub struct Responder<'p, P>
66where
67    P: Pack,
68{
69    pack: &'p P,
70}
71impl<'p, P> Responder<'p, P>
72where
73    P: Pack,
74{
75    /// Creates new instance, based on [Pack].
76    pub const fn new(pack: &'p P) -> Self {
77        Self { pack }
78    }
79
80    /// Returns http response for given request parts or rust error to be
81    /// handled by user.
82    ///
83    /// Inside this method:
84    /// - Checks http method (accepts GET or HEAD).
85    /// - Looks for file inside `pack` passed in constructor.
86    /// - Checks for `ETag` match (and returns 304).
87    /// - Negotiates content encoding.
88    /// - Builds final http response containing header and body (if method is
89    ///   not HEAD).
90    ///
91    /// For alternative handling errors with default http responses see
92    /// [Self::respond_flatten].
93    pub fn respond(
94        &self,
95        method: &Method,
96        path: &str,
97        headers: &HeaderMap,
98    ) -> Result<Response<'p>, ResponderRespondError> {
99        // only GET and HEAD are supported
100        let body_in_response = match *method {
101            Method::GET => true,
102            Method::HEAD => false,
103            _ => {
104                return Err(ResponderRespondError::HttpMethodNotSupported);
105            }
106        };
107
108        // find file for given path
109        let file = match self.pack.get_file_by_path(path) {
110            Some(file_descriptor) => file_descriptor,
111            None => {
112                return Err(ResponderRespondError::PackPathNotFound);
113            }
114        };
115
116        // check for possible `ETag`
117        // if `ETag` exists and matches current file, return 304
118        if let Some(etag_request) = headers.get(header::IF_NONE_MATCH)
119            && etag_request.as_bytes() == file.etag().as_bytes()
120        {
121            let response = ResponseBuilder::new()
122                .status(StatusCode::NOT_MODIFIED)
123                .header(header::ETAG, file.etag()) // https://stackoverflow.com/a/4226409/1658328
124                .body(Body::empty())
125                .unwrap();
126            return Ok(response);
127        };
128
129        // resolve content and content-encoding header
130        let content_content_encoding = ContentContentEncoding::resolve(
131            &match EncodingAccepted::from_headers(headers) {
132                Ok(content_encoding_encoding_accepted) => content_encoding_encoding_accepted,
133                Err(_) => return Err(ResponderRespondError::UnparsableAcceptEncoding),
134            },
135            file,
136        );
137
138        // build final response
139        let response = ResponseBuilder::new()
140            .header(header::CONTENT_TYPE, file.content_type())
141            .header(header::ETAG, file.etag())
142            .header(header::CACHE_CONTROL, file.cache_control().cache_control())
143            .header(
144                header::CONTENT_LENGTH,
145                content_content_encoding.content.len(),
146            )
147            .header(
148                header::CONTENT_ENCODING,
149                content_content_encoding.content_encoding,
150            )
151            .body(if body_in_response {
152                Body::new(content_content_encoding.content)
153            } else {
154                Body::empty()
155            })
156            .unwrap();
157
158        Ok(response)
159    }
160
161    /// Like [Self::respond], but generates "default" (proper http
162    /// status code and empty body) responses also for errors. This will for
163    /// example generate HTTP 404 response for request uri not found in path.
164    ///
165    /// For manual error handling, see [Self::respond].
166    pub fn respond_flatten(
167        &self,
168        method: &Method,
169        path: &str,
170        headers: &HeaderMap,
171    ) -> Response<'p> {
172        match self.respond(method, path, headers) {
173            Ok(response) => response,
174            Err(responder_error) => responder_error.into_response(),
175        }
176    }
177}
178
179/// Possible errors during [Responder::respond] handling.
180#[derive(PartialEq, Eq, Debug)]
181pub enum ResponderRespondError {
182    /// Not supported HTTP Method, this maps to HTTP `METHOD_NOT_ALLOWED`.
183    HttpMethodNotSupported,
184
185    /// Request URI was not found in [Pack]. This maps to HTTP `NOT_FOUND`.
186    PackPathNotFound,
187
188    /// Error while parsing HTTP `Accept-Encoding`. This maps to HTTP
189    /// `BAD_REQUEST`.
190    UnparsableAcceptEncoding,
191}
192impl ResponderRespondError {
193    /// Converts error into best matching HTTP error code.
194    pub fn status_code(&self) -> StatusCode {
195        match self {
196            ResponderRespondError::HttpMethodNotSupported => StatusCode::METHOD_NOT_ALLOWED,
197            ResponderRespondError::PackPathNotFound => StatusCode::NOT_FOUND,
198            ResponderRespondError::UnparsableAcceptEncoding => StatusCode::BAD_REQUEST,
199        }
200    }
201
202    /// Creates default response (status code + empty body) for this error.
203    pub fn into_response(&self) -> Response<'static> {
204        let response = ResponseBuilder::new()
205            .status(self.status_code())
206            .body(Body::empty())
207            .unwrap();
208        response
209    }
210}
211
212#[cfg(test)]
213mod test_responder {
214    use super::{Responder, ResponderRespondError};
215    use crate::{cache_control::CacheControl, file::File, pack::Pack};
216    use anyhow::anyhow;
217    use http::{HeaderMap, HeaderName, HeaderValue, header, method::Method, status::StatusCode};
218
219    struct FileMock;
220    impl File for FileMock {
221        fn content(&self) -> &[u8] {
222            b"content-identity"
223        }
224        fn content_gzip(&self) -> Option<&[u8]> {
225            None
226        }
227        fn content_brotli(&self) -> Option<&[u8]> {
228            Some(b"content-br")
229        }
230
231        fn content_type(&self) -> HeaderValue {
232            HeaderValue::from_static("text/plain; charset=utf-8")
233        }
234        fn etag(&self) -> HeaderValue {
235            HeaderValue::from_static("\"etagvalue\"")
236        }
237        fn cache_control(&self) -> CacheControl {
238            CacheControl::MaxCache
239        }
240    }
241
242    struct PackMock;
243    impl Pack for PackMock {
244        type File = FileMock;
245
246        fn get_file_by_path(
247            &self,
248            path: &str,
249        ) -> Option<&Self::File> {
250            match path {
251                "/present" => Some(&FileMock),
252                _ => None,
253            }
254        }
255    }
256
257    static RESPONDER: Responder<'static, PackMock> = Responder::new(&PackMock);
258
259    fn header_as_string(
260        headers: &HeaderMap,
261        name: HeaderName,
262    ) -> &str {
263        headers
264            .get(&name)
265            .ok_or_else(|| anyhow!("missing header {name}"))
266            .unwrap()
267            .to_str()
268            .unwrap()
269    }
270
271    #[test]
272    fn resolves_typical_request() {
273        let response = RESPONDER
274            .respond(
275                &Method::GET,
276                "/present",
277                &[
278                    (
279                        header::ACCEPT_ENCODING,
280                        HeaderValue::from_static("br, gzip"),
281                    ),
282                    (
283                        header::IF_NONE_MATCH,
284                        HeaderValue::from_static("\"invalidetag\""),
285                    ),
286                ]
287                .into_iter()
288                .collect::<HeaderMap>(),
289            )
290            .unwrap();
291
292        let headers = response.headers();
293
294        assert_eq!(response.status(), StatusCode::OK);
295
296        assert_eq!(
297            header_as_string(headers, header::CONTENT_TYPE),
298            "text/plain; charset=utf-8"
299        );
300        assert_eq!(
301            header_as_string(headers, header::ETAG), // line break
302            "\"etagvalue\""
303        );
304        assert_eq!(
305            header_as_string(headers, header::CACHE_CONTROL), // line break
306            "max-age=31536000, immutable"
307        );
308        assert_eq!(
309            header_as_string(headers, header::CONTENT_LENGTH), // line break
310            "10"
311        );
312        assert_eq!(
313            header_as_string(headers, header::CONTENT_ENCODING), // line break
314            "br"
315        );
316
317        assert_eq!(response.body().data(), b"content-br");
318    }
319
320    #[test]
321    fn resolves_no_body_for_head_request() {
322        let response = RESPONDER
323            .respond(&Method::HEAD, "/present", &HeaderMap::default())
324            .unwrap();
325        let headers = response.headers();
326
327        assert_eq!(response.status(), StatusCode::OK);
328
329        assert_eq!(
330            header_as_string(headers, header::CONTENT_TYPE),
331            "text/plain; charset=utf-8"
332        );
333        assert_eq!(
334            header_as_string(headers, header::ETAG), // line break
335            "\"etagvalue\""
336        );
337        assert_eq!(
338            header_as_string(headers, header::CONTENT_LENGTH), // line break
339            "16"
340        );
341        assert_eq!(
342            header_as_string(headers, header::CONTENT_ENCODING),
343            "identity"
344        );
345
346        assert_eq!(response.body().data(), b"");
347    }
348
349    #[test]
350    fn resolves_not_modified_for_matching_etag() {
351        let response = RESPONDER
352            .respond(
353                &Method::GET,
354                "/present",
355                &[(
356                    header::IF_NONE_MATCH,
357                    HeaderValue::from_static("\"etagvalue\""),
358                )]
359                .into_iter()
360                .collect::<HeaderMap>(),
361            )
362            .unwrap();
363        let headers = response.headers();
364
365        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
366
367        // `ETag` should be resent, others should be missing
368        assert_eq!(
369            header_as_string(headers, header::ETAG), // line break
370            "\"etagvalue\""
371        );
372        assert!(headers.get(header::CONTENT_TYPE).is_none());
373
374        // of course no body
375        assert_eq!(response.body().data(), b"");
376    }
377
378    #[test]
379    fn resolves_error_for_invalid_method() {
380        let response_error = RESPONDER
381            .respond(&Method::POST, "/present", &HeaderMap::default())
382            .unwrap_err();
383        assert_eq!(
384            response_error,
385            ResponderRespondError::HttpMethodNotSupported
386        );
387
388        let response_flatten = response_error.into_response();
389        assert_eq!(response_flatten.status(), StatusCode::METHOD_NOT_ALLOWED);
390    }
391
392    #[test]
393    fn resolves_error_for_file_not_found() {
394        let response_error = RESPONDER
395            .respond(&Method::GET, "/missing", &HeaderMap::default())
396            .unwrap_err();
397        assert_eq!(response_error, ResponderRespondError::PackPathNotFound);
398
399        let response_flatten = response_error.into_response();
400        assert_eq!(response_flatten.status(), StatusCode::NOT_FOUND);
401    }
402}