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