1pub mod cache;
2pub mod compression;
3pub mod config;
4pub mod control;
5pub mod path_matcher;
6pub mod proxy;
7
8use axum::{extract::Extension, Router};
9use cache::{CacheStore, RefreshTrigger};
10use proxy::ProxyState;
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13use std::sync::Arc;
14
15#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CacheStrategy {
19 #[default]
21 All,
22 None,
24 OnlyHtml,
26 NoImages,
28 OnlyImages,
30 OnlyAssets,
32}
33
34impl CacheStrategy {
35 pub fn allows_content_type(&self, content_type: Option<&str>) -> bool {
37 let content_type = content_type
38 .and_then(|value| value.split(';').next())
39 .map(|value| value.trim().to_ascii_lowercase());
40
41 match self {
42 Self::All => true,
43 Self::None => false,
44 Self::OnlyHtml => content_type
45 .as_deref()
46 .is_some_and(|value| value == "text/html" || value == "application/xhtml+xml"),
47 Self::NoImages => !content_type
48 .as_deref()
49 .is_some_and(|value| value.starts_with("image/")),
50 Self::OnlyImages => content_type
51 .as_deref()
52 .is_some_and(|value| value.starts_with("image/")),
53 Self::OnlyAssets => content_type.as_deref().is_some_and(|value| {
54 value.starts_with("image/")
55 || value.starts_with("font/")
56 || value == "text/css"
57 || value == "text/javascript"
58 || value == "application/javascript"
59 || value == "application/x-javascript"
60 || value == "application/json"
61 || value == "application/manifest+json"
62 || value == "application/wasm"
63 || value == "application/xml"
64 || value == "text/xml"
65 }),
66 }
67 }
68}
69
70impl std::fmt::Display for CacheStrategy {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 let value = match self {
73 Self::All => "all",
74 Self::None => "none",
75 Self::OnlyHtml => "only_html",
76 Self::NoImages => "no_images",
77 Self::OnlyImages => "only_images",
78 Self::OnlyAssets => "only_assets",
79 };
80
81 f.write_str(value)
82 }
83}
84
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum CompressStrategy {
89 None,
91 #[default]
93 Brotli,
94 Gzip,
96 Deflate,
98}
99
100impl std::fmt::Display for CompressStrategy {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 let value = match self {
103 Self::None => "none",
104 Self::Brotli => "brotli",
105 Self::Gzip => "gzip",
106 Self::Deflate => "deflate",
107 };
108
109 f.write_str(value)
110 }
111}
112
113#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum CacheStorageMode {
117 #[default]
119 Memory,
120 Filesystem,
122}
123
124impl std::fmt::Display for CacheStorageMode {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 let value = match self {
127 Self::Memory => "memory",
128 Self::Filesystem => "filesystem",
129 };
130
131 f.write_str(value)
132 }
133}
134
135#[derive(Clone, Debug)]
137pub struct RequestInfo<'a> {
138 pub method: &'a str,
140 pub path: &'a str,
142 pub query: &'a str,
144 pub headers: &'a axum::http::HeaderMap,
146}
147
148#[derive(Clone)]
150pub struct CreateProxyConfig {
151 pub proxy_url: String,
153
154 pub include_paths: Vec<String>,
157
158 pub exclude_paths: Vec<String>,
162
163 pub enable_websocket: bool,
167
168 pub forward_get_only: bool,
172
173 pub cache_key_fn: Arc<dyn Fn(&RequestInfo) -> String + Send + Sync>,
177 pub cache_404_capacity: usize,
179
180 pub use_404_meta: bool,
183
184 pub cache_strategy: CacheStrategy,
186
187 pub compress_strategy: CompressStrategy,
189
190 pub cache_storage_mode: CacheStorageMode,
192
193 pub cache_directory: Option<PathBuf>,
195}
196
197impl CreateProxyConfig {
198 pub fn new(proxy_url: String) -> Self {
200 Self {
201 proxy_url,
202 include_paths: vec![],
203 exclude_paths: vec![],
204 enable_websocket: true,
205 forward_get_only: false,
206 cache_key_fn: Arc::new(|req_info| {
207 if req_info.query.is_empty() {
208 format!("{}:{}", req_info.method, req_info.path)
209 } else {
210 format!("{}:{}?{}", req_info.method, req_info.path, req_info.query)
211 }
212 }),
213 cache_404_capacity: 100,
214 use_404_meta: false,
215 cache_strategy: CacheStrategy::All,
216 compress_strategy: CompressStrategy::Brotli,
217 cache_storage_mode: CacheStorageMode::Memory,
218 cache_directory: None,
219 }
220 }
221
222 pub fn with_include_paths(mut self, paths: Vec<String>) -> Self {
224 self.include_paths = paths;
225 self
226 }
227
228 pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
230 self.exclude_paths = paths;
231 self
232 }
233
234 pub fn with_websocket_enabled(mut self, enabled: bool) -> Self {
236 self.enable_websocket = enabled;
237 self
238 }
239
240 pub fn with_forward_get_only(mut self, enabled: bool) -> Self {
242 self.forward_get_only = enabled;
243 self
244 }
245
246 pub fn with_cache_key_fn<F>(mut self, f: F) -> Self
248 where
249 F: Fn(&RequestInfo) -> String + Send + Sync + 'static,
250 {
251 self.cache_key_fn = Arc::new(f);
252 self
253 }
254
255 pub fn with_cache_404_capacity(mut self, capacity: usize) -> Self {
257 self.cache_404_capacity = capacity;
258 self
259 }
260
261 pub fn with_use_404_meta(mut self, enabled: bool) -> Self {
263 self.use_404_meta = enabled;
264 self
265 }
266
267 pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
269 self.cache_strategy = strategy;
270 self
271 }
272
273 pub fn caching_strategy(self, strategy: CacheStrategy) -> Self {
275 self.with_cache_strategy(strategy)
276 }
277
278 pub fn with_compress_strategy(mut self, strategy: CompressStrategy) -> Self {
280 self.compress_strategy = strategy;
281 self
282 }
283
284 pub fn compression_strategy(self, strategy: CompressStrategy) -> Self {
286 self.with_compress_strategy(strategy)
287 }
288
289 pub fn with_cache_storage_mode(mut self, mode: CacheStorageMode) -> Self {
291 self.cache_storage_mode = mode;
292 self
293 }
294
295 pub fn with_cache_directory(mut self, directory: impl Into<PathBuf>) -> Self {
297 self.cache_directory = Some(directory.into());
298 self
299 }
300}
301
302pub fn create_proxy(config: CreateProxyConfig) -> (Router, RefreshTrigger) {
305 let refresh_trigger = RefreshTrigger::new();
306 let cache = CacheStore::with_storage(
307 refresh_trigger.clone(),
308 config.cache_404_capacity,
309 config.cache_storage_mode.clone(),
310 config.cache_directory.clone(),
311 );
312
313 spawn_refresh_listener(cache.clone());
315
316 let proxy_state = Arc::new(ProxyState::new(cache, config));
317
318 let app = Router::new()
319 .fallback(proxy::proxy_handler)
320 .layer(Extension(proxy_state));
321
322 (app, refresh_trigger)
323}
324
325pub fn create_proxy_with_trigger(
327 config: CreateProxyConfig,
328 refresh_trigger: RefreshTrigger,
329) -> Router {
330 let cache = CacheStore::with_storage(
331 refresh_trigger,
332 config.cache_404_capacity,
333 config.cache_storage_mode.clone(),
334 config.cache_directory.clone(),
335 );
336
337 spawn_refresh_listener(cache.clone());
339
340 let proxy_state = Arc::new(ProxyState::new(cache, config));
341
342 Router::new()
343 .fallback(proxy::proxy_handler)
344 .layer(Extension(proxy_state))
345}
346
347fn spawn_refresh_listener(cache: CacheStore) {
349 let mut receiver = cache.refresh_trigger().subscribe();
350
351 tokio::spawn(async move {
352 loop {
353 match receiver.recv().await {
354 Ok(cache::RefreshMessage::All) => {
355 tracing::debug!("Cache refresh triggered: clearing all entries");
356 cache.clear().await;
357 }
358 Ok(cache::RefreshMessage::Pattern(pattern)) => {
359 tracing::debug!(
360 "Cache refresh triggered: clearing entries matching pattern '{}'",
361 pattern
362 );
363 cache.clear_by_pattern(&pattern).await;
364 }
365 Err(e) => {
366 tracing::error!("Refresh trigger channel error: {}", e);
367 break;
368 }
369 }
370 }
371 });
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_cache_strategy_content_types() {
380 assert!(CacheStrategy::All.allows_content_type(None));
381 assert!(!CacheStrategy::None.allows_content_type(Some("text/html")));
382 assert!(CacheStrategy::OnlyHtml.allows_content_type(Some("text/html; charset=utf-8")));
383 assert!(!CacheStrategy::OnlyHtml.allows_content_type(Some("image/png")));
384 assert!(CacheStrategy::NoImages.allows_content_type(Some("text/css")));
385 assert!(!CacheStrategy::NoImages.allows_content_type(Some("image/webp")));
386 assert!(CacheStrategy::OnlyImages.allows_content_type(Some("image/svg+xml")));
387 assert!(!CacheStrategy::OnlyImages.allows_content_type(Some("application/javascript")));
388 assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("application/javascript")));
389 assert!(CacheStrategy::OnlyAssets.allows_content_type(Some("image/png")));
390 assert!(!CacheStrategy::OnlyAssets.allows_content_type(Some("text/html")));
391 assert!(!CacheStrategy::OnlyAssets.allows_content_type(None));
392 }
393
394 #[test]
395 fn test_compress_strategy_display() {
396 assert_eq!(CompressStrategy::default().to_string(), "brotli");
397 assert_eq!(CompressStrategy::None.to_string(), "none");
398 assert_eq!(CompressStrategy::Gzip.to_string(), "gzip");
399 assert_eq!(CompressStrategy::Deflate.to_string(), "deflate");
400 }
401
402 #[tokio::test]
403 async fn test_create_proxy() {
404 let config = CreateProxyConfig::new("http://localhost:8080".to_string());
405 assert_eq!(config.compress_strategy, CompressStrategy::Brotli);
406 let (_app, trigger) = create_proxy(config);
407 trigger.trigger();
408 trigger.trigger_by_key_match("GET:/api/*");
409 }
411}