Skip to main content

spikard_core/bindings/
response.rs

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