spikard_core/bindings/
response.rs

1use crate::CompressionConfig;
2use brotli::CompressorWriter;
3use flate2::Compression;
4use flate2::write::GzEncoder;
5use std::collections::HashMap;
6use std::io::Write;
7
8/// Minimal response container shared by bindings.
9#[derive(Clone, Debug)]
10pub struct RawResponse {
11    pub status: u16,
12    pub headers: HashMap<String, String>,
13    pub body: Vec<u8>,
14}
15
16impl RawResponse {
17    /// Construct a new response.
18    #[must_use]
19    #[allow(clippy::missing_const_for_fn)]
20    pub fn new(status: u16, headers: HashMap<String, String>, body: Vec<u8>) -> Self {
21        Self { status, headers, body }
22    }
23
24    /// Apply compression filters if the response qualifies.
25    pub fn apply_compression(&mut self, request_headers: &HashMap<String, String>, compression: &CompressionConfig) {
26        if self.body.is_empty() || self.status == 206 {
27            return;
28        }
29        if self
30            .headers
31            .keys()
32            .any(|key| key.eq_ignore_ascii_case("content-encoding"))
33        {
34            return;
35        }
36        if self.body.len() < compression.min_size {
37            return;
38        }
39
40        let accept_encoding = header_value(request_headers, "Accept-Encoding").map(str::to_ascii_lowercase);
41        let accepts_brotli = accept_encoding.as_ref().is_some_and(|value| value.contains("br"));
42        if compression.brotli && accepts_brotli && self.try_compress_brotli(compression) {
43            return;
44        }
45
46        let accepts_gzip = accept_encoding.as_ref().is_some_and(|value| value.contains("gzip"));
47        if compression.gzip && accepts_gzip {
48            self.try_compress_gzip(compression);
49        }
50    }
51
52    fn try_compress_brotli(&mut self, compression: &CompressionConfig) -> bool {
53        let quality = compression.quality.min(11);
54        let mut writer = CompressorWriter::new(Vec::new(), 4096, quality, 22);
55        if writer.write_all(&self.body).is_err() || writer.flush().is_err() {
56            return false;
57        }
58        let compressed = writer.into_inner();
59        if compressed.is_empty() {
60            return false;
61        }
62        self.finalize_encoded_body("br", compressed);
63        true
64    }
65
66    fn try_compress_gzip(&mut self, compression: &CompressionConfig) -> bool {
67        let mut encoder = GzEncoder::new(Vec::new(), Compression::new(compression.quality));
68        if encoder.write_all(&self.body).is_err() {
69            return false;
70        }
71        let compressed = encoder.finish().unwrap_or_else(|_| Vec::new());
72        if compressed.is_empty() {
73            return false;
74        }
75        self.finalize_encoded_body("gzip", compressed);
76        true
77    }
78
79    fn finalize_encoded_body(&mut self, encoding: &str, compressed: Vec<u8>) {
80        self.body = compressed;
81        self.headers
82            .insert("content-encoding".to_string(), encoding.to_string());
83        self.headers.insert("vary".to_string(), "Accept-Encoding".to_string());
84        self.headers
85            .insert("content-length".to_string(), self.body.len().to_string());
86    }
87}
88
89fn header_value<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
90    headers.iter().find_map(|(key, value)| {
91        if key.eq_ignore_ascii_case(name) {
92            Some(value.as_str())
93        } else {
94            None
95        }
96    })
97}
98
99/// Pre-rendered static asset produced by the CLI bundler.
100#[derive(Clone, Debug)]
101pub struct StaticAsset {
102    pub route: String,
103    pub headers: HashMap<String, String>,
104    pub body: Vec<u8>,
105}
106
107impl StaticAsset {
108    /// Build a response snapshot if the incoming request targets this asset.
109    #[must_use]
110    pub fn serve(&self, method: &str, normalized_path: &str) -> Option<RawResponse> {
111        if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
112            return None;
113        }
114        if self.route != normalized_path {
115            return None;
116        }
117
118        let mut headers = self.headers.clone();
119        headers
120            .entry("content-length".to_string())
121            .or_insert_with(|| self.body.len().to_string());
122        let body = if method.eq_ignore_ascii_case("HEAD") {
123            Vec::new()
124        } else {
125            self.body.clone()
126        };
127
128        Some(RawResponse::new(200, headers, body))
129    }
130}