spikard_core/bindings/
response.rs1use 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#[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 #[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 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#[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 #[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}