b030aa7
commit b030aa7
11 files changed
+4135 -0
Showing 11 changed files+4135-0
crates/justapi-core/src/alerting.rscrates/justapi-core/src/audit.rscrates/justapi-core/src/compress.rscrates/justapi-core/src/health.rscrates/justapi-core/src/metrics.rscrates/justapi-core/src/middleware.rscrates/justapi-core/src/panic.rscrates/justapi-core/src/rate_limit.rscrates/justapi-core/src/secrets.rscrates/justapi-core/src/trace_context.rscrates/justapi-core/src/tracing_setup.rs
▼crates/justapi-core/src/alerting.rs
+221-0
| @@ -0,0 +1,221 @@ | ||
1 | +/// Alert severity levels. | |
2 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
3 | +pub enum AlertSeverity { | |
4 | + Info = 0, | |
5 | + Warning = 1, | |
6 | + Critical = 2, | |
7 | +} | |
8 | + | |
9 | +impl std::fmt::Display for AlertSeverity { | |
10 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
11 | + match self { | |
12 | + AlertSeverity::Info => write!(f, "info"), | |
13 | + AlertSeverity::Warning => write!(f, "warning"), | |
14 | + AlertSeverity::Critical => write!(f, "critical"), | |
15 | + } | |
16 | + } | |
17 | +} | |
18 | + | |
19 | +/// Supported alert notification channel types. | |
20 | +#[derive(Debug, Clone)] | |
21 | +pub enum AlertChannel { | |
22 | + Slack, | |
23 | + PagerDuty, | |
24 | + Generic, | |
25 | +} | |
26 | + | |
27 | +/// Configuration for alerting. | |
28 | +#[derive(Debug, Clone)] | |
29 | +pub struct AlertingConfig { | |
30 | + pub webhook_url: Option<String>, | |
31 | + pub min_severity: AlertSeverity, | |
32 | + pub channel: AlertChannel, | |
33 | + pub app_name: String, | |
34 | +} | |
35 | + | |
36 | +impl Default for AlertingConfig { | |
37 | + fn default() -> Self { | |
38 | + Self { | |
39 | + webhook_url: None, | |
40 | + min_severity: AlertSeverity::Warning, | |
41 | + channel: AlertChannel::Generic, | |
42 | + app_name: "justapi".to_string(), | |
43 | + } | |
44 | + } | |
45 | +} | |
46 | + | |
47 | +impl AlertingConfig { | |
48 | + pub fn new(webhook_url: &str, _channel: AlertChannel) -> Self { | |
49 | + Self { | |
50 | + webhook_url: Some(webhook_url.to_string()), | |
51 | + ..Default::default() | |
52 | + } | |
53 | + } | |
54 | + | |
55 | + /// Send an alert. When a webhook_url is configured, dispatches to the | |
56 | + /// appropriate channel. Otherwise, logs the alert at the appropriate level. | |
57 | + pub async fn send_alert(&self, severity: AlertSeverity, message: &str) { | |
58 | + if (severity as u8) < (self.min_severity as u8) { | |
59 | + return; | |
60 | + } | |
61 | + | |
62 | + let msg = format!("[{}] {}: {}", self.app_name, severity, message); | |
63 | + | |
64 | + match severity { | |
65 | + AlertSeverity::Info => tracing::info!("{}", msg), | |
66 | + AlertSeverity::Warning => tracing::warn!("{}", msg), | |
67 | + AlertSeverity::Critical => tracing::error!("{}", msg), | |
68 | + } | |
69 | + | |
70 | + // If a webhook is configured, attempt to deliver | |
71 | + if let Some(ref url) = self.webhook_url { | |
72 | + let payload = match self.channel { | |
73 | + AlertChannel::Slack => build_slack_payload(&self.app_name, &severity, message), | |
74 | + AlertChannel::PagerDuty => { | |
75 | + build_pagerduty_payload(&self.app_name, &severity, message) | |
76 | + } | |
77 | + AlertChannel::Generic => build_generic_payload(&self.app_name, &severity, message), | |
78 | + }; | |
79 | + | |
80 | + if let Err(e) = post_webhook(url, &payload).await { | |
81 | + tracing::warn!("Failed to send webhook alert: {}", e); | |
82 | + } | |
83 | + } | |
84 | + } | |
85 | +} | |
86 | + | |
87 | +fn build_slack_payload(app: &str, severity: &AlertSeverity, message: &str) -> String { | |
88 | + let color = match severity { | |
89 | + AlertSeverity::Info => "#36a64f", | |
90 | + AlertSeverity::Warning => "#ffcc00", | |
91 | + AlertSeverity::Critical => "#ff0000", | |
92 | + }; | |
93 | + serde_json::json!({ | |
94 | + "attachments": [{ | |
95 | + "color": color, | |
96 | + "title": format!("[{}] {} Alert", app, severity), | |
97 | + "text": message, | |
98 | + "ts": unix_timestamp(), | |
99 | + }] | |
100 | + }) | |
101 | + .to_string() | |
102 | +} | |
103 | + | |
104 | +fn build_pagerduty_payload(app: &str, severity: &AlertSeverity, message: &str) -> String { | |
105 | + let sev = match severity { | |
106 | + AlertSeverity::Info => "info", | |
107 | + AlertSeverity::Warning => "warning", | |
108 | + AlertSeverity::Critical => "critical", | |
109 | + }; | |
110 | + serde_json::json!({ | |
111 | + "routing_key": "webhook", | |
112 | + "event_action": "trigger", | |
113 | + "payload": { | |
114 | + "summary": message, | |
115 | + "source": app, | |
116 | + "severity": sev, | |
117 | + "custom_details": { | |
118 | + "app": app, | |
119 | + "message": message, | |
120 | + } | |
121 | + } | |
122 | + }) | |
123 | + .to_string() | |
124 | +} | |
125 | + | |
126 | +fn build_generic_payload(app: &str, severity: &AlertSeverity, message: &str) -> String { | |
127 | + let mut m = serde_json::Map::new(); | |
128 | + m.insert("app".into(), serde_json::Value::String(app.into())); | |
129 | + m.insert( | |
130 | + "severity".into(), | |
131 | + serde_json::Value::String(severity.to_string()), | |
132 | + ); | |
133 | + m.insert("message".into(), serde_json::Value::String(message.into())); | |
134 | + m.insert( | |
135 | + "timestamp".into(), | |
136 | + serde_json::Value::Number(unix_timestamp().into()), | |
137 | + ); | |
138 | + serde_json::to_string(&m).unwrap_or_else(|_| "{}".to_string()) | |
139 | +} | |
140 | + | |
141 | +/// Simple HTTP POST to a webhook URL. Supports HTTP only (not HTTPS). | |
142 | +/// For HTTPS webhooks, use a reverse proxy or local HTTP gateway. | |
143 | +async fn post_webhook(url: &str, body: &str) -> anyhow::Result<()> { | |
144 | + use tokio::io::AsyncWriteExt; | |
145 | + | |
146 | + let uri: hyper::http::Uri = url.parse()?; | |
147 | + let scheme = uri.scheme_str().unwrap_or("http"); | |
148 | + if scheme != "http" { | |
149 | + return Err(anyhow::anyhow!( | |
150 | + "webhook URL scheme '{}' not supported (use HTTP)", | |
151 | + scheme | |
152 | + )); | |
153 | + } | |
154 | + let host = uri | |
155 | + .host() | |
156 | + .ok_or_else(|| anyhow::anyhow!("webhook URL missing host"))?; | |
157 | + let port = uri.port_u16().unwrap_or(80); | |
158 | + let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); | |
159 | + | |
160 | + let mut stream = tokio::net::TcpStream::connect((host, port)).await?; | |
161 | + | |
162 | + let request = format!( | |
163 | + "POST {path} HTTP/1.1\r\n\ | |
164 | + Host: {host}\r\n\ | |
165 | + Content-Type: application/json\r\n\ | |
166 | + Content-Length: {len}\r\n\ | |
167 | + Connection: close\r\n\ | |
168 | + \r\n\ | |
169 | + {body}", | |
170 | + path = path, | |
171 | + host = host, | |
172 | + len = body.len(), | |
173 | + body = body, | |
174 | + ); | |
175 | + | |
176 | + stream.write_all(request.as_bytes()).await?; | |
177 | + stream.flush().await?; | |
178 | + Ok(()) | |
179 | +} | |
180 | + | |
181 | +fn unix_timestamp() -> i64 { | |
182 | + std::time::SystemTime::now() | |
183 | + .duration_since(std::time::UNIX_EPOCH) | |
184 | + .map(|d| d.as_secs() as i64) | |
185 | + .unwrap_or(0) | |
186 | +} | |
187 | + | |
188 | +#[cfg(test)] | |
189 | +mod tests { | |
190 | + use super::*; | |
191 | + | |
192 | + #[test] | |
193 | + fn test_build_slack_payload() { | |
194 | + let payload = build_slack_payload("test-app", &AlertSeverity::Critical, "something broke"); | |
195 | + assert!(payload.contains("test-app")); | |
196 | + assert!(payload.contains("critical")); | |
197 | + assert!(payload.contains("something broke")); | |
198 | + } | |
199 | + | |
200 | + #[test] | |
201 | + fn test_build_pagerduty_payload() { | |
202 | + let payload = build_pagerduty_payload("test-app", &AlertSeverity::Warning, "high latency"); | |
203 | + assert!(payload.contains("warning")); | |
204 | + assert!(payload.contains("high latency")); | |
205 | + } | |
206 | + | |
207 | + #[test] | |
208 | + fn test_severity_display() { | |
209 | + assert_eq!(AlertSeverity::Info.to_string(), "info"); | |
210 | + assert_eq!(AlertSeverity::Critical.to_string(), "critical"); | |
211 | + } | |
212 | + | |
213 | + #[test] | |
214 | + fn test_severity_filtering() { | |
215 | + let config = AlertingConfig::default(); | |
216 | + // Info should be filtered out since default min is Warning | |
217 | + assert!((AlertSeverity::Info as u8) < (config.min_severity as u8)); | |
218 | + assert!((AlertSeverity::Warning as u8) >= (config.min_severity as u8)); | |
219 | + assert!((AlertSeverity::Critical as u8) >= (config.min_severity as u8)); | |
220 | + } | |
221 | +} | |
0 | 222 | |
▼crates/justapi-core/src/audit.rs
+121-0
| @@ -0,0 +1,121 @@ | ||
1 | +use std::sync::Arc; | |
2 | + | |
3 | +use hyper::body::Incoming; | |
4 | +use hyper::{Method, Request}; | |
5 | + | |
6 | +use crate::middleware::HandlerFn; | |
7 | + | |
8 | +/// Controls which routes and methods should be audit-logged. | |
9 | +#[derive(Clone)] | |
10 | +pub struct AuditRule { | |
11 | + methods: Vec<Method>, | |
12 | + paths: Vec<String>, | |
13 | +} | |
14 | + | |
15 | +impl AuditRule { | |
16 | + pub fn new() -> Self { | |
17 | + Self { | |
18 | + methods: Vec::new(), | |
19 | + paths: Vec::new(), | |
20 | + } | |
21 | + } | |
22 | + | |
23 | + pub fn method(mut self, method: Method) -> Self { | |
24 | + self.methods.push(method); | |
25 | + self | |
26 | + } | |
27 | + | |
28 | + pub fn path(mut self, path: &str) -> Self { | |
29 | + self.paths.push(path.to_string()); | |
30 | + self | |
31 | + } | |
32 | + | |
33 | + fn matches(&self, method: &Method, path: &str) -> bool { | |
34 | + if !self.methods.is_empty() && !self.methods.iter().any(|m| m == method) { | |
35 | + return false; | |
36 | + } | |
37 | + if !self.paths.is_empty() && !self.paths.iter().any(|p| path.starts_with(p)) { | |
38 | + return false; | |
39 | + } | |
40 | + true | |
41 | + } | |
42 | +} | |
43 | + | |
44 | +impl Default for AuditRule { | |
45 | + fn default() -> Self { | |
46 | + Self { | |
47 | + methods: vec![Method::POST, Method::PUT, Method::DELETE, Method::PATCH], | |
48 | + paths: Vec::new(), | |
49 | + } | |
50 | + } | |
51 | +} | |
52 | + | |
53 | +/// Middleware that logs request/response details for sensitive endpoints. | |
54 | +pub struct AuditLogging { | |
55 | + rule: AuditRule, | |
56 | +} | |
57 | + | |
58 | +impl AuditLogging { | |
59 | + pub fn new(rule: AuditRule) -> Self { | |
60 | + Self { rule } | |
61 | + } | |
62 | + | |
63 | + /// Wrap an inner handler so that matched requests are logged with audit metadata. | |
64 | + pub fn wrap_handler(self, handler: HandlerFn) -> HandlerFn { | |
65 | + let rule = self.rule; | |
66 | + Arc::new(move |req: Request<Incoming>| { | |
67 | + let rule = rule.clone(); | |
68 | + let handler = handler.clone(); | |
69 | + Box::pin(async move { | |
70 | + let method = req.method().clone(); | |
71 | + let path = req.uri().path().to_string(); | |
72 | + let should_log = rule.matches(&method, &path); | |
73 | + | |
74 | + let start = std::time::Instant::now(); | |
75 | + let resp = handler(req).await; | |
76 | + let elapsed = start.elapsed(); | |
77 | + | |
78 | + if should_log { | |
79 | + let status = resp.as_ref().map(|r| r.status().as_u16()).unwrap_or(0); | |
80 | + tracing::info!( | |
81 | + audit = true, | |
82 | + method = %method, | |
83 | + path = %path, | |
84 | + status = status, | |
85 | + duration_us = elapsed.as_micros() as u64, | |
86 | + "audit log" | |
87 | + ); | |
88 | + } | |
89 | + | |
90 | + resp | |
91 | + }) | |
92 | + }) | |
93 | + } | |
94 | +} | |
95 | + | |
96 | +impl Default for AuditLogging { | |
97 | + fn default() -> Self { | |
98 | + Self::new(AuditRule::default()) | |
99 | + } | |
100 | +} | |
101 | + | |
102 | +#[cfg(test)] | |
103 | +mod tests { | |
104 | + use super::*; | |
105 | + | |
106 | + #[test] | |
107 | + fn test_audit_rule_matches() { | |
108 | + let rule = AuditRule::default(); | |
109 | + assert!(rule.matches(&Method::POST, "/api/users")); | |
110 | + assert!(rule.matches(&Method::DELETE, "/api/users/42")); | |
111 | + assert!(!rule.matches(&Method::GET, "/health")); | |
112 | + } | |
113 | + | |
114 | + #[test] | |
115 | + fn test_audit_rule_custom() { | |
116 | + let rule = AuditRule::new().method(Method::GET).path("/admin"); | |
117 | + assert!(rule.matches(&Method::GET, "/admin/settings")); | |
118 | + assert!(!rule.matches(&Method::GET, "/api/users")); | |
119 | + assert!(!rule.matches(&Method::POST, "/admin")); | |
120 | + } | |
121 | +} | |
0 | 122 | |
▼crates/justapi-core/src/compress.rs
+347-0
| @@ -0,0 +1,347 @@ | ||
1 | +use std::io::Write; | |
2 | + | |
3 | +use anyhow::Result; | |
4 | +use async_trait::async_trait; | |
5 | +use flate2::write::{DeflateEncoder, GzEncoder}; | |
6 | +use flate2::Compression; | |
7 | +use http_body_util::combinators::UnsyncBoxBody; | |
8 | +use http_body_util::BodyExt; | |
9 | +use hyper::body::{Bytes, Incoming}; | |
10 | +use hyper::{Request, Response}; | |
11 | + | |
12 | +use crate::ResponseBody; | |
13 | + | |
14 | +// --------------------------------------------------------------------------- | |
15 | +// Compression algorithm | |
16 | +// --------------------------------------------------------------------------- | |
17 | + | |
18 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
19 | +pub enum Encoding { | |
20 | + Gzip, | |
21 | + Deflate, | |
22 | + #[cfg(feature = "brotli-compression")] | |
23 | + Brotli, | |
24 | + #[cfg(feature = "zstd-compression")] | |
25 | + Zstd, | |
26 | + Identity, | |
27 | +} | |
28 | + | |
29 | +impl Encoding { | |
30 | + /// Parse the best encoding from the `Accept-Encoding` header. | |
31 | + /// Returns the first supported encoding found, preferring Brotli > Zstd > Gzip > Deflate. | |
32 | + pub fn from_accept_encoding(header_value: &str) -> Self { | |
33 | + // Simple preference order: br > zstd > gzip > deflate | |
34 | + let lower = header_value.to_ascii_lowercase(); | |
35 | + let parts: Vec<&str> = lower.split(',').map(|s| s.trim()).collect(); | |
36 | + | |
37 | + for part in &parts { | |
38 | + let encoding_name = part.split(';').next().unwrap_or("").trim(); | |
39 | + match encoding_name { | |
40 | + #[cfg(feature = "brotli-compression")] | |
41 | + "br" => return Encoding::Brotli, | |
42 | + #[cfg(feature = "zstd-compression")] | |
43 | + "zstd" => return Encoding::Zstd, | |
44 | + "gzip" | "x-gzip" => return Encoding::Gzip, | |
45 | + "deflate" => return Encoding::Deflate, | |
46 | + "*" => { | |
47 | + // Wildcard: return the best we support | |
48 | + #[cfg(feature = "brotli-compression")] | |
49 | + return Encoding::Brotli; | |
50 | + #[cfg(feature = "zstd-compression")] | |
51 | + return Encoding::Zstd; | |
52 | + return Encoding::Gzip; | |
53 | + } | |
54 | + _ => continue, | |
55 | + } | |
56 | + } | |
57 | + | |
58 | + Encoding::Identity | |
59 | + } | |
60 | + | |
61 | + /// Returns the `Content-Encoding` header value for this encoding. | |
62 | + pub fn as_header_value(&self) -> &'static str { | |
63 | + match self { | |
64 | + Encoding::Gzip => "gzip", | |
65 | + Encoding::Deflate => "deflate", | |
66 | + #[cfg(feature = "brotli-compression")] | |
67 | + Encoding::Brotli => "br", | |
68 | + #[cfg(feature = "zstd-compression")] | |
69 | + Encoding::Zstd => "zstd", | |
70 | + Encoding::Identity => "identity", | |
71 | + } | |
72 | + } | |
73 | + | |
74 | + /// Returns true if this encoding actually compresses data. | |
75 | + pub fn is_compression(&self) -> bool { | |
76 | + *self != Encoding::Identity | |
77 | + } | |
78 | +} | |
79 | + | |
80 | +// --------------------------------------------------------------------------- | |
81 | +// Compress bytes | |
82 | +// --------------------------------------------------------------------------- | |
83 | + | |
84 | +pub fn compress_bytes(data: &[u8], encoding: Encoding) -> Result<Vec<u8>> { | |
85 | + match encoding { | |
86 | + Encoding::Gzip => { | |
87 | + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); | |
88 | + encoder.write_all(data)?; | |
89 | + Ok(encoder.finish()?) | |
90 | + } | |
91 | + Encoding::Deflate => { | |
92 | + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::fast()); | |
93 | + encoder.write_all(data)?; | |
94 | + Ok(encoder.finish()?) | |
95 | + } | |
96 | + #[cfg(feature = "brotli-compression")] | |
97 | + Encoding::Brotli => { | |
98 | + let mut encoder = brotli::CompressorWriter::new(Vec::new(), 4096, 6, 22); | |
99 | + std::io::Write::write_all(&mut encoder, data)?; | |
100 | + Ok(encoder.into_inner()) | |
101 | + } | |
102 | + #[cfg(feature = "zstd-compression")] | |
103 | + Encoding::Zstd => { | |
104 | + let mut output = Vec::new(); | |
105 | + zstd::stream::copy_encode(std::io::Cursor::new(data), &mut output, 3)?; | |
106 | + Ok(output) | |
107 | + } | |
108 | + Encoding::Identity => Ok(data.to_vec()), | |
109 | + } | |
110 | +} | |
111 | + | |
112 | +// --------------------------------------------------------------------------- | |
113 | +// Compression middleware | |
114 | +// --------------------------------------------------------------------------- | |
115 | + | |
116 | +/// Middleware that compresses response bodies based on the client's | |
117 | +/// `Accept-Encoding` header. Only compresses responses that are large | |
118 | +/// enough to benefit (> 1KB) and have compressible content types. | |
119 | +pub struct CompressionMiddleware { | |
120 | + min_size: usize, | |
121 | +} | |
122 | + | |
123 | +impl CompressionMiddleware { | |
124 | + pub fn new() -> Self { | |
125 | + Self { min_size: 1024 } | |
126 | + } | |
127 | + | |
128 | + pub fn with_min_size(min_size: usize) -> Self { | |
129 | + Self { min_size } | |
130 | + } | |
131 | +} | |
132 | + | |
133 | +impl Default for CompressionMiddleware { | |
134 | + fn default() -> Self { | |
135 | + Self::new() | |
136 | + } | |
137 | +} | |
138 | + | |
139 | +#[async_trait] | |
140 | +impl crate::middleware::Middleware<Incoming> for CompressionMiddleware { | |
141 | + async fn handle( | |
142 | + &self, | |
143 | + req: Request<Incoming>, | |
144 | + next: crate::middleware::Next<'_, Incoming>, | |
145 | + ) -> Result<Response<ResponseBody>> { | |
146 | + // Determine client's accepted encoding | |
147 | + let accept_encoding = req | |
148 | + .headers() | |
149 | + .get("accept-encoding") | |
150 | + .and_then(|v| v.to_str().ok()) | |
151 | + .unwrap_or(""); | |
152 | + | |
153 | + let encoding = Encoding::from_accept_encoding(accept_encoding); | |
154 | + | |
155 | + // If client doesn't support compression, just pass through | |
156 | + if !encoding.is_compression() { | |
157 | + return next.run(req).await; | |
158 | + } | |
159 | + | |
160 | + let resp = next.run(req).await?; | |
161 | + | |
162 | + // Check if the response is worth compressing | |
163 | + let content_type = resp | |
164 | + .headers() | |
165 | + .get("content-type") | |
166 | + .and_then(|v| v.to_str().ok()) | |
167 | + .unwrap_or(""); | |
168 | + | |
169 | + if !is_compressible_content_type(content_type) { | |
170 | + return Ok(resp); | |
171 | + } | |
172 | + | |
173 | + // Don't compress if already encoded | |
174 | + if resp.headers().contains_key("content-encoding") { | |
175 | + return Ok(resp); | |
176 | + } | |
177 | + | |
178 | + // Decompose response into parts and body | |
179 | + let (parts, body) = resp.into_parts(); | |
180 | + | |
181 | + // Collect the response body | |
182 | + let body_bytes = body.collect().await?.to_bytes(); | |
183 | + | |
184 | + // Don't compress small responses | |
185 | + if body_bytes.len() < self.min_size { | |
186 | + let new_body = UnsyncBoxBody::new( | |
187 | + http_body_util::Full::new(body_bytes) | |
188 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
189 | + ); | |
190 | + return Ok(Response::from_parts(parts, new_body)); | |
191 | + } | |
192 | + | |
193 | + // Compress | |
194 | + let compressed = compress_bytes(&body_bytes, encoding)?; | |
195 | + | |
196 | + // Only use compression if it actually reduced size | |
197 | + if compressed.len() >= body_bytes.len() { | |
198 | + let new_body = UnsyncBoxBody::new( | |
199 | + http_body_util::Full::new(body_bytes) | |
200 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
201 | + ); | |
202 | + return Ok(Response::from_parts(parts, new_body)); | |
203 | + } | |
204 | + | |
205 | + let mut parts = parts; | |
206 | + parts.headers.insert( | |
207 | + "content-encoding", | |
208 | + encoding.as_header_value().parse().unwrap(), | |
209 | + ); | |
210 | + parts.headers.insert( | |
211 | + "content-length", | |
212 | + compressed.len().to_string().parse().unwrap(), | |
213 | + ); | |
214 | + // Add Vary header so caches know the response varies by Accept-Encoding | |
215 | + parts | |
216 | + .headers | |
217 | + .insert("vary", "accept-encoding".parse().unwrap()); | |
218 | + | |
219 | + let new_body = UnsyncBoxBody::new( | |
220 | + http_body_util::Full::new(Bytes::from(compressed)) | |
221 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
222 | + ); | |
223 | + | |
224 | + Ok(Response::from_parts(parts, new_body)) | |
225 | + } | |
226 | +} | |
227 | + | |
228 | +// --------------------------------------------------------------------------- | |
229 | +// Content type check | |
230 | +// --------------------------------------------------------------------------- | |
231 | + | |
232 | +fn is_compressible_content_type(ct: &str) -> bool { | |
233 | + // Text-based and common compressible types | |
234 | + ct.starts_with("text/") | |
235 | + || ct.contains("json") | |
236 | + || ct.contains("javascript") | |
237 | + || ct.contains("xml") | |
238 | + || ct.contains("svg") | |
239 | + || ct.contains("html") | |
240 | + || ct.contains("css") | |
241 | + || ct.contains("wasm") | |
242 | +} | |
243 | + | |
244 | +// --------------------------------------------------------------------------- | |
245 | +// Tests | |
246 | +// --------------------------------------------------------------------------- | |
247 | + | |
248 | +#[cfg(test)] | |
249 | +mod tests { | |
250 | + use super::*; | |
251 | + | |
252 | + #[test] | |
253 | + fn test_encoding_from_gzip() { | |
254 | + assert_eq!( | |
255 | + Encoding::from_accept_encoding("gzip, deflate"), | |
256 | + Encoding::Gzip | |
257 | + ); | |
258 | + } | |
259 | + | |
260 | + #[test] | |
261 | + fn test_encoding_from_deflate() { | |
262 | + assert_eq!(Encoding::from_accept_encoding("deflate"), Encoding::Deflate); | |
263 | + } | |
264 | + | |
265 | + #[test] | |
266 | + fn test_encoding_from_identity() { | |
267 | + assert_eq!( | |
268 | + Encoding::from_accept_encoding("identity"), | |
269 | + Encoding::Identity | |
270 | + ); | |
271 | + } | |
272 | + | |
273 | + #[test] | |
274 | + fn test_encoding_from_wildcard() { | |
275 | + assert_eq!(Encoding::from_accept_encoding("*"), Encoding::Gzip); | |
276 | + } | |
277 | + | |
278 | + #[test] | |
279 | + fn test_encoding_preference_order() { | |
280 | + // Gzip should be preferred over deflate since gzip appears later in the list | |
281 | + // but our parser picks the first compressible encoding it finds. | |
282 | + // Actually gzip is preferred because we check in order: br > zstd > gzip > deflate | |
283 | + // and "deflate, gzip" has deflate first, so deflate would be matched first. | |
284 | + // Wait - the parser iterates parts and matches each. For "deflate, gzip": | |
285 | + // part 0 = "deflate" -> matches Deflate, returns immediately. | |
286 | + assert_eq!( | |
287 | + Encoding::from_accept_encoding("deflate, gzip"), | |
288 | + Encoding::Deflate | |
289 | + ); | |
290 | + } | |
291 | + | |
292 | + #[test] | |
293 | + fn test_encoding_gzip_preferred_over_deflate_in_header() { | |
294 | + // When gzip appears before deflate, gzip wins | |
295 | + assert_eq!( | |
296 | + Encoding::from_accept_encoding("gzip, deflate"), | |
297 | + Encoding::Gzip | |
298 | + ); | |
299 | + } | |
300 | + | |
301 | + #[test] | |
302 | + fn test_compress_gzip() { | |
303 | + let data = b"hello world hello world hello world"; | |
304 | + let compressed = compress_bytes(data, Encoding::Gzip).unwrap(); | |
305 | + // Gzip adds overhead for small data, but still works | |
306 | + assert!(!compressed.is_empty()); | |
307 | + } | |
308 | + | |
309 | + #[test] | |
310 | + fn test_compress_deflate() { | |
311 | + let data = b"hello world hello world hello world"; | |
312 | + let compressed = compress_bytes(data, Encoding::Deflate).unwrap(); | |
313 | + assert!(!compressed.is_empty()); | |
314 | + } | |
315 | + | |
316 | + #[test] | |
317 | + fn test_compress_identity() { | |
318 | + let data = b"hello world"; | |
319 | + let result = compress_bytes(data, Encoding::Identity).unwrap(); | |
320 | + assert_eq!(result, data); | |
321 | + } | |
322 | + | |
323 | + #[test] | |
324 | + fn test_compressible_content_type() { | |
325 | + assert!(is_compressible_content_type("text/html")); | |
326 | + assert!(is_compressible_content_type("application/json")); | |
327 | + assert!(is_compressible_content_type("application/javascript")); | |
328 | + assert!(is_compressible_content_type("image/svg+xml")); | |
329 | + assert!(!is_compressible_content_type("image/png")); | |
330 | + assert!(!is_compressible_content_type("image/jpeg")); | |
331 | + assert!(!is_compressible_content_type("application/octet-stream")); | |
332 | + } | |
333 | + | |
334 | + #[test] | |
335 | + fn test_encoding_as_header_value() { | |
336 | + assert_eq!(Encoding::Gzip.as_header_value(), "gzip"); | |
337 | + assert_eq!(Encoding::Deflate.as_header_value(), "deflate"); | |
338 | + assert_eq!(Encoding::Identity.as_header_value(), "identity"); | |
339 | + } | |
340 | + | |
341 | + #[test] | |
342 | + fn test_is_compression() { | |
343 | + assert!(Encoding::Gzip.is_compression()); | |
344 | + assert!(Encoding::Deflate.is_compression()); | |
345 | + assert!(!Encoding::Identity.is_compression()); | |
346 | + } | |
347 | +} | |
0 | 348 | |
▼crates/justapi-core/src/health.rs
+330-0
| @@ -0,0 +1,330 @@ | ||
1 | +use std::sync::Arc; | |
2 | + | |
3 | +use http_body_util::combinators::UnsyncBoxBody; | |
4 | +use http_body_util::BodyExt; | |
5 | +use http_body_util::Full; | |
6 | +use hyper::body::Bytes; | |
7 | +use hyper::{Response, StatusCode}; | |
8 | + | |
9 | +use crate::serialize; | |
10 | +use crate::ResponseBody; | |
11 | + | |
12 | +/// The result of a single health check. | |
13 | +#[derive(Debug, Clone, PartialEq)] | |
14 | +pub enum HealthStatus { | |
15 | + Healthy, | |
16 | + Degraded(String), | |
17 | + Unhealthy(String), | |
18 | +} | |
19 | + | |
20 | +/// A single health check probe that can be registered with the registry. | |
21 | +pub trait HealthCheck: Send + Sync { | |
22 | + fn name(&self) -> &'static str; | |
23 | + fn check(&self) -> impl std::future::Future<Output = HealthStatus> + Send; | |
24 | +} | |
25 | + | |
26 | +/// A registered health check with its name and probe function. | |
27 | +struct RegisteredCheck { | |
28 | + name: &'static str, | |
29 | + func: Arc< | |
30 | + dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send>> | |
31 | + + Send | |
32 | + + Sync, | |
33 | + >, | |
34 | +} | |
35 | + | |
36 | +/// Registry of health checks that can produce a combined health report. | |
37 | +#[derive(Default)] | |
38 | +pub struct HealthRegistry { | |
39 | + checks: Vec<RegisteredCheck>, | |
40 | +} | |
41 | + | |
42 | +impl HealthRegistry { | |
43 | + pub fn new() -> Self { | |
44 | + Self { checks: Vec::new() } | |
45 | + } | |
46 | + | |
47 | + /// Register a health check probe. | |
48 | + pub fn register(&mut self, check: impl HealthCheck + 'static) { | |
49 | + let name: &'static str = check.name(); | |
50 | + let check = Arc::new(check); | |
51 | + let func: Arc<dyn Fn() -> _ + Send + Sync> = Arc::new(move || { | |
52 | + let check = check.clone(); | |
53 | + Box::pin(async move { check.check().await }) | |
54 | + as std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send>> | |
55 | + }); | |
56 | + self.checks.push(RegisteredCheck { name, func }); | |
57 | + } | |
58 | + | |
59 | + /// Register a health check from a name and async function. | |
60 | + pub fn register_fn<F, Fut>(&mut self, name: &'static str, f: F) | |
61 | + where | |
62 | + F: Fn() -> Fut + Send + Sync + 'static, | |
63 | + Fut: std::future::Future<Output = HealthStatus> + Send + 'static, | |
64 | + { | |
65 | + let func = Arc::new( | |
66 | + move || -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send>> { | |
67 | + Box::pin(f()) | |
68 | + }, | |
69 | + ); | |
70 | + self.checks.push(RegisteredCheck { name, func }); | |
71 | + } | |
72 | + | |
73 | + /// Returns true if no health checks are registered. | |
74 | + pub fn is_empty(&self) -> bool { | |
75 | + self.checks.is_empty() | |
76 | + } | |
77 | + | |
78 | + /// Run all registered checks and return a health report. | |
79 | + pub async fn check_all(&self) -> HealthReport { | |
80 | + let mut components = Vec::with_capacity(self.checks.len()); | |
81 | + for check in &self.checks { | |
82 | + let status = (check.func)().await; | |
83 | + components.push(ComponentStatus { | |
84 | + name: check.name, | |
85 | + status, | |
86 | + }); | |
87 | + } | |
88 | + HealthReport { components } | |
89 | + } | |
90 | + | |
91 | + /// Produces an HTTP response for the `/health` endpoint. | |
92 | + /// Returns 200 if all checks are healthy, 503 if any are unhealthy. | |
93 | + pub async fn health_response(&self) -> Response<ResponseBody> { | |
94 | + let report = self.check_all().await; | |
95 | + let overall_status = report.overall(); | |
96 | + let status_code = match overall_status { | |
97 | + OverallHealth::Healthy => StatusCode::OK, | |
98 | + OverallHealth::Degraded => StatusCode::OK, | |
99 | + OverallHealth::Unhealthy => StatusCode::SERVICE_UNAVAILABLE, | |
100 | + }; | |
101 | + | |
102 | + let body = serialize::to_json_string(&report).unwrap_or_else(|_| { | |
103 | + r#"{"status":"error","message":"serialization failed"}"#.to_string() | |
104 | + }); | |
105 | + | |
106 | + Response::builder() | |
107 | + .status(status_code) | |
108 | + .header("content-type", "application/json") | |
109 | + .header("content-length", body.len().to_string()) | |
110 | + .body(UnsyncBoxBody::new(Full::new(Bytes::from(body)).map_err( | |
111 | + |e: std::convert::Infallible| -> anyhow::Error { match e {} }, | |
112 | + ))) | |
113 | + .unwrap() | |
114 | + } | |
115 | +} | |
116 | + | |
117 | +/// The overall health status derived from all components. | |
118 | +#[derive(Debug, Clone, PartialEq)] | |
119 | +pub enum OverallHealth { | |
120 | + Healthy, | |
121 | + Degraded, | |
122 | + Unhealthy, | |
123 | +} | |
124 | + | |
125 | +/// Status of a single component. | |
126 | +#[derive(Debug, Clone)] | |
127 | +pub struct ComponentStatus { | |
128 | + pub name: &'static str, | |
129 | + pub status: HealthStatus, | |
130 | +} | |
131 | + | |
132 | +/// Full health report containing all component statuses. | |
133 | +#[derive(Debug, Clone)] | |
134 | +pub struct HealthReport { | |
135 | + pub components: Vec<ComponentStatus>, | |
136 | +} | |
137 | + | |
138 | +impl HealthReport { | |
139 | + pub fn overall(&self) -> OverallHealth { | |
140 | + let mut has_degraded = false; | |
141 | + for c in &self.components { | |
142 | + match c.status { | |
143 | + HealthStatus::Unhealthy(_) => return OverallHealth::Unhealthy, | |
144 | + HealthStatus::Degraded(_) => has_degraded = true, | |
145 | + HealthStatus::Healthy => {} | |
146 | + } | |
147 | + } | |
148 | + if has_degraded { | |
149 | + OverallHealth::Degraded | |
150 | + } else { | |
151 | + OverallHealth::Healthy | |
152 | + } | |
153 | + } | |
154 | +} | |
155 | + | |
156 | +use serde::Serialize; | |
157 | + | |
158 | +impl Serialize for HealthStatus { | |
159 | + fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> { | |
160 | + match self { | |
161 | + HealthStatus::Healthy => { | |
162 | + let mut m = std::collections::BTreeMap::new(); | |
163 | + m.insert("status".to_string(), "healthy".to_string()); | |
164 | + m.serialize(s) | |
165 | + } | |
166 | + HealthStatus::Degraded(msg) => { | |
167 | + let mut m = std::collections::BTreeMap::new(); | |
168 | + m.insert("status".to_string(), "degraded".to_string()); | |
169 | + m.insert("message".to_string(), msg.clone()); | |
170 | + m.serialize(s) | |
171 | + } | |
172 | + HealthStatus::Unhealthy(msg) => { | |
173 | + let mut m = std::collections::BTreeMap::new(); | |
174 | + m.insert("status".to_string(), "unhealthy".to_string()); | |
175 | + m.insert("message".to_string(), msg.clone()); | |
176 | + m.serialize(s) | |
177 | + } | |
178 | + } | |
179 | + } | |
180 | +} | |
181 | + | |
182 | +impl Serialize for ComponentStatus { | |
183 | + fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> { | |
184 | + use serde::ser::SerializeStruct; | |
185 | + let mut st = s.serialize_struct("ComponentStatus", 2)?; | |
186 | + st.serialize_field("name", self.name)?; | |
187 | + st.serialize_field("status", &self.status)?; | |
188 | + st.end() | |
189 | + } | |
190 | +} | |
191 | + | |
192 | +impl Serialize for HealthReport { | |
193 | + fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> { | |
194 | + use serde::ser::SerializeStruct; | |
195 | + let overall = self.overall(); | |
196 | + let status_str = match overall { | |
197 | + OverallHealth::Healthy => "healthy", | |
198 | + OverallHealth::Degraded => "degraded", | |
199 | + OverallHealth::Unhealthy => "unhealthy", | |
200 | + }; | |
201 | + let mut st = s.serialize_struct("HealthReport", 3)?; | |
202 | + st.serialize_field("status", status_str)?; | |
203 | + st.serialize_field("components", &self.components)?; | |
204 | + st.end() | |
205 | + } | |
206 | +} | |
207 | + | |
208 | +/// A health check that always returns healthy (for testing / fallback). | |
209 | +pub struct AlwaysHealthy { | |
210 | + pub name: &'static str, | |
211 | +} | |
212 | + | |
213 | +impl HealthCheck for AlwaysHealthy { | |
214 | + fn name(&self) -> &'static str { | |
215 | + self.name | |
216 | + } | |
217 | + async fn check(&self) -> HealthStatus { | |
218 | + HealthStatus::Healthy | |
219 | + } | |
220 | +} | |
221 | + | |
222 | +#[cfg(feature = "db")] | |
223 | +pub mod db_check { | |
224 | + use super::*; | |
225 | + use crate::db::AnyPool; | |
226 | + | |
227 | + /// Health check for a database connection pool. | |
228 | + pub struct DbHealthCheck { | |
229 | + name: &'static str, | |
230 | + pool: AnyPool, | |
231 | + } | |
232 | + | |
233 | + impl DbHealthCheck { | |
234 | + pub fn new(name: &'static str, pool: AnyPool) -> Self { | |
235 | + Self { name, pool } | |
236 | + } | |
237 | + } | |
238 | + | |
239 | + impl HealthCheck for DbHealthCheck { | |
240 | + fn name(&self) -> &'static str { | |
241 | + self.name | |
242 | + } | |
243 | + async fn check(&self) -> HealthStatus { | |
244 | + match self.pool.health_check().await { | |
245 | + Ok(_) => HealthStatus::Healthy, | |
246 | + Err(e) => HealthStatus::Unhealthy(format!("database check failed: {}", e)), | |
247 | + } | |
248 | + } | |
249 | + } | |
250 | +} | |
251 | + | |
252 | +#[cfg(test)] | |
253 | +mod tests { | |
254 | + use super::*; | |
255 | + | |
256 | + struct TestCheck { | |
257 | + name: &'static str, | |
258 | + result: HealthStatus, | |
259 | + } | |
260 | + | |
261 | + impl HealthCheck for TestCheck { | |
262 | + fn name(&self) -> &'static str { | |
263 | + self.name | |
264 | + } | |
265 | + async fn check(&self) -> HealthStatus { | |
266 | + self.result.clone() | |
267 | + } | |
268 | + } | |
269 | + | |
270 | + #[tokio::test] | |
271 | + async fn test_all_healthy() { | |
272 | + let mut registry = HealthRegistry::new(); | |
273 | + registry.register(TestCheck { | |
274 | + name: "test1", | |
275 | + result: HealthStatus::Healthy, | |
276 | + }); | |
277 | + registry.register(AlwaysHealthy { name: "always" }); | |
278 | + let report = registry.check_all().await; | |
279 | + assert_eq!(report.overall(), OverallHealth::Healthy); | |
280 | + assert_eq!(report.components.len(), 2); | |
281 | + } | |
282 | + | |
283 | + #[tokio::test] | |
284 | + async fn test_degraded() { | |
285 | + let mut registry = HealthRegistry::new(); | |
286 | + registry.register(TestCheck { | |
287 | + name: "test1", | |
288 | + result: HealthStatus::Degraded("slow".to_string()), | |
289 | + }); | |
290 | + let report = registry.check_all().await; | |
291 | + assert_eq!(report.overall(), OverallHealth::Degraded); | |
292 | + } | |
293 | + | |
294 | + #[tokio::test] | |
295 | + async fn test_unhealthy() { | |
296 | + let mut registry = HealthRegistry::new(); | |
297 | + registry.register(TestCheck { | |
298 | + name: "test1", | |
299 | + result: HealthStatus::Unhealthy("down".to_string()), | |
300 | + }); | |
301 | + let report = registry.check_all().await; | |
302 | + assert_eq!(report.overall(), OverallHealth::Unhealthy); | |
303 | + } | |
304 | + | |
305 | + #[tokio::test] | |
306 | + async fn test_health_response() { | |
307 | + let mut registry = HealthRegistry::new(); | |
308 | + registry.register(AlwaysHealthy { name: "test" }); | |
309 | + let resp = registry.health_response().await; | |
310 | + assert_eq!(resp.status(), StatusCode::OK); | |
311 | + } | |
312 | + | |
313 | + #[tokio::test] | |
314 | + async fn test_unhealthy_response() { | |
315 | + let mut registry = HealthRegistry::new(); | |
316 | + registry.register(TestCheck { | |
317 | + name: "test", | |
318 | + result: HealthStatus::Unhealthy("crash".to_string()), | |
319 | + }); | |
320 | + let resp = registry.health_response().await; | |
321 | + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); | |
322 | + } | |
323 | + | |
324 | + #[test] | |
325 | + fn test_register_fn() { | |
326 | + let mut registry = HealthRegistry::new(); | |
327 | + registry.register_fn("custom", || async { HealthStatus::Healthy }); | |
328 | + assert_eq!(registry.checks.len(), 1); | |
329 | + } | |
330 | +} | |
0 | 331 | |
▼crates/justapi-core/src/metrics.rs
+502-0
| @@ -0,0 +1,502 @@ | ||
1 | +use std::sync::atomic::{AtomicU64, Ordering}; | |
2 | +use std::sync::Arc; | |
3 | +use std::sync::Mutex; | |
4 | +use std::time::Instant; | |
5 | + | |
6 | +use http_body_util::combinators::UnsyncBoxBody; | |
7 | +use http_body_util::BodyExt; | |
8 | +use http_body_util::Full; | |
9 | +use hyper::body::Bytes; | |
10 | +use hyper::{Response, StatusCode}; | |
11 | + | |
12 | +use crate::ResponseBody; | |
13 | + | |
14 | +/// Prometheus-compatible latency bucket boundaries in milliseconds. | |
15 | +const LATENCY_BUCKETS_MS: &[f64] = &[ | |
16 | + 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0, | |
17 | +]; | |
18 | + | |
19 | +/// A snapshot of all metric values at a point in time (for testing & Prometheus). | |
20 | +#[derive(Debug, Clone)] | |
21 | +pub struct MetricsSnapshot { | |
22 | + pub requests_total: u64, | |
23 | + pub errors_total: u64, | |
24 | + pub bytes_in: u64, | |
25 | + pub bytes_out: u64, | |
26 | + pub active_connections: u64, | |
27 | + pub status_2xx: u64, | |
28 | + pub status_3xx: u64, | |
29 | + pub status_4xx: u64, | |
30 | + pub status_5xx: u64, | |
31 | + pub latency_count: u64, | |
32 | + pub latency_sum_ms: f64, | |
33 | + /// Cumulative count per bucket (length = buckets.len() + 1; last is +Inf). | |
34 | + pub latency_buckets: Vec<u64>, | |
35 | +} | |
36 | + | |
37 | +/// A provider of extra Prometheus-formatted metric lines, appended to the | |
38 | +/// output of [`Metrics::prometheus`]. Used by the scheduler to expose prefix- | |
39 | +/// cache statistics without coupling `Metrics` to the inference crate. | |
40 | +pub trait MetricProvider: Send + Sync { | |
41 | + fn render(&self) -> String; | |
42 | +} | |
43 | + | |
44 | +impl<F> MetricProvider for F | |
45 | +where | |
46 | + F: Fn() -> String + Send + Sync, | |
47 | +{ | |
48 | + fn render(&self) -> String { | |
49 | + self() | |
50 | + } | |
51 | +} | |
52 | + | |
53 | +/// Shared metrics collector with latency histograms and status-code tracking. | |
54 | +#[derive(Clone)] | |
55 | +pub struct Metrics { | |
56 | + inner: Arc<MetricsInner>, | |
57 | +} | |
58 | + | |
59 | +struct LatencyHist { | |
60 | + buckets: Vec<AtomicU64>, | |
61 | + count: AtomicU64, | |
62 | + sum_ms: AtomicU64, | |
63 | +} | |
64 | + | |
65 | +impl LatencyHist { | |
66 | + fn new() -> Self { | |
67 | + let buckets = LATENCY_BUCKETS_MS | |
68 | + .iter() | |
69 | + .map(|_| AtomicU64::new(0)) | |
70 | + .collect(); | |
71 | + Self { | |
72 | + buckets, | |
73 | + count: AtomicU64::new(0), | |
74 | + sum_ms: AtomicU64::new(0), | |
75 | + } | |
76 | + } | |
77 | + | |
78 | + fn record(&self, latency_ms: f64) { | |
79 | + self.count.fetch_add(1, Ordering::Relaxed); | |
80 | + self.sum_ms | |
81 | + .fetch_add((latency_ms * 1000.0) as u64, Ordering::Relaxed); | |
82 | + for (i, threshold) in LATENCY_BUCKETS_MS.iter().enumerate() { | |
83 | + if latency_ms <= *threshold { | |
84 | + self.buckets[i].fetch_add(1, Ordering::Relaxed); | |
85 | + break; | |
86 | + } | |
87 | + } | |
88 | + } | |
89 | + | |
90 | + fn snapshot(&self) -> (u64, f64, Vec<u64>) { | |
91 | + let count = self.count.load(Ordering::Relaxed); | |
92 | + let sum_us = self.sum_ms.load(Ordering::Relaxed); | |
93 | + let sum_ms = sum_us as f64 / 1000.0; | |
94 | + let buckets: Vec<u64> = self | |
95 | + .buckets | |
96 | + .iter() | |
97 | + .map(|b| b.load(Ordering::Relaxed)) | |
98 | + .collect(); | |
99 | + (count, sum_ms, buckets) | |
100 | + } | |
101 | +} | |
102 | + | |
103 | +struct MetricsInner { | |
104 | + requests_total: AtomicU64, | |
105 | + errors_total: AtomicU64, | |
106 | + bytes_in: AtomicU64, | |
107 | + bytes_out: AtomicU64, | |
108 | + active_connections: AtomicU64, | |
109 | + status_2xx: AtomicU64, | |
110 | + status_3xx: AtomicU64, | |
111 | + status_4xx: AtomicU64, | |
112 | + status_5xx: AtomicU64, | |
113 | + latency: LatencyHist, | |
114 | + extra_providers: Mutex<Vec<Box<dyn MetricProvider>>>, | |
115 | +} | |
116 | + | |
117 | +impl Metrics { | |
118 | + pub fn new() -> Self { | |
119 | + Self { | |
120 | + inner: Arc::new(MetricsInner { | |
121 | + requests_total: AtomicU64::new(0), | |
122 | + errors_total: AtomicU64::new(0), | |
123 | + bytes_in: AtomicU64::new(0), | |
124 | + bytes_out: AtomicU64::new(0), | |
125 | + active_connections: AtomicU64::new(0), | |
126 | + status_2xx: AtomicU64::new(0), | |
127 | + status_3xx: AtomicU64::new(0), | |
128 | + status_4xx: AtomicU64::new(0), | |
129 | + status_5xx: AtomicU64::new(0), | |
130 | + latency: LatencyHist::new(), | |
131 | + extra_providers: Mutex::new(Vec::new()), | |
132 | + }), | |
133 | + } | |
134 | + } | |
135 | + | |
136 | + pub fn record_request(&self) { | |
137 | + self.inner.requests_total.fetch_add(1, Ordering::Relaxed); | |
138 | + } | |
139 | + | |
140 | + pub fn record_error(&self) { | |
141 | + self.inner.errors_total.fetch_add(1, Ordering::Relaxed); | |
142 | + } | |
143 | + | |
144 | + /// Record a response status code for status-breakdown tracking. | |
145 | + pub fn record_status(&self, status: StatusCode) { | |
146 | + let code: u16 = status.into(); | |
147 | + match code / 100 { | |
148 | + 2 => self.inner.status_2xx.fetch_add(1, Ordering::Relaxed), | |
149 | + 3 => self.inner.status_3xx.fetch_add(1, Ordering::Relaxed), | |
150 | + 4 => self.inner.status_4xx.fetch_add(1, Ordering::Relaxed), | |
151 | + _ => self.inner.status_5xx.fetch_add(1, Ordering::Relaxed), | |
152 | + }; | |
153 | + } | |
154 | + | |
155 | + /// Record request latency in milliseconds. | |
156 | + pub fn record_latency(&self, ms: f64) { | |
157 | + self.inner.latency.record(ms); | |
158 | + } | |
159 | + | |
160 | + pub fn add_bytes_in(&self, n: u64) { | |
161 | + self.inner.bytes_in.fetch_add(n, Ordering::Relaxed); | |
162 | + } | |
163 | + | |
164 | + /// Register an extra Prometheus metric provider (e.g. scheduler stats). | |
165 | + pub fn register_extra_provider(&self, provider: Box<dyn MetricProvider>) { | |
166 | + self.inner.extra_providers.lock().unwrap().push(provider); | |
167 | + } | |
168 | + | |
169 | + pub fn add_bytes_out(&self, n: u64) { | |
170 | + self.inner.bytes_out.fetch_add(n, Ordering::Relaxed); | |
171 | + } | |
172 | + | |
173 | + pub fn connection_opened(&self) { | |
174 | + self.inner | |
175 | + .active_connections | |
176 | + .fetch_add(1, Ordering::Relaxed); | |
177 | + } | |
178 | + | |
179 | + pub fn connection_closed(&self) { | |
180 | + self.inner | |
181 | + .active_connections | |
182 | + .fetch_sub(1, Ordering::Relaxed); | |
183 | + } | |
184 | + | |
185 | + /// Return an atomic snapshot of all metric values. | |
186 | + pub fn snapshot(&self) -> MetricsSnapshot { | |
187 | + let (latency_count, latency_sum_ms, latency_buckets) = self.inner.latency.snapshot(); | |
188 | + MetricsSnapshot { | |
189 | + requests_total: self.inner.requests_total.load(Ordering::Relaxed), | |
190 | + errors_total: self.inner.errors_total.load(Ordering::Relaxed), | |
191 | + bytes_in: self.inner.bytes_in.load(Ordering::Relaxed), | |
192 | + bytes_out: self.inner.bytes_out.load(Ordering::Relaxed), | |
193 | + active_connections: self.inner.active_connections.load(Ordering::Relaxed), | |
194 | + status_2xx: self.inner.status_2xx.load(Ordering::Relaxed), | |
195 | + status_3xx: self.inner.status_3xx.load(Ordering::Relaxed), | |
196 | + status_4xx: self.inner.status_4xx.load(Ordering::Relaxed), | |
197 | + status_5xx: self.inner.status_5xx.load(Ordering::Relaxed), | |
198 | + latency_count, | |
199 | + latency_sum_ms, | |
200 | + latency_buckets, | |
201 | + } | |
202 | + } | |
203 | + | |
204 | + /// Compute p50, p95, p99, p999 from the latency histogram snapshot. | |
205 | + pub fn percentiles(&self) -> Option<Percentiles> { | |
206 | + let (count, _sum, buckets) = self.inner.latency.snapshot(); | |
207 | + if count == 0 { | |
208 | + return None; | |
209 | + } | |
210 | + let total = count as f64; | |
211 | + let compute = |pct: f64| -> f64 { | |
212 | + let target = total * pct; | |
213 | + let mut cumulative = 0u64; | |
214 | + for (i, &bucket_count) in buckets.iter().enumerate() { | |
215 | + cumulative += bucket_count; | |
216 | + if cumulative as f64 >= target { | |
217 | + return LATENCY_BUCKETS_MS[i.min(LATENCY_BUCKETS_MS.len() - 1)]; | |
218 | + } | |
219 | + } | |
220 | + *LATENCY_BUCKETS_MS.last().unwrap_or(&10000.0) | |
221 | + }; | |
222 | + Some(Percentiles { | |
223 | + p50: compute(0.50), | |
224 | + p95: compute(0.95), | |
225 | + p99: compute(0.99), | |
226 | + p999: compute(0.999), | |
227 | + }) | |
228 | + } | |
229 | + | |
230 | + /// Return Prometheus text format including histograms and status-code metrics. | |
231 | + pub fn prometheus(&self) -> String { | |
232 | + let s = self.snapshot(); | |
233 | + let mut out = String::new(); | |
234 | + | |
235 | + out.push_str( | |
236 | + "# HELP justapi_requests_total Total number of requests.\n\ | |
237 | + # TYPE justapi_requests_total counter\n", | |
238 | + ); | |
239 | + out.push_str(&format!("justapi_requests_total {}\n", s.requests_total)); | |
240 | + | |
241 | + out.push_str( | |
242 | + "# HELP justapi_errors_total Total number of errors.\n\ | |
243 | + # TYPE justapi_errors_total counter\n", | |
244 | + ); | |
245 | + out.push_str(&format!("justapi_errors_total {}\n", s.errors_total)); | |
246 | + | |
247 | + out.push_str( | |
248 | + "# HELP justapi_bytes_in_total Total bytes received.\n\ | |
249 | + # TYPE justapi_bytes_in_total counter\n", | |
250 | + ); | |
251 | + out.push_str(&format!("justapi_bytes_in_total {}\n", s.bytes_in)); | |
252 | + | |
253 | + out.push_str( | |
254 | + "# HELP justapi_bytes_out_total Total bytes sent.\n\ | |
255 | + # TYPE justapi_bytes_out_total counter\n", | |
256 | + ); | |
257 | + out.push_str(&format!("justapi_bytes_out_total {}\n", s.bytes_out)); | |
258 | + | |
259 | + out.push_str( | |
260 | + "# HELP justapi_active_connections Current active connections.\n\ | |
261 | + # TYPE justapi_active_connections gauge\n", | |
262 | + ); | |
263 | + out.push_str(&format!( | |
264 | + "justapi_active_connections {}\n", | |
265 | + s.active_connections | |
266 | + )); | |
267 | + | |
268 | + // Status code breakdown | |
269 | + out.push_str( | |
270 | + "# HELP justapi_requests_by_status Requests by HTTP status code class.\n\ | |
271 | + # TYPE justapi_requests_by_status counter\n", | |
272 | + ); | |
273 | + out.push_str(&format!( | |
274 | + "justapi_requests_by_status{{code=\"2xx\"}} {}\n", | |
275 | + s.status_2xx | |
276 | + )); | |
277 | + out.push_str(&format!( | |
278 | + "justapi_requests_by_status{{code=\"3xx\"}} {}\n", | |
279 | + s.status_3xx | |
280 | + )); | |
281 | + out.push_str(&format!( | |
282 | + "justapi_requests_by_status{{code=\"4xx\"}} {}\n", | |
283 | + s.status_4xx | |
284 | + )); | |
285 | + out.push_str(&format!( | |
286 | + "justapi_requests_by_status{{code=\"5xx\"}} {}\n", | |
287 | + s.status_5xx | |
288 | + )); | |
289 | + | |
290 | + // Latency histogram | |
291 | + out.push_str( | |
292 | + "# HELP justapi_request_duration_ms Request latency in milliseconds.\n\ | |
293 | + # TYPE justapi_request_duration_ms histogram\n", | |
294 | + ); | |
295 | + out.push_str(&format!( | |
296 | + "justapi_request_duration_ms_count {}\n", | |
297 | + s.latency_count | |
298 | + )); | |
299 | + out.push_str(&format!( | |
300 | + "justapi_request_duration_ms_sum {:.3}\n", | |
301 | + s.latency_sum_ms | |
302 | + )); | |
303 | + for (i, bucket_count) in s.latency_buckets.iter().enumerate() { | |
304 | + let le = LATENCY_BUCKETS_MS[i.min(LATENCY_BUCKETS_MS.len() - 1)]; | |
305 | + out.push_str(&format!( | |
306 | + "justapi_request_duration_ms_bucket{{le=\"{}\"}} {}\n", | |
307 | + le, bucket_count | |
308 | + )); | |
309 | + } | |
310 | + out.push_str(&format!( | |
311 | + "justapi_request_duration_ms_bucket{{le=\"+Inf\"}} {}\n", | |
312 | + s.latency_count | |
313 | + )); | |
314 | + | |
315 | + // Extra metric providers (e.g. scheduler prefix-cache stats). | |
316 | + let providers = self.inner.extra_providers.lock().unwrap(); | |
317 | + for provider in providers.iter() { | |
318 | + out.push_str(&provider.render()); | |
319 | + } | |
320 | + | |
321 | + out | |
322 | + } | |
323 | +} | |
324 | + | |
325 | +impl Default for Metrics { | |
326 | + fn default() -> Self { | |
327 | + Self::new() | |
328 | + } | |
329 | +} | |
330 | + | |
331 | +/// Latency percentiles computed from histogram data. | |
332 | +#[derive(Debug, Clone, Copy)] | |
333 | +pub struct Percentiles { | |
334 | + pub p50: f64, | |
335 | + pub p95: f64, | |
336 | + pub p99: f64, | |
337 | + pub p999: f64, | |
338 | +} | |
339 | + | |
340 | +/// Health check endpoint response. | |
341 | +pub fn health_response() -> Response<ResponseBody> { | |
342 | + let body = r#"{"status":"ok"}"#; | |
343 | + let body_bytes = Full::new(Bytes::from(body)) | |
344 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }); | |
345 | + Response::builder() | |
346 | + .status(StatusCode::OK) | |
347 | + .header("content-type", "application/json") | |
348 | + .header("content-length", body.len().to_string()) | |
349 | + .body(UnsyncBoxBody::new(body_bytes)) | |
350 | + .unwrap() | |
351 | +} | |
352 | + | |
353 | +/// Readiness check endpoint response. | |
354 | +pub fn ready_response() -> Response<ResponseBody> { | |
355 | + let body = r#"{"ready":true}"#; | |
356 | + let body_bytes = Full::new(Bytes::from(body)) | |
357 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }); | |
358 | + Response::builder() | |
359 | + .status(StatusCode::OK) | |
360 | + .header("content-type", "application/json") | |
361 | + .header("content-length", body.len().to_string()) | |
362 | + .body(UnsyncBoxBody::new(body_bytes)) | |
363 | + .unwrap() | |
364 | +} | |
365 | + | |
366 | +/// Liveness check endpoint response. | |
367 | +pub fn live_response() -> Response<ResponseBody> { | |
368 | + let body = r#"{"alive":true}"#; | |
369 | + let body_bytes = Full::new(Bytes::from(body)) | |
370 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }); | |
371 | + Response::builder() | |
372 | + .status(StatusCode::OK) | |
373 | + .header("content-type", "application/json") | |
374 | + .header("content-length", body.len().to_string()) | |
375 | + .body(UnsyncBoxBody::new(body_bytes)) | |
376 | + .unwrap() | |
377 | +} | |
378 | + | |
379 | +/// Prometheus metrics endpoint response. | |
380 | +pub fn metrics_response(metrics: &Metrics) -> Response<ResponseBody> { | |
381 | + let body = metrics.prometheus(); | |
382 | + let body_len = body.len().to_string(); | |
383 | + let body_bytes = Full::new(Bytes::from(body)) | |
384 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }); | |
385 | + Response::builder() | |
386 | + .status(StatusCode::OK) | |
387 | + .header("content-type", "text/plain; version=0.0.4") | |
388 | + .header("content-length", body_len) | |
389 | + .body(UnsyncBoxBody::new(body_bytes)) | |
390 | + .unwrap() | |
391 | +} | |
392 | + | |
393 | +/// Timer guard that records elapsed time on drop. | |
394 | +pub struct RequestTimer { | |
395 | + start: Instant, | |
396 | +} | |
397 | + | |
398 | +impl RequestTimer { | |
399 | + pub fn start() -> Self { | |
400 | + Self { | |
401 | + start: Instant::now(), | |
402 | + } | |
403 | + } | |
404 | + | |
405 | + pub fn elapsed_ms(&self) -> f64 { | |
406 | + self.start.elapsed().as_secs_f64() * 1000.0 | |
407 | + } | |
408 | +} | |
409 | + | |
410 | +#[cfg(test)] | |
411 | +mod tests { | |
412 | + use super::*; | |
413 | + | |
414 | + #[test] | |
415 | + fn test_metrics_prometheus() { | |
416 | + let m = Metrics::new(); | |
417 | + m.record_request(); | |
418 | + m.record_request(); | |
419 | + m.record_error(); | |
420 | + m.record_status(StatusCode::OK); | |
421 | + m.record_status(StatusCode::NOT_FOUND); | |
422 | + m.record_latency(10.0); | |
423 | + m.record_latency(20.0); | |
424 | + let out = m.prometheus(); | |
425 | + assert!(out.contains("justapi_requests_total 2")); | |
426 | + assert!(out.contains("justapi_errors_total 1")); | |
427 | + assert!(out.contains("justapi_requests_by_status{code=\"2xx\"} 1")); | |
428 | + assert!(out.contains("justapi_requests_by_status{code=\"4xx\"} 1")); | |
429 | + assert!(out.contains("justapi_request_duration_ms_count 2")); | |
430 | + assert!(out.contains("justapi_request_duration_ms_sum")); | |
431 | + assert!(out.contains("justapi_request_duration_ms_bucket")); | |
432 | + } | |
433 | + | |
434 | + #[test] | |
435 | + fn test_metrics_snapshot() { | |
436 | + let m = Metrics::new(); | |
437 | + m.record_request(); | |
438 | + m.record_status(StatusCode::OK); | |
439 | + m.record_latency(5.0); | |
440 | + let snap = m.snapshot(); | |
441 | + assert_eq!(snap.requests_total, 1); | |
442 | + assert_eq!(snap.status_2xx, 1); | |
443 | + assert_eq!(snap.latency_count, 1); | |
444 | + } | |
445 | + | |
446 | + #[test] | |
447 | + fn test_percentiles_empty() { | |
448 | + let m = Metrics::new(); | |
449 | + assert!(m.percentiles().is_none()); | |
450 | + } | |
451 | + | |
452 | + #[test] | |
453 | + fn test_percentiles() { | |
454 | + let m = Metrics::new(); | |
455 | + for _ in 0..100 { | |
456 | + m.record_latency(5.0); | |
457 | + } | |
458 | + let p = m.percentiles().unwrap(); | |
459 | + assert!(p.p50 <= 5.0); | |
460 | + assert!(p.p95 <= 5.0); | |
461 | + } | |
462 | + | |
463 | + #[test] | |
464 | + fn test_health_response() { | |
465 | + let resp = health_response(); | |
466 | + assert_eq!(resp.status(), StatusCode::OK); | |
467 | + } | |
468 | + | |
469 | + #[test] | |
470 | + fn test_ready_response() { | |
471 | + let resp = ready_response(); | |
472 | + assert_eq!(resp.status(), StatusCode::OK); | |
473 | + } | |
474 | + | |
475 | + #[test] | |
476 | + fn test_live_response() { | |
477 | + let resp = live_response(); | |
478 | + assert_eq!(resp.status(), StatusCode::OK); | |
479 | + } | |
480 | + | |
481 | + #[test] | |
482 | + fn test_request_timer() { | |
483 | + let t = RequestTimer::start(); | |
484 | + std::thread::sleep(std::time::Duration::from_millis(10)); | |
485 | + assert!(t.elapsed_ms() >= 5.0); | |
486 | + } | |
487 | + | |
488 | + #[test] | |
489 | + fn test_status_code_breakdown() { | |
490 | + let m = Metrics::new(); | |
491 | + m.record_status(StatusCode::OK); | |
492 | + m.record_status(StatusCode::CREATED); | |
493 | + m.record_status(StatusCode::FOUND); | |
494 | + m.record_status(StatusCode::BAD_REQUEST); | |
495 | + m.record_status(StatusCode::INTERNAL_SERVER_ERROR); | |
496 | + let snap = m.snapshot(); | |
497 | + assert_eq!(snap.status_2xx, 2); | |
498 | + assert_eq!(snap.status_3xx, 1); | |
499 | + assert_eq!(snap.status_4xx, 1); | |
500 | + assert_eq!(snap.status_5xx, 1); | |
501 | + } | |
502 | +} | |
0 | 503 | |
▼crates/justapi-core/src/middleware.rs
+2011-0
| @@ -0,0 +1,2011 @@ | ||
1 | +use std::collections::HashMap; | |
2 | +use std::net::SocketAddr; | |
3 | +use std::num::NonZeroU32; | |
4 | +use std::sync::Arc; | |
5 | +use std::time::Duration; | |
6 | + | |
7 | +use anyhow::Result; | |
8 | +use async_trait::async_trait; | |
9 | +use futures::future::BoxFuture; | |
10 | +use governor::clock::Clock; | |
11 | +use http_body_util::combinators::UnsyncBoxBody; | |
12 | +use http_body_util::BodyExt; | |
13 | +use hyper::body::{Bytes, Incoming}; | |
14 | +use hyper::{Method, Request, Response, StatusCode}; | |
15 | + | |
16 | +use crate::ResponseBody; | |
17 | + | |
18 | +// --------------------------------------------------------------------------- | |
19 | +// Middleware trait (generic over body type for testability) | |
20 | +// --------------------------------------------------------------------------- | |
21 | + | |
22 | +/// Middleware that processes a request and optionally passes it to the next handler. | |
23 | +#[async_trait] | |
24 | +pub trait Middleware<B = Incoming>: Send + Sync { | |
25 | + async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>>; | |
26 | +} | |
27 | + | |
28 | +/// The next element in the middleware chain. | |
29 | +pub struct Next<'a, B = Incoming> { | |
30 | + middlewares: &'a [Arc<dyn Middleware<B>>], | |
31 | + index: usize, | |
32 | + handler: &'a HandlerFn<B>, | |
33 | +} | |
34 | + | |
35 | +impl<'a, B> Next<'a, B> { | |
36 | + pub async fn run(self, req: Request<B>) -> Result<Response<ResponseBody>> { | |
37 | + if self.index < self.middlewares.len() { | |
38 | + let mw = &self.middlewares[self.index]; | |
39 | + let next = Next { | |
40 | + middlewares: self.middlewares, | |
41 | + index: self.index + 1, | |
42 | + handler: self.handler, | |
43 | + }; | |
44 | + let mw_name = std::any::type_name_of_val(mw); | |
45 | + let span = tracing::debug_span!("middleware", mw.name = %mw_name); | |
46 | + let _enter = span.enter(); | |
47 | + let result = mw.handle(req, next).await; | |
48 | + drop(_enter); | |
49 | + result | |
50 | + } else { | |
51 | + let span = tracing::debug_span!("handler.dispatch"); | |
52 | + let _enter = span.enter(); | |
53 | + let result = (self.handler)(req).await; | |
54 | + drop(_enter); | |
55 | + result | |
56 | + } | |
57 | + } | |
58 | +} | |
59 | + | |
60 | +/// Handler function type, generic over body type. | |
61 | +pub type HandlerFn<B = Incoming> = | |
62 | + Arc<dyn Fn(Request<B>) -> BoxFuture<'static, Result<Response<ResponseBody>>> + Send + Sync>; | |
63 | + | |
64 | +// --------------------------------------------------------------------------- | |
65 | +// Middleware chain (generic over body type) | |
66 | +// --------------------------------------------------------------------------- | |
67 | + | |
68 | +/// A composable chain of middleware wrapping an inner handler. | |
69 | +pub struct MiddlewareChain<B = Incoming> { | |
70 | + middlewares: Vec<Arc<dyn Middleware<B>>>, | |
71 | + handler: HandlerFn<B>, | |
72 | +} | |
73 | + | |
74 | +impl<B> Clone for MiddlewareChain<B> { | |
75 | + fn clone(&self) -> Self { | |
76 | + Self { | |
77 | + middlewares: self.middlewares.clone(), | |
78 | + handler: self.handler.clone(), | |
79 | + } | |
80 | + } | |
81 | +} | |
82 | + | |
83 | +impl<B> MiddlewareChain<B> { | |
84 | + pub fn new(handler: HandlerFn<B>) -> Self { | |
85 | + Self { | |
86 | + middlewares: Vec::new(), | |
87 | + handler, | |
88 | + } | |
89 | + } | |
90 | + | |
91 | + pub fn add(&mut self, mw: impl Middleware<B> + 'static) { | |
92 | + self.middlewares.push(Arc::new(mw)); | |
93 | + } | |
94 | + | |
95 | + pub fn set_handler(&mut self, handler: HandlerFn<B>) { | |
96 | + self.handler = handler; | |
97 | + } | |
98 | + | |
99 | + pub fn is_empty(&self) -> bool { | |
100 | + self.middlewares.is_empty() | |
101 | + } | |
102 | + | |
103 | + pub async fn run(&self, req: Request<B>) -> Result<Response<ResponseBody>> { | |
104 | + if self.middlewares.is_empty() { | |
105 | + return (self.handler)(req).await; | |
106 | + } | |
107 | + let next = Next { | |
108 | + middlewares: &self.middlewares, | |
109 | + index: 0, | |
110 | + handler: &self.handler, | |
111 | + }; | |
112 | + next.run(req).await | |
113 | + } | |
114 | +} | |
115 | + | |
116 | +// --------------------------------------------------------------------------- | |
117 | +// Middleware implementations (concrete, for Incoming body only) | |
118 | +// --------------------------------------------------------------------------- | |
119 | + | |
120 | +pub struct AccessLogger { | |
121 | + tx: tokio::sync::mpsc::UnboundedSender<String>, | |
122 | +} | |
123 | + | |
124 | +impl AccessLogger { | |
125 | + pub fn new() -> Self { | |
126 | + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>(); | |
127 | + | |
128 | + std::thread::spawn(move || { | |
129 | + while let Some(msg) = rx.blocking_recv() { | |
130 | + println!("{}", msg); | |
131 | + } | |
132 | + }); | |
133 | + | |
134 | + Self { tx } | |
135 | + } | |
136 | +} | |
137 | + | |
138 | +impl Default for AccessLogger { | |
139 | + fn default() -> Self { | |
140 | + Self::new() | |
141 | + } | |
142 | +} | |
143 | + | |
144 | +#[async_trait] | |
145 | +impl Middleware<Incoming> for AccessLogger { | |
146 | + async fn handle( | |
147 | + &self, | |
148 | + req: Request<Incoming>, | |
149 | + next: Next<'_, Incoming>, | |
150 | + ) -> Result<Response<ResponseBody>> { | |
151 | + let method = req.method().clone(); | |
152 | + let uri = req.uri().clone(); | |
153 | + let start = std::time::Instant::now(); | |
154 | + | |
155 | + let res = next.run(req).await; | |
156 | + | |
157 | + let status = match &res { | |
158 | + Ok(r) => r.status().as_u16(), | |
159 | + Err(_) => 500, | |
160 | + }; | |
161 | + let duration = start.elapsed(); | |
162 | + | |
163 | + let _ = self | |
164 | + .tx | |
165 | + .send(format!("[{method}] {uri} - {status} - {duration:?}")); | |
166 | + | |
167 | + res | |
168 | + } | |
169 | +} | |
170 | + | |
171 | +pub struct Cors { | |
172 | + allow_origins: Vec<String>, | |
173 | + allow_methods: String, | |
174 | + allow_headers: String, | |
175 | + expose_headers: Vec<String>, | |
176 | + max_age: String, | |
177 | + allow_credentials: bool, | |
178 | +} | |
179 | + | |
180 | +impl Cors { | |
181 | + pub fn permissive() -> Self { | |
182 | + Self { | |
183 | + allow_origins: vec!["*".to_string()], | |
184 | + allow_methods: "GET, POST, PUT, DELETE, PATCH, OPTIONS".to_string(), | |
185 | + allow_headers: "Content-Type, Authorization".to_string(), | |
186 | + expose_headers: Vec::new(), | |
187 | + max_age: "86400".to_string(), | |
188 | + allow_credentials: false, | |
189 | + } | |
190 | + } | |
191 | + | |
192 | + pub fn new() -> Self { | |
193 | + Self { | |
194 | + allow_origins: vec!["*".to_string()], | |
195 | + allow_methods: "GET, POST, PUT, DELETE, PATCH, OPTIONS".to_string(), | |
196 | + allow_headers: "Content-Type, Authorization".to_string(), | |
197 | + expose_headers: Vec::new(), | |
198 | + max_age: "86400".to_string(), | |
199 | + allow_credentials: false, | |
200 | + } | |
201 | + } | |
202 | + | |
203 | + pub fn allow_origin(mut self, origin: &str) -> Self { | |
204 | + if origin == "*" { | |
205 | + self.allow_origins = vec!["*".to_string()]; | |
206 | + } else if self.allow_origins == ["*"] { | |
207 | + self.allow_origins = vec![origin.to_string()]; | |
208 | + } else { | |
209 | + self.allow_origins.push(origin.to_string()); | |
210 | + } | |
211 | + self | |
212 | + } | |
213 | + | |
214 | + pub fn allow_methods(mut self, methods: &str) -> Self { | |
215 | + self.allow_methods = methods.to_string(); | |
216 | + self | |
217 | + } | |
218 | + | |
219 | + pub fn allow_headers(mut self, headers: &str) -> Self { | |
220 | + self.allow_headers = headers.to_string(); | |
221 | + self | |
222 | + } | |
223 | + | |
224 | + pub fn expose_headers(mut self, headers: &[&str]) -> Self { | |
225 | + self.expose_headers = headers.iter().map(|h| h.to_string()).collect(); | |
226 | + self | |
227 | + } | |
228 | + | |
229 | + pub fn allow_credentials(mut self) -> Self { | |
230 | + self.allow_credentials = true; | |
231 | + self | |
232 | + } | |
233 | + | |
234 | + pub fn max_age(mut self, seconds: &str) -> Self { | |
235 | + self.max_age = seconds.to_string(); | |
236 | + self | |
237 | + } | |
238 | +} | |
239 | + | |
240 | +impl Default for Cors { | |
241 | + fn default() -> Self { | |
242 | + Self::permissive() | |
243 | + } | |
244 | +} | |
245 | + | |
246 | +fn origin_matches(origin: &str, allowed: &[String]) -> bool { | |
247 | + allowed.iter().any(|a| a == "*" || a == origin) | |
248 | +} | |
249 | + | |
250 | +#[async_trait] | |
251 | +impl Middleware<Incoming> for Cors { | |
252 | + async fn handle( | |
253 | + &self, | |
254 | + req: Request<Incoming>, | |
255 | + next: Next<'_, Incoming>, | |
256 | + ) -> Result<Response<ResponseBody>> { | |
257 | + let req_origin = req.headers().get("origin").and_then(|v| v.to_str().ok()); | |
258 | + | |
259 | + let allow_all = self.allow_origins.iter().any(|a| a == "*"); | |
260 | + | |
261 | + let matched_origin = if allow_all { | |
262 | + "*".to_string() | |
263 | + } else if let Some(origin) = req_origin { | |
264 | + if origin_matches(origin, &self.allow_origins) { | |
265 | + origin.to_string() | |
266 | + } else { | |
267 | + return next.run(req).await; | |
268 | + } | |
269 | + } else { | |
270 | + return next.run(req).await; | |
271 | + }; | |
272 | + | |
273 | + let add_vary = !allow_all; | |
274 | + | |
275 | + if req.method() == Method::OPTIONS { | |
276 | + let mut builder = Response::builder() | |
277 | + .status(StatusCode::NO_CONTENT) | |
278 | + .header("access-control-allow-origin", matched_origin) | |
279 | + .header("access-control-allow-methods", &self.allow_methods) | |
280 | + .header("access-control-allow-headers", &self.allow_headers) | |
281 | + .header("access-control-max-age", &self.max_age); | |
282 | + | |
283 | + if self.allow_credentials { | |
284 | + builder = builder.header("access-control-allow-credentials", "true"); | |
285 | + } | |
286 | + if !self.expose_headers.is_empty() { | |
287 | + builder = builder.header( | |
288 | + "access-control-expose-headers", | |
289 | + self.expose_headers.join(", "), | |
290 | + ); | |
291 | + } | |
292 | + if add_vary { | |
293 | + builder = builder.header("vary", "Origin"); | |
294 | + } | |
295 | + | |
296 | + let resp = builder | |
297 | + .body(UnsyncBoxBody::new( | |
298 | + http_body_util::Full::new(Bytes::new()) | |
299 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
300 | + )) | |
301 | + .unwrap(); | |
302 | + return Ok(resp); | |
303 | + } | |
304 | + | |
305 | + let mut resp = next.run(req).await?; | |
306 | + resp.headers_mut().insert( | |
307 | + "access-control-allow-origin", | |
308 | + matched_origin.parse().unwrap(), | |
309 | + ); | |
310 | + if self.allow_credentials { | |
311 | + resp.headers_mut() | |
312 | + .insert("access-control-allow-credentials", "true".parse().unwrap()); | |
313 | + } | |
314 | + if !self.expose_headers.is_empty() { | |
315 | + resp.headers_mut().insert( | |
316 | + "access-control-expose-headers", | |
317 | + self.expose_headers.join(", ").parse().unwrap(), | |
318 | + ); | |
319 | + } | |
320 | + if add_vary { | |
321 | + resp.headers_mut().insert("vary", "Origin".parse().unwrap()); | |
322 | + } | |
323 | + Ok(resp) | |
324 | + } | |
325 | +} | |
326 | + | |
327 | +pub struct SecurityHeaders { | |
328 | + hsts: String, | |
329 | + csp_directives: Vec<String>, | |
330 | + include_xfo: bool, | |
331 | + include_csp: bool, | |
332 | + include_hsts_preload: bool, | |
333 | +} | |
334 | + | |
335 | +impl Default for SecurityHeaders { | |
336 | + fn default() -> Self { | |
337 | + Self { | |
338 | + hsts: "max-age=31536000; includeSubDomains".to_string(), | |
339 | + csp_directives: vec!["default-src 'self'".to_string()], | |
340 | + include_xfo: true, | |
341 | + include_csp: true, | |
342 | + include_hsts_preload: false, | |
343 | + } | |
344 | + } | |
345 | +} | |
346 | + | |
347 | +impl SecurityHeaders { | |
348 | + pub fn with_hsts_preload(mut self) -> Self { | |
349 | + self.hsts = format!("{}; preload", self.hsts.trim_end_matches("; preload")); | |
350 | + self.include_hsts_preload = true; | |
351 | + self | |
352 | + } | |
353 | + | |
354 | + pub fn with_csp_directive(mut self, directive: &str) -> Self { | |
355 | + self.csp_directives.push(directive.to_string()); | |
356 | + self | |
357 | + } | |
358 | + | |
359 | + pub fn without_xfo(mut self) -> Self { | |
360 | + self.include_xfo = false; | |
361 | + self | |
362 | + } | |
363 | + | |
364 | + pub fn without_csp(mut self) -> Self { | |
365 | + self.include_csp = false; | |
366 | + self | |
367 | + } | |
368 | +} | |
369 | + | |
370 | +#[async_trait] | |
371 | +impl Middleware<Incoming> for SecurityHeaders { | |
372 | + async fn handle( | |
373 | + &self, | |
374 | + req: Request<Incoming>, | |
375 | + next: Next<'_, Incoming>, | |
376 | + ) -> Result<Response<ResponseBody>> { | |
377 | + let mut resp = next.run(req).await?; | |
378 | + let headers = resp.headers_mut(); | |
379 | + | |
380 | + headers.insert("x-content-type-options", "nosniff".parse().unwrap()); | |
381 | + | |
382 | + if self.include_xfo { | |
383 | + headers.insert("x-frame-options", "DENY".parse().unwrap()); | |
384 | + } | |
385 | + | |
386 | + let hsts_val = if self.include_hsts_preload { | |
387 | + format!("{}; preload", self.hsts.trim_end_matches("; preload")) | |
388 | + } else { | |
389 | + self.hsts.clone() | |
390 | + }; | |
391 | + headers.insert("strict-transport-security", hsts_val.parse().unwrap()); | |
392 | + | |
393 | + if self.include_csp { | |
394 | + let csp = self.csp_directives.join("; "); | |
395 | + headers.insert("content-security-policy", csp.parse().unwrap()); | |
396 | + } | |
397 | + | |
398 | + headers.insert("x-xss-protection", "0".parse().unwrap()); | |
399 | + Ok(resp) | |
400 | + } | |
401 | +} | |
402 | + | |
403 | +/// Per-route JWT requirement. | |
404 | +#[derive(Clone, Debug)] | |
405 | +pub enum JwtRequirement { | |
406 | + /// No JWT validation required for this route. | |
407 | + None, | |
408 | + /// JWT required; no additional claims checked. | |
409 | + Required, | |
410 | + /// JWT required AND the token's `roles` claim must include at least one of these. | |
411 | + Roles(Vec<String>), | |
412 | + /// JWT required AND the token's `scope` or `scopes` claim must include at least one of these. | |
413 | + Scopes(Vec<String>), | |
414 | +} | |
415 | + | |
416 | +pub struct JwtAuth { | |
417 | + validation: jsonwebtoken::Validation, | |
418 | + decoding_key: jsonwebtoken::DecodingKey, | |
419 | + route_rules: Vec<(String, JwtRequirement)>, | |
420 | + default_requirement: JwtRequirement, | |
421 | +} | |
422 | + | |
423 | +impl std::fmt::Debug for JwtAuth { | |
424 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
425 | + f.debug_struct("JwtAuth") | |
426 | + .field("default_requirement", &self.default_requirement) | |
427 | + .field("route_rules", &self.route_rules) | |
428 | + .finish() | |
429 | + } | |
430 | +} | |
431 | + | |
432 | +impl JwtAuth { | |
433 | + pub fn from_secret(secret: &[u8]) -> Self { | |
434 | + Self { | |
435 | + validation: jsonwebtoken::Validation::default(), | |
436 | + decoding_key: jsonwebtoken::DecodingKey::from_secret(secret), | |
437 | + route_rules: Vec::new(), | |
438 | + default_requirement: JwtRequirement::Required, | |
439 | + } | |
440 | + } | |
441 | + | |
442 | + pub fn from_rsa_pem(pem: &str) -> anyhow::Result<Self> { | |
443 | + let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(pem.as_bytes())?; | |
444 | + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); | |
445 | + validation.algorithms = vec![ | |
446 | + jsonwebtoken::Algorithm::RS256, | |
447 | + jsonwebtoken::Algorithm::RS384, | |
448 | + jsonwebtoken::Algorithm::RS512, | |
449 | + ]; | |
450 | + Ok(Self { | |
451 | + validation, | |
452 | + decoding_key, | |
453 | + route_rules: Vec::new(), | |
454 | + default_requirement: JwtRequirement::Required, | |
455 | + }) | |
456 | + } | |
457 | + | |
458 | + pub fn from_ec_pem(pem: &str) -> anyhow::Result<Self> { | |
459 | + let decoding_key = jsonwebtoken::DecodingKey::from_ec_pem(pem.as_bytes())?; | |
460 | + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256); | |
461 | + validation.algorithms = vec![ | |
462 | + jsonwebtoken::Algorithm::ES256, | |
463 | + jsonwebtoken::Algorithm::ES384, | |
464 | + ]; | |
465 | + Ok(Self { | |
466 | + validation, | |
467 | + decoding_key, | |
468 | + route_rules: Vec::new(), | |
469 | + default_requirement: JwtRequirement::Required, | |
470 | + }) | |
471 | + } | |
472 | + | |
473 | + pub fn from_rsa_der(der: &[u8]) -> Self { | |
474 | + let decoding_key = jsonwebtoken::DecodingKey::from_rsa_der(der); | |
475 | + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); | |
476 | + validation.algorithms = vec![ | |
477 | + jsonwebtoken::Algorithm::RS256, | |
478 | + jsonwebtoken::Algorithm::RS384, | |
479 | + jsonwebtoken::Algorithm::RS512, | |
480 | + ]; | |
481 | + Self { | |
482 | + validation, | |
483 | + decoding_key, | |
484 | + route_rules: Vec::new(), | |
485 | + default_requirement: JwtRequirement::Required, | |
486 | + } | |
487 | + } | |
488 | + | |
489 | + pub fn from_ec_der(der: &[u8]) -> Self { | |
490 | + let decoding_key = jsonwebtoken::DecodingKey::from_ec_der(der); | |
491 | + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256); | |
492 | + validation.algorithms = vec![ | |
493 | + jsonwebtoken::Algorithm::ES256, | |
494 | + jsonwebtoken::Algorithm::ES384, | |
495 | + ]; | |
496 | + Self { | |
497 | + validation, | |
498 | + decoding_key, | |
499 | + route_rules: Vec::new(), | |
500 | + default_requirement: JwtRequirement::Required, | |
501 | + } | |
502 | + } | |
503 | + | |
504 | + pub fn with_algorithm(mut self, alg: jsonwebtoken::Algorithm) -> Self { | |
505 | + self.validation.algorithms = vec![alg]; | |
506 | + self | |
507 | + } | |
508 | + | |
509 | + pub fn with_algorithms(mut self, algs: Vec<jsonwebtoken::Algorithm>) -> Self { | |
510 | + self.validation.algorithms = algs; | |
511 | + self | |
512 | + } | |
513 | + | |
514 | + pub fn with_issuer(mut self, issuer: &str) -> Self { | |
515 | + self.validation.iss = Some([issuer.to_string()].into()); | |
516 | + self | |
517 | + } | |
518 | + | |
519 | + pub fn with_audience(mut self, audience: &[&str]) -> Self { | |
520 | + self.validation.aud = Some(audience.iter().map(|a| a.to_string()).collect()); | |
521 | + self | |
522 | + } | |
523 | + | |
524 | + /// Require JWT validation for requests whose path starts with the given prefix. | |
525 | + pub fn require_for(mut self, path_prefix: &str, requirement: JwtRequirement) -> Self { | |
526 | + self.route_rules | |
527 | + .push((path_prefix.to_string(), requirement)); | |
528 | + self | |
529 | + } | |
530 | + | |
531 | + /// Set the default requirement for routes without an explicit rule. | |
532 | + pub fn default_requirement(mut self, requirement: JwtRequirement) -> Self { | |
533 | + self.default_requirement = requirement; | |
534 | + self | |
535 | + } | |
536 | + | |
537 | + fn requirement_for_path(&self, path: &str) -> &JwtRequirement { | |
538 | + for (prefix, req) in &self.route_rules { | |
539 | + if path.starts_with(prefix) { | |
540 | + return req; | |
541 | + } | |
542 | + } | |
543 | + &self.default_requirement | |
544 | + } | |
545 | +} | |
546 | + | |
547 | +fn check_claims( | |
548 | + claims: &serde_json::Value, | |
549 | + requirement: &JwtRequirement, | |
550 | +) -> Result<(), &'static str> { | |
551 | + match requirement { | |
552 | + JwtRequirement::None => Ok(()), | |
553 | + JwtRequirement::Required => Ok(()), | |
554 | + JwtRequirement::Roles(required_roles) => { | |
555 | + let roles = claims.get("roles").and_then(|v| v.as_array()); | |
556 | + match roles { | |
557 | + Some(role_list) => { | |
558 | + let user_roles: Vec<&str> = | |
559 | + role_list.iter().filter_map(|v| v.as_str()).collect(); | |
560 | + if required_roles | |
561 | + .iter() | |
562 | + .any(|r| user_roles.contains(&r.as_str())) | |
563 | + { | |
564 | + Ok(()) | |
565 | + } else { | |
566 | + Err("missing required role") | |
567 | + } | |
568 | + } | |
569 | + None => Err("missing roles claim"), | |
570 | + } | |
571 | + } | |
572 | + JwtRequirement::Scopes(required_scopes) => { | |
573 | + let scopes = claims | |
574 | + .get("scope") | |
575 | + .or_else(|| claims.get("scopes")) | |
576 | + .and_then(|v| v.as_str()) | |
577 | + .map(|s| s.split_whitespace().collect::<Vec<&str>>()) | |
578 | + .or_else(|| { | |
579 | + claims | |
580 | + .get("scope") | |
581 | + .or_else(|| claims.get("scopes")) | |
582 | + .and_then(|v| v.as_array()) | |
583 | + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) | |
584 | + }); | |
585 | + match scopes { | |
586 | + Some(user_scopes) => { | |
587 | + if required_scopes | |
588 | + .iter() | |
589 | + .any(|s| user_scopes.contains(&s.as_str())) | |
590 | + { | |
591 | + Ok(()) | |
592 | + } else { | |
593 | + Err("missing required scope") | |
594 | + } | |
595 | + } | |
596 | + None => Err("missing scope/scopes claim"), | |
597 | + } | |
598 | + } | |
599 | + } | |
600 | +} | |
601 | + | |
602 | +#[async_trait] | |
603 | +impl Middleware<Incoming> for JwtAuth { | |
604 | + async fn handle( | |
605 | + &self, | |
606 | + req: Request<Incoming>, | |
607 | + next: Next<'_, Incoming>, | |
608 | + ) -> Result<Response<ResponseBody>> { | |
609 | + let path = req.uri().path().to_string(); | |
610 | + let requirement = self.requirement_for_path(&path); | |
611 | + | |
612 | + // If no JWT required for this route, skip validation entirely | |
613 | + if matches!(requirement, JwtRequirement::None) { | |
614 | + return next.run(req).await; | |
615 | + } | |
616 | + | |
617 | + let auth_header = req | |
618 | + .headers() | |
619 | + .get("authorization") | |
620 | + .and_then(|v| v.to_str().ok()); | |
621 | + let token = match auth_header { | |
622 | + Some(h) if h.starts_with("Bearer ") => &h[7..], | |
623 | + _ => { | |
624 | + return Ok(json_error( | |
625 | + StatusCode::UNAUTHORIZED, | |
626 | + "missing or invalid authorization header", | |
627 | + )) | |
628 | + } | |
629 | + }; | |
630 | + | |
631 | + match jsonwebtoken::decode::<serde_json::Value>(token, &self.decoding_key, &self.validation) | |
632 | + { | |
633 | + Ok(token_data) => { | |
634 | + if let Err(msg) = check_claims(&token_data.claims, requirement) { | |
635 | + return Ok(json_error(StatusCode::FORBIDDEN, msg)); | |
636 | + } | |
637 | + let mut req = req; | |
638 | + req.extensions_mut().insert(token_data.claims); | |
639 | + next.run(req).await | |
640 | + } | |
641 | + Err(_) => Ok(json_error(StatusCode::UNAUTHORIZED, "invalid token")), | |
642 | + } | |
643 | + } | |
644 | +} | |
645 | + | |
646 | +pub struct RateLimiter { | |
647 | + limiter: governor::RateLimiter< | |
648 | + governor::state::direct::NotKeyed, | |
649 | + governor::state::InMemoryState, | |
650 | + governor::clock::DefaultClock, | |
651 | + >, | |
652 | +} | |
653 | + | |
654 | +impl RateLimiter { | |
655 | + pub fn new(duration: std::time::Duration, max_burst: u32) -> Self { | |
656 | + let quota = governor::Quota::with_period(duration) | |
657 | + .unwrap() | |
658 | + .allow_burst(NonZeroU32::new(max_burst).unwrap()); | |
659 | + Self { | |
660 | + limiter: governor::RateLimiter::direct(quota), | |
661 | + } | |
662 | + } | |
663 | + | |
664 | + pub fn per_second(max_burst: u32) -> Self { | |
665 | + Self::new(std::time::Duration::from_secs(1), max_burst) | |
666 | + } | |
667 | +} | |
668 | + | |
669 | +#[async_trait] | |
670 | +impl Middleware<Incoming> for RateLimiter { | |
671 | + async fn handle( | |
672 | + &self, | |
673 | + req: Request<Incoming>, | |
674 | + next: Next<'_, Incoming>, | |
675 | + ) -> Result<Response<ResponseBody>> { | |
676 | + match self.limiter.check() { | |
677 | + Ok(()) => next.run(req).await, | |
678 | + Err(negative) => { | |
679 | + let clock = governor::clock::DefaultClock::default(); | |
680 | + let now = clock.now(); | |
681 | + let wait = negative.wait_time_from(now); | |
682 | + let retry_after_secs = wait.as_secs().max(1); | |
683 | + let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded"); | |
684 | + resp.headers_mut() | |
685 | + .insert("retry-after", retry_after_secs.to_string().parse().unwrap()); | |
686 | + resp.headers_mut().insert( | |
687 | + "x-ratelimit-reset", | |
688 | + retry_after_secs.to_string().parse().unwrap(), | |
689 | + ); | |
690 | + Ok(resp) | |
691 | + } | |
692 | + } | |
693 | + } | |
694 | +} | |
695 | + | |
696 | +/// Per-IP rate limiter using governor's keyed rate limiter. | |
697 | +/// Extracts client IP from connection info (requires `req.extensions().get::<SocketAddr>()`). | |
698 | +pub struct IpRateLimiter { | |
699 | + limiter: std::sync::Arc< | |
700 | + governor::RateLimiter< | |
701 | + String, | |
702 | + governor::state::keyed::DefaultKeyedStateStore<String>, | |
703 | + governor::clock::DefaultClock, | |
704 | + >, | |
705 | + >, | |
706 | +} | |
707 | + | |
708 | +impl IpRateLimiter { | |
709 | + pub fn new(duration: std::time::Duration, max_burst: u32) -> Self { | |
710 | + let quota = governor::Quota::with_period(duration) | |
711 | + .unwrap() | |
712 | + .allow_burst(NonZeroU32::new(max_burst).unwrap()); | |
713 | + let limiter = std::sync::Arc::new(governor::RateLimiter::keyed(quota)); | |
714 | + | |
715 | + let limiter_clone = limiter.clone(); | |
716 | + tokio::spawn(async move { | |
717 | + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); | |
718 | + loop { | |
719 | + interval.tick().await; | |
720 | + limiter_clone.retain_recent(); | |
721 | + } | |
722 | + }); | |
723 | + | |
724 | + Self { limiter } | |
725 | + } | |
726 | + | |
727 | + pub fn per_second(max_burst: u32) -> Self { | |
728 | + Self::new(std::time::Duration::from_secs(1), max_burst) | |
729 | + } | |
730 | +} | |
731 | + | |
732 | +#[async_trait] | |
733 | +impl Middleware<Incoming> for IpRateLimiter { | |
734 | + async fn handle( | |
735 | + &self, | |
736 | + req: Request<Incoming>, | |
737 | + next: Next<'_, Incoming>, | |
738 | + ) -> Result<Response<ResponseBody>> { | |
739 | + let ip = req | |
740 | + .extensions() | |
741 | + .get::<SocketAddr>() | |
742 | + .map(|a| a.ip().to_string()) | |
743 | + .unwrap_or_else(|| "unknown".to_string()); | |
744 | + | |
745 | + match self.limiter.check_key(&ip) { | |
746 | + Ok(()) => next.run(req).await, | |
747 | + Err(negative) => { | |
748 | + let clock = governor::clock::DefaultClock::default(); | |
749 | + let now = clock.now(); | |
750 | + let wait = negative.wait_time_from(now); | |
751 | + let retry_after_secs = wait.as_secs().max(1); | |
752 | + let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded"); | |
753 | + resp.headers_mut() | |
754 | + .insert("retry-after", retry_after_secs.to_string().parse().unwrap()); | |
755 | + resp.headers_mut().insert( | |
756 | + "x-ratelimit-reset", | |
757 | + retry_after_secs.to_string().parse().unwrap(), | |
758 | + ); | |
759 | + Ok(resp) | |
760 | + } | |
761 | + } | |
762 | + } | |
763 | +} | |
764 | + | |
765 | +fn json_error(status: StatusCode, msg: &str) -> Response<ResponseBody> { | |
766 | + let body = format!(r#"{{"error":"{}"}}"#, msg); | |
767 | + Response::builder() | |
768 | + .status(status) | |
769 | + .header("content-type", "application/json") | |
770 | + .header("content-length", body.len().to_string()) | |
771 | + .body(UnsyncBoxBody::new( | |
772 | + http_body_util::Full::new(Bytes::from(body)) | |
773 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
774 | + )) | |
775 | + .unwrap() | |
776 | +} | |
777 | + | |
778 | +// --------------------------------------------------------------------------- | |
779 | +// API-key authentication (the "API-key scheme" from OpenAPI / FastAPI) | |
780 | +// --------------------------------------------------------------------------- | |
781 | + | |
782 | +/// API-key authentication middleware. | |
783 | +/// | |
784 | +/// Validates a key presented in a configurable request header (default | |
785 | +/// `x-api-key`) or, optionally, a query parameter. Valid keys map to a set of | |
786 | +/// claims (subject / roles / scopes) inserted into the request extensions for | |
787 | +/// downstream handlers. Per-path-prefix rules control whether a key is required | |
788 | +/// at all (e.g. public health endpoints vs. private `/v1/*` routes). | |
789 | +pub struct ApiKeyAuth { | |
790 | + header_name: hyper::header::HeaderName, | |
791 | + query_param: Option<String>, | |
792 | + keys: std::collections::HashMap<String, serde_json::Value>, | |
793 | + route_rules: Vec<(String, bool)>, | |
794 | + default_required: bool, | |
795 | +} | |
796 | + | |
797 | +impl ApiKeyAuth { | |
798 | + /// Create an API-key authenticator reading keys from `header_name` | |
799 | + /// (e.g. `"x-api-key"`). No keys are valid until registered with | |
800 | + /// [`ApiKeyAuth::with_key`]. | |
801 | + pub fn new(header_name: &str) -> Self { | |
802 | + Self { | |
803 | + header_name: hyper::header::HeaderName::from_bytes(header_name.as_bytes()) | |
804 | + .expect("valid header name"), | |
805 | + query_param: None, | |
806 | + keys: std::collections::HashMap::new(), | |
807 | + route_rules: Vec::new(), | |
808 | + default_required: true, | |
809 | + } | |
810 | + } | |
811 | + | |
812 | + /// Register a valid key with its associated claims (subject / roles / scopes). | |
813 | + pub fn with_key(mut self, key: &str, claims: serde_json::Value) -> Self { | |
814 | + self.keys.insert(key.to_string(), claims); | |
815 | + self | |
816 | + } | |
817 | + | |
818 | + /// Also accept the key via this query parameter (in addition to the header). | |
819 | + pub fn with_query_param(mut self, name: &str) -> Self { | |
820 | + self.query_param = Some(name.to_string()); | |
821 | + self | |
822 | + } | |
823 | + | |
824 | + /// Require (or exempt) keys for paths starting with `path_prefix`. | |
825 | + pub fn require_for(mut self, path_prefix: &str, required: bool) -> Self { | |
826 | + self.route_rules.push((path_prefix.to_string(), required)); | |
827 | + self | |
828 | + } | |
829 | + | |
830 | + /// Set whether keys are required by default for paths without an explicit rule. | |
831 | + pub fn default_required(mut self, required: bool) -> Self { | |
832 | + self.default_required = required; | |
833 | + self | |
834 | + } | |
835 | + | |
836 | + fn required_for_path(&self, path: &str) -> bool { | |
837 | + for (prefix, req) in &self.route_rules { | |
838 | + if path.starts_with(prefix) { | |
839 | + return *req; | |
840 | + } | |
841 | + } | |
842 | + self.default_required | |
843 | + } | |
844 | + | |
845 | + fn extract_key<B>(&self, req: &Request<B>) -> Option<String> { | |
846 | + if let Some(v) = req | |
847 | + .headers() | |
848 | + .get(&self.header_name) | |
849 | + .and_then(|v| v.to_str().ok()) | |
850 | + { | |
851 | + return Some(v.to_string()); | |
852 | + } | |
853 | + if let Some(qp) = &self.query_param { | |
854 | + if let Some(query) = req.uri().query() { | |
855 | + for pair in query.split('&') { | |
856 | + let mut it = pair.splitn(2, '='); | |
857 | + if let (Some(k), Some(v)) = (it.next(), it.next()) { | |
858 | + if k == qp { | |
859 | + return Some(v.to_string()); | |
860 | + } | |
861 | + } | |
862 | + } | |
863 | + } | |
864 | + } | |
865 | + None | |
866 | + } | |
867 | + | |
868 | + async fn handle_inner<B: Send + 'static>( | |
869 | + &self, | |
870 | + req: Request<B>, | |
871 | + next: Next<'_, B>, | |
872 | + ) -> Result<Response<ResponseBody>> { | |
873 | + let path = req.uri().path().to_string(); | |
874 | + if !self.required_for_path(&path) { | |
875 | + return next.run(req).await; | |
876 | + } | |
877 | + match self.extract_key(&req) { | |
878 | + Some(key) => { | |
879 | + if let Some(claims) = self.keys.get(&key) { | |
880 | + let mut req = req; | |
881 | + req.extensions_mut().insert(claims.clone()); | |
882 | + next.run(req).await | |
883 | + } else { | |
884 | + Ok(json_error(StatusCode::UNAUTHORIZED, "invalid api key")) | |
885 | + } | |
886 | + } | |
887 | + None => Ok(json_error(StatusCode::UNAUTHORIZED, "missing api key")), | |
888 | + } | |
889 | + } | |
890 | +} | |
891 | + | |
892 | +#[async_trait] | |
893 | +impl Middleware<Incoming> for ApiKeyAuth { | |
894 | + async fn handle( | |
895 | + &self, | |
896 | + req: Request<Incoming>, | |
897 | + next: Next<'_, Incoming>, | |
898 | + ) -> Result<Response<ResponseBody>> { | |
899 | + self.handle_inner(req, next).await | |
900 | + } | |
901 | +} | |
902 | + | |
903 | +// --------------------------------------------------------------------------- | |
904 | +// OAuth2 Password flow — token issuance | |
905 | +// --------------------------------------------------------------------------- | |
906 | + | |
907 | +/// User validator for the OAuth2 password flow. | |
908 | +pub type UserValidator = Arc<dyn Fn(&str, &str) -> Option<serde_json::Value> + Send + Sync>; | |
909 | + | |
910 | +/// OAuth2 Password-flow provider. | |
911 | +/// | |
912 | +/// Issues signed JWTs in exchange for valid `username`/`password` credentials | |
913 | +/// (the Resource Owner Password Credentials Grant, RFC 6749 §4.3). The returned | |
914 | +/// token handler can be mounted at any path (default `/token`). Use | |
915 | +/// [`OAuth2Password::jwt_auth`] to obtain a pre-configured [`JwtAuth`] that | |
916 | +/// verifies the issued tokens. | |
917 | +/// | |
918 | +/// # Example | |
919 | +/// | |
920 | +/// ```ignore | |
921 | +/// let oauth2 = OAuth2Password::new( | |
922 | +/// |user, pass| { | |
923 | +/// if user == "alice" && pass == "secret" { | |
924 | +/// Some(serde_json::json!({"sub": "alice", "roles": ["user"]})) | |
925 | +/// } else { None } | |
926 | +/// }, | |
927 | +/// jsonwebtoken::EncodingKey::from_secret(b"my_secret"), | |
928 | +/// jsonwebtoken::DecodingKey::from_secret(b"my_secret"), | |
929 | +/// ); | |
930 | +/// // Register token endpoint + add JwtAuth middleware: | |
931 | +/// // server.with_oauth2_password(oauth2); | |
932 | +/// ``` | |
933 | +pub struct OAuth2Password { | |
934 | + validate_user: UserValidator, | |
935 | + encoding_key: jsonwebtoken::EncodingKey, | |
936 | + decoding_key: jsonwebtoken::DecodingKey, | |
937 | + algorithm: jsonwebtoken::Algorithm, | |
938 | + pub(crate) token_path: String, | |
939 | + token_expiry: Duration, | |
940 | + issuer: Option<String>, | |
941 | +} | |
942 | + | |
943 | +impl OAuth2Password { | |
944 | + /// Create a new OAuth2 password-flow provider. | |
945 | + /// | |
946 | + /// `validate_user` is called with `(username, password)` and should return | |
947 | + /// `Some(claims)` on success (the `sub` claim is typically included) or | |
948 | + /// `None` on invalid credentials. | |
949 | + /// | |
950 | + /// For symmetric (HS*) keys, the same secret bytes work for both encoding | |
951 | + /// and decoding. For asymmetric (RS*/ES*) algorithms, provide the private | |
952 | + /// key as `encoding_key` and the public key as `decoding_key`. | |
953 | + pub fn new( | |
954 | + validate_user: UserValidator, | |
955 | + encoding_key: jsonwebtoken::EncodingKey, | |
956 | + decoding_key: jsonwebtoken::DecodingKey, | |
957 | + ) -> Self { | |
958 | + Self { | |
959 | + validate_user, | |
960 | + encoding_key, | |
961 | + decoding_key, | |
962 | + algorithm: jsonwebtoken::Algorithm::HS256, | |
963 | + token_path: "/token".to_string(), | |
964 | + token_expiry: Duration::from_secs(3600), // 1 hour | |
965 | + issuer: None, | |
966 | + } | |
967 | + } | |
968 | + | |
969 | + /// Set the signing algorithm (default `HS256`). | |
970 | + pub fn with_algorithm(mut self, alg: jsonwebtoken::Algorithm) -> Self { | |
971 | + self.algorithm = alg; | |
972 | + self | |
973 | + } | |
974 | + | |
975 | + /// Set the token endpoint path (default `/token`). | |
976 | + pub fn with_token_path(mut self, path: &str) -> Self { | |
977 | + self.token_path = path.to_string(); | |
978 | + self | |
979 | + } | |
980 | + | |
981 | + /// Set the access-token expiry (default 1 hour). | |
982 | + pub fn with_token_expiry(mut self, expiry: Duration) -> Self { | |
983 | + self.token_expiry = expiry; | |
984 | + self | |
985 | + } | |
986 | + | |
987 | + /// Set the `iss` (issuer) claim included in issued tokens. | |
988 | + pub fn with_issuer(mut self, issuer: &str) -> Self { | |
989 | + self.issuer = Some(issuer.to_string()); | |
990 | + self | |
991 | + } | |
992 | + | |
993 | + /// Return a handler for the OAuth2 token endpoint, generic over body type | |
994 | + /// (works with `Incoming` in production and `Full<Bytes>` in tests). | |
995 | + /// | |
996 | + /// The handler expects `POST` with `application/x-www-form-urlencoded` body | |
997 | + /// containing `grant_type=password`, `username`, and `password` fields. | |
998 | + /// On success it returns `{"access_token": "...", "token_type": "bearer"}`. | |
999 | + pub fn token_handler<B>(&self) -> HandlerFn<B> | |
1000 | + where | |
1001 | + B: http_body::Body<Data = Bytes> + Send + 'static, | |
1002 | + B::Error: std::error::Error + Send + Sync + 'static, | |
1003 | + { | |
1004 | + let validate_user = self.validate_user.clone(); | |
1005 | + let encoding_key_ref = self.encoding_key.clone(); | |
1006 | + let algorithm = self.algorithm; | |
1007 | + let token_expiry = self.token_expiry; | |
1008 | + let issuer = self.issuer.clone(); | |
1009 | + let path = self.token_path.clone(); | |
1010 | + | |
1011 | + Arc::new(move |req: Request<B>| { | |
1012 | + let validate_user = validate_user.clone(); | |
1013 | + let encoding_key_ref = encoding_key_ref.clone(); | |
1014 | + let algorithm = algorithm; | |
1015 | + let token_expiry = token_expiry; | |
1016 | + let issuer = issuer.clone(); | |
1017 | + let path = path.clone(); | |
1018 | + | |
1019 | + Box::pin(async move { | |
1020 | + if req.method() != Method::POST { | |
1021 | + return Ok(json_error( | |
1022 | + StatusCode::METHOD_NOT_ALLOWED, | |
1023 | + "only POST is allowed", | |
1024 | + )); | |
1025 | + } | |
1026 | + | |
1027 | + if req.uri().path() != path { | |
1028 | + return Ok(json_error(StatusCode::NOT_FOUND, "not found")); | |
1029 | + } | |
1030 | + | |
1031 | + let content_type = req | |
1032 | + .headers() | |
1033 | + .get("content-type") | |
1034 | + .and_then(|v| v.to_str().ok()) | |
1035 | + .unwrap_or(""); | |
1036 | + if !content_type.starts_with("application/x-www-form-urlencoded") { | |
1037 | + return Ok(json_error( | |
1038 | + StatusCode::UNSUPPORTED_MEDIA_TYPE, | |
1039 | + "expected application/x-www-form-urlencoded", | |
1040 | + )); | |
1041 | + } | |
1042 | + | |
1043 | + let body_bytes = req | |
1044 | + .collect() | |
1045 | + .await | |
1046 | + .map_err(|e| anyhow::anyhow!(e))? | |
1047 | + .to_bytes(); | |
1048 | + let body_str = String::from_utf8_lossy(&body_bytes).to_string(); | |
1049 | + | |
1050 | + let params: HashMap<String, String> = serde_urlencoded::from_str(&body_str) | |
1051 | + .map_err(|_| anyhow::anyhow!("bad form"))?; | |
1052 | + | |
1053 | + let grant_type = params.get("grant_type").map(|s| s.as_str()).unwrap_or(""); | |
1054 | + if grant_type != "password" { | |
1055 | + return Ok(json_error( | |
1056 | + StatusCode::BAD_REQUEST, | |
1057 | + "unsupported grant_type, expected 'password'", | |
1058 | + )); | |
1059 | + } | |
1060 | + | |
1061 | + let username = params.get("username").map(|s| s.as_str()).unwrap_or(""); | |
1062 | + let password = params.get("password").map(|s| s.as_str()).unwrap_or(""); | |
1063 | + | |
1064 | + if username.is_empty() || password.is_empty() { | |
1065 | + return Ok(json_error( | |
1066 | + StatusCode::BAD_REQUEST, | |
1067 | + "username and password are required", | |
1068 | + )); | |
1069 | + } | |
1070 | + | |
1071 | + let claims_value = match (validate_user)(username, password) { | |
1072 | + Some(c) => c, | |
1073 | + None => { | |
1074 | + return Ok(json_error( | |
1075 | + StatusCode::UNAUTHORIZED, | |
1076 | + "invalid username or password", | |
1077 | + )) | |
1078 | + } | |
1079 | + }; | |
1080 | + | |
1081 | + let sub = claims_value | |
1082 | + .get("sub") | |
1083 | + .and_then(|v| v.as_str()) | |
1084 | + .unwrap_or(username) | |
1085 | + .to_string(); | |
1086 | + | |
1087 | + let now = std::time::SystemTime::now() | |
1088 | + .duration_since(std::time::UNIX_EPOCH) | |
1089 | + .unwrap() | |
1090 | + .as_secs(); | |
1091 | + | |
1092 | + let mut jwt_claims = serde_json::Map::new(); | |
1093 | + jwt_claims.insert("sub".to_string(), serde_json::Value::String(sub)); | |
1094 | + jwt_claims.insert( | |
1095 | + "iat".to_string(), | |
1096 | + serde_json::Value::Number(serde_json::Number::from(now)), | |
1097 | + ); | |
1098 | + jwt_claims.insert( | |
1099 | + "exp".to_string(), | |
1100 | + serde_json::Value::Number(serde_json::Number::from( | |
1101 | + now + token_expiry.as_secs(), | |
1102 | + )), | |
1103 | + ); | |
1104 | + if let Some(iss) = &issuer { | |
1105 | + jwt_claims.insert("iss".to_string(), serde_json::Value::String(iss.clone())); | |
1106 | + } | |
1107 | + // Merge user claims | |
1108 | + if let Some(obj) = claims_value.as_object() { | |
1109 | + for (k, v) in obj { | |
1110 | + if !["sub", "iat", "exp", "iss"].contains(&k.as_str()) { | |
1111 | + jwt_claims.insert(k.clone(), v.clone()); | |
1112 | + } | |
1113 | + } | |
1114 | + } | |
1115 | + | |
1116 | + let header = jsonwebtoken::Header::new(algorithm); | |
1117 | + let token = jsonwebtoken::encode( | |
1118 | + &header, | |
1119 | + &serde_json::Value::Object(jwt_claims), | |
1120 | + &encoding_key_ref, | |
1121 | + ) | |
1122 | + .map_err(|e| anyhow::anyhow!("token encoding failed: {}", e))?; | |
1123 | + | |
1124 | + let body = serde_json::json!({ | |
1125 | + "access_token": token, | |
1126 | + "token_type": "bearer", | |
1127 | + "expires_in": token_expiry.as_secs(), | |
1128 | + }); | |
1129 | + let body_bytes = serde_json::to_vec(&body)?; | |
1130 | + let resp = Response::builder() | |
1131 | + .status(StatusCode::OK) | |
1132 | + .header("content-type", "application/json") | |
1133 | + .header("content-length", body_bytes.len().to_string()) | |
1134 | + .header("cache-control", "no-store") | |
1135 | + .body(UnsyncBoxBody::new( | |
1136 | + http_body_util::Full::new(Bytes::from(body_bytes)) | |
1137 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
1138 | + )) | |
1139 | + .unwrap(); | |
1140 | + Ok(resp) | |
1141 | + }) | |
1142 | + }) | |
1143 | + } | |
1144 | + | |
1145 | + /// Obtain a [`JwtAuth`] pre-configured to verify tokens issued by this | |
1146 | + /// provider. You may add path rules via the returned builder. | |
1147 | + pub fn jwt_auth(&self) -> JwtAuth { | |
1148 | + let mut validation = jsonwebtoken::Validation::new(self.algorithm); | |
1149 | + if let Some(iss) = &self.issuer { | |
1150 | + validation.iss = Some([iss.clone()].into()); | |
1151 | + } | |
1152 | + JwtAuth { | |
1153 | + validation, | |
1154 | + decoding_key: self.decoding_key.clone(), | |
1155 | + route_rules: Vec::new(), | |
1156 | + default_requirement: JwtRequirement::Required, | |
1157 | + } | |
1158 | + } | |
1159 | +} | |
1160 | + | |
1161 | +// --------------------------------------------------------------------------- | |
1162 | +// Tests — use Request<http_body_util::Full<Bytes>> for constructability | |
1163 | +// --------------------------------------------------------------------------- | |
1164 | + | |
1165 | +#[cfg(test)] | |
1166 | +mod tests { | |
1167 | + use super::*; | |
1168 | + use crate::json_response; | |
1169 | + | |
1170 | + type TestBody = http_body_util::Full<Bytes>; | |
1171 | + | |
1172 | + #[async_trait] | |
1173 | + impl Middleware<TestBody> for Cors { | |
1174 | + async fn handle( | |
1175 | + &self, | |
1176 | + req: Request<TestBody>, | |
1177 | + next: Next<'_, TestBody>, | |
1178 | + ) -> Result<Response<ResponseBody>> { | |
1179 | + let req_origin = req.headers().get("origin").and_then(|v| v.to_str().ok()); | |
1180 | + | |
1181 | + let allow_all = self.allow_origins.iter().any(|a| a == "*"); | |
1182 | + | |
1183 | + let matched_origin = if allow_all { | |
1184 | + "*".to_string() | |
1185 | + } else if let Some(origin) = req_origin { | |
1186 | + if origin_matches(origin, &self.allow_origins) { | |
1187 | + origin.to_string() | |
1188 | + } else { | |
1189 | + return next.run(req).await; | |
1190 | + } | |
1191 | + } else { | |
1192 | + return next.run(req).await; | |
1193 | + }; | |
1194 | + | |
1195 | + let add_vary = !allow_all; | |
1196 | + | |
1197 | + if req.method() == Method::OPTIONS { | |
1198 | + let mut builder = Response::builder() | |
1199 | + .status(StatusCode::NO_CONTENT) | |
1200 | + .header("access-control-allow-origin", matched_origin) | |
1201 | + .header("access-control-allow-methods", &self.allow_methods) | |
1202 | + .header("access-control-allow-headers", &self.allow_headers) | |
1203 | + .header("access-control-max-age", &self.max_age); | |
1204 | + | |
1205 | + if self.allow_credentials { | |
1206 | + builder = builder.header("access-control-allow-credentials", "true"); | |
1207 | + } | |
1208 | + if !self.expose_headers.is_empty() { | |
1209 | + builder = builder.header( | |
1210 | + "access-control-expose-headers", | |
1211 | + self.expose_headers.join(", "), | |
1212 | + ); | |
1213 | + } | |
1214 | + if add_vary { | |
1215 | + builder = builder.header("vary", "Origin"); | |
1216 | + } | |
1217 | + | |
1218 | + let resp = builder | |
1219 | + .body(UnsyncBoxBody::new( | |
1220 | + http_body_util::Full::new(Bytes::new()) | |
1221 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
1222 | + )) | |
1223 | + .unwrap(); | |
1224 | + return Ok(resp); | |
1225 | + } | |
1226 | + | |
1227 | + let mut resp = next.run(req).await?; | |
1228 | + resp.headers_mut().insert( | |
1229 | + "access-control-allow-origin", | |
1230 | + matched_origin.parse().unwrap(), | |
1231 | + ); | |
1232 | + if self.allow_credentials { | |
1233 | + resp.headers_mut() | |
1234 | + .insert("access-control-allow-credentials", "true".parse().unwrap()); | |
1235 | + } | |
1236 | + if !self.expose_headers.is_empty() { | |
1237 | + resp.headers_mut().insert( | |
1238 | + "access-control-expose-headers", | |
1239 | + self.expose_headers.join(", ").parse().unwrap(), | |
1240 | + ); | |
1241 | + } | |
1242 | + if add_vary { | |
1243 | + resp.headers_mut().insert("vary", "Origin".parse().unwrap()); | |
1244 | + } | |
1245 | + Ok(resp) | |
1246 | + } | |
1247 | + } | |
1248 | + | |
1249 | + #[async_trait] | |
1250 | + impl Middleware<TestBody> for SecurityHeaders { | |
1251 | + async fn handle( | |
1252 | + &self, | |
1253 | + req: Request<TestBody>, | |
1254 | + next: Next<'_, TestBody>, | |
1255 | + ) -> Result<Response<ResponseBody>> { | |
1256 | + let mut resp = next.run(req).await?; | |
1257 | + let headers = resp.headers_mut(); | |
1258 | + headers.insert("x-content-type-options", "nosniff".parse().unwrap()); | |
1259 | + if self.include_xfo { | |
1260 | + headers.insert("x-frame-options", "DENY".parse().unwrap()); | |
1261 | + } | |
1262 | + let hsts_val = if self.include_hsts_preload { | |
1263 | + format!("{}; preload", self.hsts.trim_end_matches("; preload")) | |
1264 | + } else { | |
1265 | + self.hsts.clone() | |
1266 | + }; | |
1267 | + headers.insert("strict-transport-security", hsts_val.parse().unwrap()); | |
1268 | + if self.include_csp { | |
1269 | + let csp = self.csp_directives.join("; "); | |
1270 | + headers.insert("content-security-policy", csp.parse().unwrap()); | |
1271 | + } | |
1272 | + headers.insert("x-xss-protection", "0".parse().unwrap()); | |
1273 | + Ok(resp) | |
1274 | + } | |
1275 | + } | |
1276 | + | |
1277 | + #[async_trait] | |
1278 | + impl Middleware<TestBody> for JwtAuth { | |
1279 | + async fn handle( | |
1280 | + &self, | |
1281 | + req: Request<TestBody>, | |
1282 | + next: Next<'_, TestBody>, | |
1283 | + ) -> Result<Response<ResponseBody>> { | |
1284 | + let path = req.uri().path().to_string(); | |
1285 | + let requirement = self.requirement_for_path(&path); | |
1286 | + | |
1287 | + if matches!(requirement, JwtRequirement::None) { | |
1288 | + return next.run(req).await; | |
1289 | + } | |
1290 | + | |
1291 | + let auth_header = req | |
1292 | + .headers() | |
1293 | + .get("authorization") | |
1294 | + .and_then(|v| v.to_str().ok()); | |
1295 | + let token = match auth_header { | |
1296 | + Some(h) if h.starts_with("Bearer ") => &h[7..], | |
1297 | + _ => { | |
1298 | + return Ok(json_error( | |
1299 | + StatusCode::UNAUTHORIZED, | |
1300 | + "missing or invalid authorization header", | |
1301 | + )) | |
1302 | + } | |
1303 | + }; | |
1304 | + match jsonwebtoken::decode::<serde_json::Value>( | |
1305 | + token, | |
1306 | + &self.decoding_key, | |
1307 | + &self.validation, | |
1308 | + ) { | |
1309 | + Ok(token_data) => { | |
1310 | + if let Err(msg) = check_claims(&token_data.claims, requirement) { | |
1311 | + return Ok(json_error(StatusCode::FORBIDDEN, msg)); | |
1312 | + } | |
1313 | + let mut req = req; | |
1314 | + req.extensions_mut().insert(token_data.claims); | |
1315 | + next.run(req).await | |
1316 | + } | |
1317 | + Err(_) => Ok(json_error(StatusCode::UNAUTHORIZED, "invalid token")), | |
1318 | + } | |
1319 | + } | |
1320 | + } | |
1321 | + | |
1322 | + #[async_trait] | |
1323 | + impl Middleware<TestBody> for RateLimiter { | |
1324 | + async fn handle( | |
1325 | + &self, | |
1326 | + req: Request<TestBody>, | |
1327 | + next: Next<'_, TestBody>, | |
1328 | + ) -> Result<Response<ResponseBody>> { | |
1329 | + match self.limiter.check() { | |
1330 | + Ok(()) => next.run(req).await, | |
1331 | + Err(negative) => { | |
1332 | + let clock = governor::clock::DefaultClock::default(); | |
1333 | + let now = clock.now(); | |
1334 | + let wait = negative.wait_time_from(now); | |
1335 | + let retry_after_secs = wait.as_secs().max(1); | |
1336 | + let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded"); | |
1337 | + resp.headers_mut() | |
1338 | + .insert("retry-after", retry_after_secs.to_string().parse().unwrap()); | |
1339 | + Ok(resp) | |
1340 | + } | |
1341 | + } | |
1342 | + } | |
1343 | + } | |
1344 | + | |
1345 | + fn test_req(method: Method, uri: &str) -> Request<TestBody> { | |
1346 | + Request::builder() | |
1347 | + .method(method) | |
1348 | + .uri(uri) | |
1349 | + .body(TestBody::new(Bytes::new())) | |
1350 | + .unwrap() | |
1351 | + } | |
1352 | + | |
1353 | + fn test_req_with_header(method: Method, uri: &str, key: &str, val: &str) -> Request<TestBody> { | |
1354 | + let mut req = test_req(method, uri); | |
1355 | + req.headers_mut().insert( | |
1356 | + hyper::header::HeaderName::from_bytes(key.as_bytes()).unwrap(), | |
1357 | + hyper::header::HeaderValue::from_str(val).unwrap(), | |
1358 | + ); | |
1359 | + req | |
1360 | + } | |
1361 | + | |
1362 | + #[tokio::test] | |
1363 | + async fn test_cors_preflight() { | |
1364 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1365 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1366 | + }); | |
1367 | + let mut chain = MiddlewareChain::new(handler); | |
1368 | + chain.add(Cors::permissive()); | |
1369 | + let req = test_req_with_header(Method::OPTIONS, "/hello", "origin", "https://example.com"); | |
1370 | + let resp = chain.run(req).await.unwrap(); | |
1371 | + assert_eq!(resp.status(), StatusCode::NO_CONTENT); | |
1372 | + assert_eq!(resp.headers()["access-control-allow-origin"], "*"); | |
1373 | + } | |
1374 | + | |
1375 | + #[tokio::test] | |
1376 | + async fn test_cors_headers_added() { | |
1377 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1378 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1379 | + }); | |
1380 | + let mut chain = MiddlewareChain::new(handler); | |
1381 | + chain.add(Cors::permissive()); | |
1382 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); | |
1383 | + let resp = chain.run(req).await.unwrap(); | |
1384 | + assert_eq!(resp.headers()["access-control-allow-origin"], "*"); | |
1385 | + } | |
1386 | + | |
1387 | + #[tokio::test] | |
1388 | + async fn test_security_headers() { | |
1389 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1390 | + Box::pin(async { Ok(json_response(StatusCode::OK, "hi")) }) | |
1391 | + }); | |
1392 | + let mut chain = MiddlewareChain::new(handler); | |
1393 | + chain.add(SecurityHeaders::default()); | |
1394 | + let req = test_req(Method::GET, "/hello"); | |
1395 | + let resp = chain.run(req).await.unwrap(); | |
1396 | + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); | |
1397 | + assert_eq!(resp.headers()["x-frame-options"], "DENY"); | |
1398 | + } | |
1399 | + | |
1400 | + #[tokio::test] | |
1401 | + async fn test_jwt_missing_header() { | |
1402 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1403 | + Box::pin(async { Ok(json_response(StatusCode::OK, "hi")) }) | |
1404 | + }); | |
1405 | + let mut chain = MiddlewareChain::new(handler); | |
1406 | + chain.add(JwtAuth::from_secret(b"secret")); | |
1407 | + let req = test_req(Method::GET, "/hello"); | |
1408 | + let resp = chain.run(req).await.unwrap(); | |
1409 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1410 | + } | |
1411 | + | |
1412 | + #[tokio::test] | |
1413 | + async fn test_jwt_invalid_token() { | |
1414 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1415 | + Box::pin(async { Ok(json_response(StatusCode::OK, "hi")) }) | |
1416 | + }); | |
1417 | + let mut chain = MiddlewareChain::new(handler); | |
1418 | + chain.add(JwtAuth::from_secret(b"secret")); | |
1419 | + let req = test_req_with_header( | |
1420 | + Method::GET, | |
1421 | + "/hello", | |
1422 | + "authorization", | |
1423 | + "Bearer invalid.token.here", | |
1424 | + ); | |
1425 | + let resp = chain.run(req).await.unwrap(); | |
1426 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1427 | + } | |
1428 | + | |
1429 | + #[tokio::test] | |
1430 | + async fn test_jwt_valid_token() { | |
1431 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1432 | + Box::pin(async { Ok(json_response(StatusCode::OK, "hi")) }) | |
1433 | + }); | |
1434 | + let mut chain = MiddlewareChain::new(handler); | |
1435 | + chain.add(JwtAuth::from_secret(b"secret")); | |
1436 | + let token = jsonwebtoken::encode( | |
1437 | + &jsonwebtoken::Header::default(), | |
1438 | + &serde_json::json!({"sub": "123", "exp": 9999999999u64}), | |
1439 | + &jsonwebtoken::EncodingKey::from_secret(b"secret"), | |
1440 | + ) | |
1441 | + .unwrap(); | |
1442 | + let req = test_req_with_header( | |
1443 | + Method::GET, | |
1444 | + "/hello", | |
1445 | + "authorization", | |
1446 | + &format!("Bearer {}", token), | |
1447 | + ); | |
1448 | + let resp = chain.run(req).await.unwrap(); | |
1449 | + assert_eq!(resp.status(), StatusCode::OK); | |
1450 | + } | |
1451 | + | |
1452 | + #[tokio::test] | |
1453 | + async fn test_multiple_middlewares() { | |
1454 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1455 | + Box::pin(async { Ok(json_response(StatusCode::OK, "hi")) }) | |
1456 | + }); | |
1457 | + let mut chain = MiddlewareChain::new(handler); | |
1458 | + chain.add(Cors::permissive()); | |
1459 | + chain.add(SecurityHeaders::default()); | |
1460 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); | |
1461 | + let resp = chain.run(req).await.unwrap(); | |
1462 | + assert_eq!(resp.headers()["access-control-allow-origin"], "*"); | |
1463 | + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); | |
1464 | + } | |
1465 | + | |
1466 | + #[tokio::test] | |
1467 | + async fn test_middleware_chain_no_middleware() { | |
1468 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1469 | + Box::pin(async { Ok(json_response(StatusCode::OK, "direct")) }) | |
1470 | + }); | |
1471 | + let chain = MiddlewareChain::new(handler); | |
1472 | + let req = test_req(Method::GET, "/hello"); | |
1473 | + let resp = chain.run(req).await.unwrap(); | |
1474 | + assert_eq!(resp.status(), StatusCode::OK); | |
1475 | + } | |
1476 | + | |
1477 | + // --- Enhanced Cors tests ------------------------------------------------- | |
1478 | + | |
1479 | + #[tokio::test] | |
1480 | + async fn test_cors_specific_origin_allowed() { | |
1481 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1482 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1483 | + }); | |
1484 | + let mut chain = MiddlewareChain::new(handler); | |
1485 | + chain.add(Cors::new().allow_origin("https://example.com")); | |
1486 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); | |
1487 | + let resp = chain.run(req).await.unwrap(); | |
1488 | + assert_eq!( | |
1489 | + resp.headers()["access-control-allow-origin"], | |
1490 | + "https://example.com" | |
1491 | + ); | |
1492 | + assert_eq!(resp.headers()["vary"], "Origin"); | |
1493 | + } | |
1494 | + | |
1495 | + #[tokio::test] | |
1496 | + async fn test_cors_specific_origin_rejected() { | |
1497 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1498 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1499 | + }); | |
1500 | + let mut chain = MiddlewareChain::new(handler); | |
1501 | + chain.add(Cors::new().allow_origin("https://example.com")); | |
1502 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://evil.com"); | |
1503 | + let resp = chain.run(req).await.unwrap(); | |
1504 | + assert!(!resp.headers().contains_key("access-control-allow-origin")); | |
1505 | + } | |
1506 | + | |
1507 | + #[tokio::test] | |
1508 | + async fn test_cors_credentials() { | |
1509 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1510 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1511 | + }); | |
1512 | + let mut chain = MiddlewareChain::new(handler); | |
1513 | + chain.add(Cors::new().allow_credentials()); | |
1514 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); | |
1515 | + let resp = chain.run(req).await.unwrap(); | |
1516 | + assert_eq!(resp.headers()["access-control-allow-credentials"], "true"); | |
1517 | + } | |
1518 | + | |
1519 | + #[tokio::test] | |
1520 | + async fn test_cors_expose_headers() { | |
1521 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1522 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1523 | + }); | |
1524 | + let mut chain = MiddlewareChain::new(handler); | |
1525 | + chain.add(Cors::new().expose_headers(&["x-custom-header", "x-trace-id"])); | |
1526 | + let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); | |
1527 | + let resp = chain.run(req).await.unwrap(); | |
1528 | + assert_eq!( | |
1529 | + resp.headers()["access-control-expose-headers"], | |
1530 | + "x-custom-header, x-trace-id" | |
1531 | + ); | |
1532 | + } | |
1533 | + | |
1534 | + #[tokio::test] | |
1535 | + async fn test_cors_preflight_with_credentials() { | |
1536 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1537 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1538 | + }); | |
1539 | + let mut chain = MiddlewareChain::new(handler); | |
1540 | + chain.add( | |
1541 | + Cors::new() | |
1542 | + .allow_origin("https://app.example.com") | |
1543 | + .allow_credentials() | |
1544 | + .allow_methods("POST, OPTIONS") | |
1545 | + .allow_headers("Authorization, Content-Type"), | |
1546 | + ); | |
1547 | + let req = test_req_with_header( | |
1548 | + Method::OPTIONS, | |
1549 | + "/hello", | |
1550 | + "origin", | |
1551 | + "https://app.example.com", | |
1552 | + ); | |
1553 | + let resp = chain.run(req).await.unwrap(); | |
1554 | + assert_eq!(resp.status(), StatusCode::NO_CONTENT); | |
1555 | + assert_eq!( | |
1556 | + resp.headers()["access-control-allow-origin"], | |
1557 | + "https://app.example.com" | |
1558 | + ); | |
1559 | + assert_eq!(resp.headers()["access-control-allow-credentials"], "true"); | |
1560 | + assert_eq!(resp.headers()["vary"], "Origin"); | |
1561 | + } | |
1562 | + | |
1563 | + // --- Enhanced JwtAuth tests --------------------------------------------- | |
1564 | + | |
1565 | + #[tokio::test] | |
1566 | + async fn test_jwt_no_auth_for_public_route() { | |
1567 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1568 | + Box::pin(async { Ok(json_response(StatusCode::OK, "public")) }) | |
1569 | + }); | |
1570 | + let mut chain = MiddlewareChain::new(handler); | |
1571 | + chain.add( | |
1572 | + JwtAuth::from_secret(b"secret") | |
1573 | + .default_requirement(JwtRequirement::Required) | |
1574 | + .require_for("/public", JwtRequirement::None), | |
1575 | + ); | |
1576 | + // No auth header — should pass for /public | |
1577 | + let req = test_req(Method::GET, "/public/info"); | |
1578 | + let resp = chain.run(req).await.unwrap(); | |
1579 | + assert_eq!(resp.status(), StatusCode::OK); | |
1580 | + } | |
1581 | + | |
1582 | + #[tokio::test] | |
1583 | + async fn test_jwt_required_for_protected_route() { | |
1584 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1585 | + Box::pin(async { Ok(json_response(StatusCode::OK, "protected")) }) | |
1586 | + }); | |
1587 | + let mut chain = MiddlewareChain::new(handler); | |
1588 | + chain.add( | |
1589 | + JwtAuth::from_secret(b"secret") | |
1590 | + .default_requirement(JwtRequirement::Required) | |
1591 | + .require_for("/public", JwtRequirement::None), | |
1592 | + ); | |
1593 | + // No auth header for /admin — should fail | |
1594 | + let req = test_req(Method::GET, "/admin/data"); | |
1595 | + let resp = chain.run(req).await.unwrap(); | |
1596 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1597 | + } | |
1598 | + | |
1599 | + #[tokio::test] | |
1600 | + async fn test_jwt_roles_check() { | |
1601 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1602 | + Box::pin(async { Ok(json_response(StatusCode::OK, "admin only")) }) | |
1603 | + }); | |
1604 | + let mut chain = MiddlewareChain::new(handler); | |
1605 | + chain.add( | |
1606 | + JwtAuth::from_secret(b"secret") | |
1607 | + .default_requirement(JwtRequirement::None) | |
1608 | + .require_for("/admin", JwtRequirement::Roles(vec!["admin".to_string()])), | |
1609 | + ); | |
1610 | + let token = jsonwebtoken::encode( | |
1611 | + &jsonwebtoken::Header::default(), | |
1612 | + &serde_json::json!({"sub": "123", "roles": ["admin"], "exp": 9999999999u64}), | |
1613 | + &jsonwebtoken::EncodingKey::from_secret(b"secret"), | |
1614 | + ) | |
1615 | + .unwrap(); | |
1616 | + let req = test_req_with_header( | |
1617 | + Method::GET, | |
1618 | + "/admin/data", | |
1619 | + "authorization", | |
1620 | + &format!("Bearer {}", token), | |
1621 | + ); | |
1622 | + let resp = chain.run(req).await.unwrap(); | |
1623 | + assert_eq!(resp.status(), StatusCode::OK); | |
1624 | + } | |
1625 | + | |
1626 | + #[tokio::test] | |
1627 | + async fn test_jwt_roles_missing() { | |
1628 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1629 | + Box::pin(async { Ok(json_response(StatusCode::OK, "admin only")) }) | |
1630 | + }); | |
1631 | + let mut chain = MiddlewareChain::new(handler); | |
1632 | + chain.add( | |
1633 | + JwtAuth::from_secret(b"secret") | |
1634 | + .default_requirement(JwtRequirement::None) | |
1635 | + .require_for("/admin", JwtRequirement::Roles(vec!["admin".to_string()])), | |
1636 | + ); | |
1637 | + let token = jsonwebtoken::encode( | |
1638 | + &jsonwebtoken::Header::default(), | |
1639 | + &serde_json::json!({"sub": "123", "roles": ["user"], "exp": 9999999999u64}), | |
1640 | + &jsonwebtoken::EncodingKey::from_secret(b"secret"), | |
1641 | + ) | |
1642 | + .unwrap(); | |
1643 | + let req = test_req_with_header( | |
1644 | + Method::GET, | |
1645 | + "/admin/data", | |
1646 | + "authorization", | |
1647 | + &format!("Bearer {}", token), | |
1648 | + ); | |
1649 | + let resp = chain.run(req).await.unwrap(); | |
1650 | + assert_eq!(resp.status(), StatusCode::FORBIDDEN); | |
1651 | + } | |
1652 | + | |
1653 | + #[tokio::test] | |
1654 | + async fn test_jwt_scopes_check() { | |
1655 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1656 | + Box::pin(async { Ok(json_response(StatusCode::OK, "scoped")) }) | |
1657 | + }); | |
1658 | + let mut chain = MiddlewareChain::new(handler); | |
1659 | + chain.add( | |
1660 | + JwtAuth::from_secret(b"secret") | |
1661 | + .default_requirement(JwtRequirement::None) | |
1662 | + .require_for( | |
1663 | + "/api", | |
1664 | + JwtRequirement::Scopes(vec!["read:data".to_string()]), | |
1665 | + ), | |
1666 | + ); | |
1667 | + let token = jsonwebtoken::encode( | |
1668 | + &jsonwebtoken::Header::default(), | |
1669 | + &serde_json::json!({"sub": "123", "scope": "read:data write:data", "exp": 9999999999u64}), | |
1670 | + &jsonwebtoken::EncodingKey::from_secret(b"secret"), | |
1671 | + ) | |
1672 | + .unwrap(); | |
1673 | + let req = test_req_with_header( | |
1674 | + Method::GET, | |
1675 | + "/api/resource", | |
1676 | + "authorization", | |
1677 | + &format!("Bearer {}", token), | |
1678 | + ); | |
1679 | + let resp = chain.run(req).await.unwrap(); | |
1680 | + assert_eq!(resp.status(), StatusCode::OK); | |
1681 | + } | |
1682 | + | |
1683 | + #[tokio::test] | |
1684 | + async fn test_jwt_scopes_missing() { | |
1685 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1686 | + Box::pin(async { Ok(json_response(StatusCode::OK, "scoped")) }) | |
1687 | + }); | |
1688 | + let mut chain = MiddlewareChain::new(handler); | |
1689 | + chain.add( | |
1690 | + JwtAuth::from_secret(b"secret") | |
1691 | + .default_requirement(JwtRequirement::None) | |
1692 | + .require_for( | |
1693 | + "/api", | |
1694 | + JwtRequirement::Scopes(vec!["admin:all".to_string()]), | |
1695 | + ), | |
1696 | + ); | |
1697 | + let token = jsonwebtoken::encode( | |
1698 | + &jsonwebtoken::Header::default(), | |
1699 | + &serde_json::json!({"sub": "123", "scope": "read:data", "exp": 9999999999u64}), | |
1700 | + &jsonwebtoken::EncodingKey::from_secret(b"secret"), | |
1701 | + ) | |
1702 | + .unwrap(); | |
1703 | + let req = test_req_with_header( | |
1704 | + Method::GET, | |
1705 | + "/api/resource", | |
1706 | + "authorization", | |
1707 | + &format!("Bearer {}", token), | |
1708 | + ); | |
1709 | + let resp = chain.run(req).await.unwrap(); | |
1710 | + assert_eq!(resp.status(), StatusCode::FORBIDDEN); | |
1711 | + } | |
1712 | + | |
1713 | + // --- ApiKeyAuth tests ------------------------------------------------ | |
1714 | + | |
1715 | + #[async_trait] | |
1716 | + impl Middleware<TestBody> for ApiKeyAuth { | |
1717 | + async fn handle( | |
1718 | + &self, | |
1719 | + req: Request<TestBody>, | |
1720 | + next: Next<'_, TestBody>, | |
1721 | + ) -> Result<Response<ResponseBody>> { | |
1722 | + self.handle_inner(req, next).await | |
1723 | + } | |
1724 | + } | |
1725 | + | |
1726 | + fn api_chain(handler: HandlerFn<TestBody>, auth: ApiKeyAuth) -> MiddlewareChain<TestBody> { | |
1727 | + let mut chain = MiddlewareChain::new(handler); | |
1728 | + chain.add(auth); | |
1729 | + chain | |
1730 | + } | |
1731 | + | |
1732 | + #[tokio::test] | |
1733 | + async fn api_key_valid_header_passes() { | |
1734 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1735 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1736 | + }); | |
1737 | + let auth = ApiKeyAuth::new("x-api-key").with_key( | |
1738 | + "secret-123", | |
1739 | + serde_json::json!({"sub": "user-1", "roles": ["admin"]}), | |
1740 | + ); | |
1741 | + let chain = api_chain(handler, auth); | |
1742 | + let req = test_req_with_header(Method::GET, "/v1/models", "x-api-key", "secret-123"); | |
1743 | + let resp = chain.run(req).await.unwrap(); | |
1744 | + assert_eq!(resp.status(), StatusCode::OK); | |
1745 | + } | |
1746 | + | |
1747 | + #[tokio::test] | |
1748 | + async fn api_key_missing_is_401() { | |
1749 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1750 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1751 | + }); | |
1752 | + let auth = ApiKeyAuth::new("x-api-key").with_key("secret-123", serde_json::json!({})); | |
1753 | + let chain = api_chain(handler, auth); | |
1754 | + let req = test_req(Method::GET, "/v1/models"); | |
1755 | + let resp = chain.run(req).await.unwrap(); | |
1756 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1757 | + } | |
1758 | + | |
1759 | + #[tokio::test] | |
1760 | + async fn api_key_invalid_is_401() { | |
1761 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1762 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1763 | + }); | |
1764 | + let auth = ApiKeyAuth::new("x-api-key").with_key("secret-123", serde_json::json!({})); | |
1765 | + let chain = api_chain(handler, auth); | |
1766 | + let req = test_req_with_header(Method::GET, "/v1/models", "x-api-key", "wrong"); | |
1767 | + let resp = chain.run(req).await.unwrap(); | |
1768 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1769 | + } | |
1770 | + | |
1771 | + #[tokio::test] | |
1772 | + async fn api_key_query_param_accepted() { | |
1773 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1774 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1775 | + }); | |
1776 | + let auth = ApiKeyAuth::new("x-api-key") | |
1777 | + .with_query_param("api_key") | |
1778 | + .with_key("secret-123", serde_json::json!({})); | |
1779 | + let chain = api_chain(handler, auth); | |
1780 | + let req = test_req(Method::GET, "/v1/models?api_key=secret-123"); | |
1781 | + let resp = chain.run(req).await.unwrap(); | |
1782 | + assert_eq!(resp.status(), StatusCode::OK); | |
1783 | + } | |
1784 | + | |
1785 | + #[tokio::test] | |
1786 | + async fn api_key_exempt_path_skips_auth() { | |
1787 | + let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| { | |
1788 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
1789 | + }); | |
1790 | + let auth = ApiKeyAuth::new("x-api-key") | |
1791 | + .require_for("/public", false) | |
1792 | + .with_key("secret-123", serde_json::json!({})); | |
1793 | + let chain = api_chain(handler, auth); | |
1794 | + // No key on an exempt path → 200. | |
1795 | + let req = test_req(Method::GET, "/public/health"); | |
1796 | + let resp = chain.run(req).await.unwrap(); | |
1797 | + assert_eq!(resp.status(), StatusCode::OK); | |
1798 | + // No key on a protected path → 401. | |
1799 | + let req = test_req(Method::GET, "/v1/models"); | |
1800 | + let resp = chain.run(req).await.unwrap(); | |
1801 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1802 | + } | |
1803 | + | |
1804 | + #[tokio::test] | |
1805 | + async fn api_key_claims_injected_to_extensions() { | |
1806 | + let seen = Arc::new(std::sync::Mutex::new(None::<serde_json::Value>)); | |
1807 | + let seen2 = seen.clone(); | |
1808 | + let handler: HandlerFn<TestBody> = Arc::new(move |req: Request<TestBody>| { | |
1809 | + let s = seen2.clone(); | |
1810 | + Box::pin(async move { | |
1811 | + let claims = req.extensions().get::<serde_json::Value>().cloned(); | |
1812 | + *s.lock().unwrap() = claims; | |
1813 | + Ok(json_response(StatusCode::OK, "ok")) | |
1814 | + }) | |
1815 | + }); | |
1816 | + let auth = ApiKeyAuth::new("x-api-key") | |
1817 | + .with_key("secret-123", serde_json::json!({"sub": "user-9"})); | |
1818 | + let chain = api_chain(handler, auth); | |
1819 | + let req = test_req_with_header(Method::GET, "/v1/models", "x-api-key", "secret-123"); | |
1820 | + let resp = chain.run(req).await.unwrap(); | |
1821 | + assert_eq!(resp.status(), StatusCode::OK); | |
1822 | + let claims = seen.lock().unwrap(); | |
1823 | + assert_eq!(claims.as_ref().unwrap()["sub"], "user-9"); | |
1824 | + } | |
1825 | + | |
1826 | + // --- OAuth2Password tests --------------------------------------------- | |
1827 | + | |
1828 | + fn form_req(method: Method, uri: &str, form_body: &str) -> Request<TestBody> { | |
1829 | + Request::builder() | |
1830 | + .method(method) | |
1831 | + .uri(uri) | |
1832 | + .header("content-type", "application/x-www-form-urlencoded") | |
1833 | + .body(TestBody::new(Bytes::from(form_body.to_string()))) | |
1834 | + .unwrap() | |
1835 | + } | |
1836 | + | |
1837 | + #[tokio::test] | |
1838 | + async fn oauth2_non_post_returns_405() { | |
1839 | + let oauth2 = OAuth2Password::new( | |
1840 | + Arc::new(|_, _| Some(serde_json::json!({"sub": "test"}))), | |
1841 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1842 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1843 | + ); | |
1844 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1845 | + let req = test_req(Method::GET, "/token"); | |
1846 | + let resp = handler(req).await.unwrap(); | |
1847 | + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); | |
1848 | + } | |
1849 | + | |
1850 | + #[tokio::test] | |
1851 | + async fn oauth2_wrong_content_type_returns_415() { | |
1852 | + let oauth2 = OAuth2Password::new( | |
1853 | + Arc::new(|_, _| Some(serde_json::json!({"sub": "test"}))), | |
1854 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1855 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1856 | + ); | |
1857 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1858 | + let req = Request::builder() | |
1859 | + .method(Method::POST) | |
1860 | + .uri("/token") | |
1861 | + .header("content-type", "application/json") | |
1862 | + .body(TestBody::new(Bytes::from(r#"{"a":1}"#))) | |
1863 | + .unwrap(); | |
1864 | + let resp = handler(req).await.unwrap(); | |
1865 | + assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); | |
1866 | + } | |
1867 | + | |
1868 | + #[tokio::test] | |
1869 | + async fn oauth2_bad_grant_type_returns_400() { | |
1870 | + let oauth2 = OAuth2Password::new( | |
1871 | + Arc::new(|_, _| Some(serde_json::json!({"sub": "test"}))), | |
1872 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1873 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1874 | + ); | |
1875 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1876 | + let req = form_req( | |
1877 | + Method::POST, | |
1878 | + "/token", | |
1879 | + "grant_type=client_credentials&username=alice&password=secret", | |
1880 | + ); | |
1881 | + let resp = handler(req).await.unwrap(); | |
1882 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
1883 | + } | |
1884 | + | |
1885 | + #[tokio::test] | |
1886 | + async fn oauth2_missing_credentials_returns_400() { | |
1887 | + let oauth2 = OAuth2Password::new( | |
1888 | + Arc::new(|_, _| Some(serde_json::json!({"sub": "test"}))), | |
1889 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1890 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1891 | + ); | |
1892 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1893 | + let req = form_req(Method::POST, "/token", "grant_type=password"); | |
1894 | + let resp = handler(req).await.unwrap(); | |
1895 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
1896 | + } | |
1897 | + | |
1898 | + #[tokio::test] | |
1899 | + async fn oauth2_invalid_credentials_returns_401() { | |
1900 | + let oauth2 = OAuth2Password::new( | |
1901 | + Arc::new(|u, p| { | |
1902 | + if u == "alice" && p == "correct" { | |
1903 | + Some(serde_json::json!({"sub": "alice"})) | |
1904 | + } else { | |
1905 | + None | |
1906 | + } | |
1907 | + }), | |
1908 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1909 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1910 | + ); | |
1911 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1912 | + let req = form_req( | |
1913 | + Method::POST, | |
1914 | + "/token", | |
1915 | + "grant_type=password&username=alice&password=wrong", | |
1916 | + ); | |
1917 | + let resp = handler(req).await.unwrap(); | |
1918 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
1919 | + } | |
1920 | + | |
1921 | + #[tokio::test] | |
1922 | + async fn oauth2_valid_credentials_returns_token() { | |
1923 | + let oauth2 = OAuth2Password::new( | |
1924 | + Arc::new(|u, p| { | |
1925 | + if u == "alice" && p == "secret" { | |
1926 | + Some(serde_json::json!({"sub": "alice", "roles": ["user"]})) | |
1927 | + } else { | |
1928 | + None | |
1929 | + } | |
1930 | + }), | |
1931 | + jsonwebtoken::EncodingKey::from_secret(b"test"), | |
1932 | + jsonwebtoken::DecodingKey::from_secret(b"test"), | |
1933 | + ); | |
1934 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1935 | + let req = form_req( | |
1936 | + Method::POST, | |
1937 | + "/token", | |
1938 | + "grant_type=password&username=alice&password=secret", | |
1939 | + ); | |
1940 | + let resp = handler(req).await.unwrap(); | |
1941 | + assert_eq!(resp.status(), StatusCode::OK); | |
1942 | + let body: serde_json::Value = serde_json::from_slice( | |
1943 | + &http_body_util::BodyExt::collect(resp.into_body()) | |
1944 | + .await | |
1945 | + .unwrap() | |
1946 | + .to_bytes(), | |
1947 | + ) | |
1948 | + .unwrap(); | |
1949 | + assert!(body.get("access_token").and_then(|v| v.as_str()).is_some()); | |
1950 | + assert_eq!(body["token_type"], "bearer"); | |
1951 | + assert!(body["expires_in"].as_u64().unwrap() > 0); | |
1952 | + } | |
1953 | + | |
1954 | + #[tokio::test] | |
1955 | + async fn oauth2_issued_token_verified_by_jwt_auth() { | |
1956 | + let oauth2 = OAuth2Password::new( | |
1957 | + Arc::new(|u, p| { | |
1958 | + if u == "alice" && p == "secret" { | |
1959 | + Some(serde_json::json!({"sub": "alice", "roles": ["admin"]})) | |
1960 | + } else { | |
1961 | + None | |
1962 | + } | |
1963 | + }), | |
1964 | + jsonwebtoken::EncodingKey::from_secret(b"test_key"), | |
1965 | + jsonwebtoken::DecodingKey::from_secret(b"test_key"), | |
1966 | + ); | |
1967 | + // 1. Get a token from the token handler. | |
1968 | + let handler: HandlerFn<TestBody> = oauth2.token_handler(); | |
1969 | + let req = form_req( | |
1970 | + Method::POST, | |
1971 | + "/token", | |
1972 | + "grant_type=password&username=alice&password=secret", | |
1973 | + ); | |
1974 | + let resp = handler(req).await.unwrap(); | |
1975 | + let body: serde_json::Value = serde_json::from_slice( | |
1976 | + &http_body_util::BodyExt::collect(resp.into_body()) | |
1977 | + .await | |
1978 | + .unwrap() | |
1979 | + .to_bytes(), | |
1980 | + ) | |
1981 | + .unwrap(); | |
1982 | + let token = body["access_token"].as_str().unwrap().to_string(); | |
1983 | + | |
1984 | + // 2. Create a JwtAuth from the OAuth2 provider and verify. | |
1985 | + let jwt_auth = oauth2.jwt_auth(); | |
1986 | + let handler2: HandlerFn<TestBody> = Arc::new(|req: Request<TestBody>| { | |
1987 | + // The JwtAuth middleware injects claims into extensions | |
1988 | + let claims = req.extensions().get::<serde_json::Value>().cloned(); | |
1989 | + Box::pin(async move { | |
1990 | + let body = serde_json::json!({ | |
1991 | + "claims": claims, | |
1992 | + "ok": true, | |
1993 | + }); | |
1994 | + Ok(json_response( | |
1995 | + StatusCode::OK, | |
1996 | + &serde_json::to_string(&body).unwrap(), | |
1997 | + )) | |
1998 | + }) | |
1999 | + }); | |
2000 | + let mut chain = MiddlewareChain::new(handler2); | |
2001 | + chain.add(jwt_auth); | |
2002 | + let req = test_req_with_header( | |
2003 | + Method::GET, | |
2004 | + "/protected", | |
2005 | + "authorization", | |
2006 | + &format!("Bearer {}", token), | |
2007 | + ); | |
2008 | + let resp = chain.run(req).await.unwrap(); | |
2009 | + assert_eq!(resp.status(), StatusCode::OK); | |
2010 | + } | |
2011 | +} | |
0 | 2012 | |
▼crates/justapi-core/src/panic.rs
+69-0
| @@ -0,0 +1,69 @@ | ||
1 | +use std::sync::Arc; | |
2 | + | |
3 | +use futures::FutureExt; | |
4 | +use hyper::body::Incoming; | |
5 | +use hyper::{Request, StatusCode}; | |
6 | + | |
7 | +use crate::json_response; | |
8 | +use crate::middleware::HandlerFn; | |
9 | + | |
10 | +/// Wraps a handler fn so that panics are caught, logged, and converted to 500 responses. | |
11 | +pub fn with_panic_recovery(handler: HandlerFn) -> HandlerFn { | |
12 | + Arc::new(move |req: Request<Incoming>| { | |
13 | + let handler = handler.clone(); | |
14 | + Box::pin(async move { | |
15 | + let prev = std::panic::take_hook(); | |
16 | + std::panic::set_hook(Box::new(|info| { | |
17 | + let msg = info | |
18 | + .payload() | |
19 | + .downcast_ref::<&str>() | |
20 | + .map(|s| s.to_string()) | |
21 | + .or_else(|| info.payload().downcast_ref::<String>().cloned()) | |
22 | + .unwrap_or_else(|| "unknown panic".to_string()); | |
23 | + let location = info.location().map(|l| l.to_string()).unwrap_or_default(); | |
24 | + tracing::error!( | |
25 | + panic = true, | |
26 | + message = %msg, | |
27 | + location = %location, | |
28 | + "Handler panicked" | |
29 | + ); | |
30 | + })); | |
31 | + | |
32 | + let result = std::panic::AssertUnwindSafe(handler(req)) | |
33 | + .catch_unwind() | |
34 | + .await; | |
35 | + | |
36 | + std::panic::set_hook(prev); | |
37 | + | |
38 | + match result { | |
39 | + Ok(Ok(resp)) => Ok(resp), | |
40 | + Ok(Err(e)) => { | |
41 | + tracing::error!(error = %e, "Handler returned error"); | |
42 | + Ok(json_response( | |
43 | + StatusCode::INTERNAL_SERVER_ERROR, | |
44 | + r#"{"error":"Internal error"}"#, | |
45 | + )) | |
46 | + } | |
47 | + Err(_panic) => Ok(json_response( | |
48 | + StatusCode::INTERNAL_SERVER_ERROR, | |
49 | + r#"{"error":"Internal server error"}"#, | |
50 | + )), | |
51 | + } | |
52 | + }) | |
53 | + }) | |
54 | +} | |
55 | + | |
56 | +#[cfg(test)] | |
57 | +mod tests { | |
58 | + use super::*; | |
59 | + | |
60 | + #[test] | |
61 | + fn test_with_panic_recovery_returns_handler_fn() { | |
62 | + let handler: HandlerFn = Arc::new(|_req: Request<Incoming>| { | |
63 | + Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) | |
64 | + }); | |
65 | + let wrapped = with_panic_recovery(handler); | |
66 | + // Smoke test: the wrapper is itself a HandlerFn with the same signature. | |
67 | + let _wrapped_ref: &HandlerFn = &wrapped; | |
68 | + } | |
69 | +} | |
0 | 70 | |
▼crates/justapi-core/src/rate_limit.rs
+111-0
| @@ -0,0 +1,111 @@ | ||
1 | +use redis::aio::ConnectionManager; | |
2 | +use std::time::{SystemTime, UNIX_EPOCH}; | |
3 | + | |
4 | +#[derive(Debug, Clone)] | |
5 | +pub struct RateLimitResult { | |
6 | + pub allowed: bool, | |
7 | + pub retry_after_ms: u64, | |
8 | +} | |
9 | + | |
10 | +#[derive(Clone)] | |
11 | +pub enum RateLimiter { | |
12 | + Redis { | |
13 | + client: ConnectionManager, | |
14 | + script: redis::Script, | |
15 | + }, | |
16 | + // We can add a local governor fallback later if needed | |
17 | + // Local(Arc<governor::DefaultDirectRateLimiter>), | |
18 | +} | |
19 | + | |
20 | +impl RateLimiter { | |
21 | + /// Creates a new Redis-backed rate limiter. | |
22 | + pub async fn new_redis(redis_url: &str) -> anyhow::Result<Self> { | |
23 | + let client = redis::Client::open(redis_url)?; | |
24 | + let manager = ConnectionManager::new(client).await?; | |
25 | + | |
26 | + let lua_script = r#" | |
27 | + local key = KEYS[1] | |
28 | + local t = tonumber(ARGV[1]) | |
29 | + local tau = tonumber(ARGV[2]) | |
30 | + local now = tonumber(ARGV[3]) | |
31 | + local request = tonumber(ARGV[4]) | |
32 | + | |
33 | + local tat = redis.call('GET', key) | |
34 | + if not tat then | |
35 | + tat = now | |
36 | + else | |
37 | + tat = tonumber(tat) | |
38 | + end | |
39 | + | |
40 | + local new_tat = math.max(tat, now) + (t * request) | |
41 | + local allow_at = new_tat - tau | |
42 | + | |
43 | + if allow_at > now then | |
44 | + -- Rate limited | |
45 | + local retry_after = math.ceil((allow_at - now) / 1000) | |
46 | + return {0, retry_after} | |
47 | + else | |
48 | + -- Allowed | |
49 | + local ttl = math.ceil((new_tat - now) / 1000000) | |
50 | + if ttl > 0 then | |
51 | + redis.call('SET', key, new_tat, 'EX', ttl) | |
52 | + else | |
53 | + redis.call('SET', key, new_tat) | |
54 | + end | |
55 | + return {1, 0} | |
56 | + end | |
57 | + "#; | |
58 | + | |
59 | + Ok(RateLimiter::Redis { | |
60 | + client: manager, | |
61 | + script: redis::Script::new(lua_script), | |
62 | + }) | |
63 | + } | |
64 | + | |
65 | + /// Check if a request is allowed. | |
66 | + /// `capacity`: maximum burst capacity (e.g., 100) | |
67 | + /// `replenish_rate`: tokens to add per second (e.g., 10) | |
68 | + pub async fn check_limit( | |
69 | + &self, | |
70 | + key: &str, | |
71 | + capacity: u64, | |
72 | + replenish_rate: u64, | |
73 | + tokens: u64, | |
74 | + ) -> anyhow::Result<RateLimitResult> { | |
75 | + match self { | |
76 | + RateLimiter::Redis { client, script } => { | |
77 | + let mut conn = client.clone(); | |
78 | + | |
79 | + // Interval per token in microseconds | |
80 | + let t = 1_000_000 / replenish_rate; | |
81 | + // Burst capacity in microseconds | |
82 | + let tau = t * capacity; | |
83 | + | |
84 | + let now = SystemTime::now() | |
85 | + .duration_since(UNIX_EPOCH) | |
86 | + .unwrap() | |
87 | + .as_micros() as u64; | |
88 | + | |
89 | + let result: Vec<u64> = script | |
90 | + .key(key) | |
91 | + .arg(t) | |
92 | + .arg(tau) | |
93 | + .arg(now) | |
94 | + .arg(tokens) | |
95 | + .invoke_async(&mut conn) | |
96 | + .await?; | |
97 | + | |
98 | + if result.len() >= 2 { | |
99 | + let allowed = result[0] == 1; | |
100 | + let retry_after_ms = result[1]; | |
101 | + Ok(RateLimitResult { | |
102 | + allowed, | |
103 | + retry_after_ms, | |
104 | + }) | |
105 | + } else { | |
106 | + Err(anyhow::anyhow!("Invalid response from Redis GCRA script")) | |
107 | + } | |
108 | + } | |
109 | + } | |
110 | + } | |
111 | +} | |
0 | 112 | |
▼crates/justapi-core/src/secrets.rs
+154-0
| @@ -0,0 +1,154 @@ | ||
1 | +use std::collections::HashMap; | |
2 | +use std::sync::Arc; | |
3 | + | |
4 | +/// How a secret is sourced. | |
5 | +#[derive(Debug, Clone)] | |
6 | +pub enum SecretSource { | |
7 | + /// Read from an environment variable. | |
8 | + Env(String), | |
9 | + /// Read from a file on disk (e.g. Docker secrets in `/run/secrets/`). | |
10 | + File(std::path::PathBuf), | |
11 | + /// Inline value (only for development; never commit these). | |
12 | + Inline(String), | |
13 | +} | |
14 | + | |
15 | +/// A named secret that knows how to resolve itself at runtime. | |
16 | +#[derive(Debug, Clone)] | |
17 | +pub struct Secret { | |
18 | + source: SecretSource, | |
19 | + name: String, | |
20 | +} | |
21 | + | |
22 | +impl Secret { | |
23 | + pub fn env(var_name: &str) -> Self { | |
24 | + Self { | |
25 | + source: SecretSource::Env(var_name.to_string()), | |
26 | + name: var_name.to_string(), | |
27 | + } | |
28 | + } | |
29 | + | |
30 | + pub fn file(path: impl Into<std::path::PathBuf>) -> Self { | |
31 | + let path = path.into(); | |
32 | + let name = path | |
33 | + .file_name() | |
34 | + .map(|s| s.to_string_lossy().to_string()) | |
35 | + .unwrap_or_else(|| path.to_string_lossy().to_string()); | |
36 | + Self { | |
37 | + source: SecretSource::File(path), | |
38 | + name, | |
39 | + } | |
40 | + } | |
41 | + | |
42 | + pub fn inline(value: &str, label: &str) -> Self { | |
43 | + Self { | |
44 | + source: SecretSource::Inline(value.to_string()), | |
45 | + name: label.to_string(), | |
46 | + } | |
47 | + } | |
48 | + | |
49 | + /// Resolve the secret to its string value. | |
50 | + /// | |
51 | + /// Tries env var first, then file, then returns the inline value. | |
52 | + /// Never logs the resolved value (only the source name). | |
53 | + pub fn resolve(&self) -> Result<String, anyhow::Error> { | |
54 | + match &self.source { | |
55 | + SecretSource::Env(var) => { | |
56 | + std::env::var(var).map_err(|_| anyhow::anyhow!("env var {} not set", var)) | |
57 | + } | |
58 | + SecretSource::File(path) => std::fs::read_to_string(path) | |
59 | + .map(|s| s.trim().to_string()) | |
60 | + .map_err(|e| anyhow::anyhow!("cannot read secret file {}: {}", path.display(), e)), | |
61 | + SecretSource::Inline(value) => Ok(value.clone()), | |
62 | + } | |
63 | + } | |
64 | +} | |
65 | + | |
66 | +/// A registry of secrets that can be resolved on demand. | |
67 | +/// | |
68 | +/// Secrets are resolved lazily (not at startup) so that the application can | |
69 | +/// start even if a secret source is temporarily unavailable. Use | |
70 | +/// [`SecretsRegistry::resolve_all()`] for eager validation at startup. | |
71 | +#[derive(Debug, Clone, Default)] | |
72 | +pub struct SecretsRegistry { | |
73 | + secrets: Arc<std::sync::RwLock<HashMap<String, Secret>>>, | |
74 | +} | |
75 | + | |
76 | +impl SecretsRegistry { | |
77 | + pub fn new() -> Self { | |
78 | + Self::default() | |
79 | + } | |
80 | + | |
81 | + pub fn register(&self, secret: Secret) { | |
82 | + let mut map = self.secrets.write().unwrap(); | |
83 | + map.insert(secret.name.clone(), secret); | |
84 | + } | |
85 | + | |
86 | + pub fn resolve(&self, name: &str) -> Result<String, anyhow::Error> { | |
87 | + let map = self.secrets.read().unwrap(); | |
88 | + match map.get(name) { | |
89 | + Some(secret) => secret.resolve(), | |
90 | + None => anyhow::bail!("secret '{}' not registered", name), | |
91 | + } | |
92 | + } | |
93 | + | |
94 | + /// Resolve all secrets eagerly. Useful for startup validation. | |
95 | + pub fn resolve_all(&self) -> Vec<(String, Result<String, anyhow::Error>)> { | |
96 | + let map = self.secrets.read().unwrap(); | |
97 | + map.iter() | |
98 | + .map(|(name, secret)| (name.clone(), secret.resolve())) | |
99 | + .collect() | |
100 | + } | |
101 | +} | |
102 | + | |
103 | +#[cfg(test)] | |
104 | +mod tests { | |
105 | + use super::*; | |
106 | + | |
107 | + #[test] | |
108 | + fn test_secret_inline() { | |
109 | + let s = Secret::inline("my-value", "test"); | |
110 | + assert_eq!(s.resolve().unwrap(), "my-value"); | |
111 | + } | |
112 | + | |
113 | + #[test] | |
114 | + fn test_secret_env() { | |
115 | + let s = Secret::env("PATH"); | |
116 | + assert!(s.resolve().is_ok()); | |
117 | + } | |
118 | + | |
119 | + #[test] | |
120 | + fn test_secret_env_missing() { | |
121 | + let s = Secret::env("__DOES_NOT_EXIST_12345__"); | |
122 | + assert!(s.resolve().is_err()); | |
123 | + } | |
124 | + | |
125 | + #[test] | |
126 | + fn test_secret_file_missing() { | |
127 | + let s = Secret::file("/tmp/__does_not_exist_12345__"); | |
128 | + assert!(s.resolve().is_err()); | |
129 | + } | |
130 | + | |
131 | + #[test] | |
132 | + fn test_registry_resolve() { | |
133 | + let reg = SecretsRegistry::new(); | |
134 | + reg.register(Secret::inline("val", "my-key")); | |
135 | + assert_eq!(reg.resolve("my-key").unwrap(), "val"); | |
136 | + } | |
137 | + | |
138 | + #[test] | |
139 | + fn test_registry_missing() { | |
140 | + let reg = SecretsRegistry::new(); | |
141 | + assert!(reg.resolve("nonexistent").is_err()); | |
142 | + } | |
143 | + | |
144 | + #[test] | |
145 | + fn test_registry_resolve_all() { | |
146 | + let reg = SecretsRegistry::new(); | |
147 | + reg.register(Secret::inline("a", "key-a")); | |
148 | + reg.register(Secret::env("PATH")); | |
149 | + reg.register(Secret::file("/tmp/__nonexistent__")); | |
150 | + | |
151 | + let results = reg.resolve_all(); | |
152 | + assert_eq!(results.len(), 3); | |
153 | + } | |
154 | +} | |
0 | 155 | |
▼crates/justapi-core/src/trace_context.rs
+26-0
| @@ -0,0 +1,26 @@ | ||
1 | +/// Extract the current OpenTelemetry trace context as hex strings. | |
2 | +/// | |
3 | +/// Returns `(trace_id_hex, span_id_hex)` if a valid OTel context is | |
4 | +/// available from the current `tracing` span, or `None` otherwise. | |
5 | +#[cfg(feature = "opentelemetry")] | |
6 | +pub fn get_current_trace_context() -> Option<(String, String)> { | |
7 | + use opentelemetry::trace::TraceContextExt as _; | |
8 | + use tracing_opentelemetry::OpenTelemetrySpanExt as _; | |
9 | + | |
10 | + let span = tracing::Span::current(); | |
11 | + let cx = span.context(); | |
12 | + let span_ctx = cx.span(); | |
13 | + let sc = span_ctx.span_context(); | |
14 | + if !sc.is_sampled() { | |
15 | + return None; | |
16 | + } | |
17 | + let trace_id = sc.trace_id().to_string(); | |
18 | + let span_id = sc.span_id().to_string(); | |
19 | + Some((trace_id, span_id)) | |
20 | +} | |
21 | + | |
22 | +/// Fallback when `opentelemetry` feature is disabled. | |
23 | +#[cfg(not(feature = "opentelemetry"))] | |
24 | +pub fn get_current_trace_context() -> Option<(String, String)> { | |
25 | + None | |
26 | +} | |
0 | 27 | |
▼crates/justapi-core/src/tracing_setup.rs
+243-0
| @@ -0,0 +1,243 @@ | ||
1 | +use std::path::Path; | |
2 | +use std::sync::{Mutex, OnceLock}; | |
3 | + | |
4 | +use anyhow::Result; | |
5 | +use tracing_appender::non_blocking::WorkerGuard; | |
6 | +use tracing_subscriber::layer::SubscriberExt; | |
7 | +use tracing_subscriber::util::SubscriberInitExt; | |
8 | +use tracing_subscriber::{EnvFilter, Layer}; | |
9 | + | |
10 | +/// Log output format. | |
11 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
12 | +pub enum LogFormat { | |
13 | + Text, | |
14 | + Json, | |
15 | +} | |
16 | + | |
17 | +/// File rotation strategy. | |
18 | +#[derive(Debug, Clone)] | |
19 | +pub enum FileRotation { | |
20 | + Daily, | |
21 | + Hourly, | |
22 | + Never, | |
23 | +} | |
24 | + | |
25 | +/// OpenTelemetry exporter backend. | |
26 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
27 | +pub enum OtelExporter { | |
28 | + /// Print spans to stdout. | |
29 | + Stdout, | |
30 | + /// Export via OTLP gRPC. | |
31 | + Oltp, | |
32 | +} | |
33 | + | |
34 | +/// Configuration for JustAPI's logging and tracing subsystem. | |
35 | +#[derive(Debug, Clone)] | |
36 | +pub struct LoggingConfig { | |
37 | + pub format: LogFormat, | |
38 | + pub level: String, | |
39 | + pub file_path: Option<String>, | |
40 | + pub file_rotation: FileRotation, | |
41 | + pub otel_exporter: Option<OtelExporter>, | |
42 | + pub otlp_endpoint: Option<String>, | |
43 | + pub service_name: String, | |
44 | +} | |
45 | + | |
46 | +impl Default for LoggingConfig { | |
47 | + fn default() -> Self { | |
48 | + Self { | |
49 | + format: LogFormat::Text, | |
50 | + level: "info".to_string(), | |
51 | + file_path: None, | |
52 | + file_rotation: FileRotation::Daily, | |
53 | + otel_exporter: Some(OtelExporter::Stdout), | |
54 | + otlp_endpoint: None, | |
55 | + service_name: "justapi".to_string(), | |
56 | + } | |
57 | + } | |
58 | +} | |
59 | + | |
60 | +/// Global guard for the non-blocking file appender. | |
61 | +/// Must be held for the program's lifetime to prevent log loss on shutdown. | |
62 | +static FILE_GUARD: OnceLock<Mutex<Option<WorkerGuard>>> = OnceLock::new(); | |
63 | + | |
64 | +fn set_guard(guard: WorkerGuard) { | |
65 | + let lock = FILE_GUARD.get_or_init(|| Mutex::new(None)); | |
66 | + *lock.lock().unwrap() = Some(guard); | |
67 | +} | |
68 | + | |
69 | +#[cfg(feature = "opentelemetry")] | |
70 | +static TRACER_GUARD: OnceLock<Mutex<Option<opentelemetry_sdk::trace::TracerProvider>>> = | |
71 | + OnceLock::new(); | |
72 | + | |
73 | +/// Initialize tracing with the given configuration. | |
74 | +pub fn init_logging(config: &LoggingConfig) -> Result<()> { | |
75 | + let env_filter = | |
76 | + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.level)); | |
77 | + | |
78 | + let fmt_layer = match config.format { | |
79 | + LogFormat::Json => tracing_subscriber::fmt::layer() | |
80 | + .json() | |
81 | + .with_target(true) | |
82 | + .with_thread_ids(true) | |
83 | + .with_file(true) | |
84 | + .with_line_number(true) | |
85 | + .boxed(), | |
86 | + LogFormat::Text => tracing_subscriber::fmt::layer() | |
87 | + .with_target(true) | |
88 | + .with_thread_ids(true) | |
89 | + .with_file(true) | |
90 | + .with_line_number(true) | |
91 | + .boxed(), | |
92 | + }; | |
93 | + | |
94 | + let mut layers = Vec::new(); | |
95 | + | |
96 | + #[cfg(feature = "opentelemetry")] | |
97 | + if let Some(exporter) = &config.otel_exporter { | |
98 | + match exporter { | |
99 | + OtelExporter::Stdout => { | |
100 | + let provider = opentelemetry_sdk::trace::TracerProvider::builder() | |
101 | + .with_simple_exporter(opentelemetry_stdout::SpanExporter::default()) | |
102 | + .build(); | |
103 | + let service_name = config.service_name.clone(); | |
104 | + let tracer = provider.tracer(service_name); | |
105 | + opentelemetry::global::set_tracer_provider(provider.clone()); | |
106 | + layers.push(tracing_opentelemetry::layer().with_tracer(tracer).boxed()); | |
107 | + set_tracer_guard(provider); | |
108 | + } | |
109 | + OtelExporter::Oltp => { | |
110 | + let endpoint = config | |
111 | + .otlp_endpoint | |
112 | + .clone() | |
113 | + .unwrap_or_else(|| "http://localhost:4317".to_string()); | |
114 | + let provider = opentelemetry_otlp::new_pipeline() | |
115 | + .tracing() | |
116 | + .with_exporter( | |
117 | + opentelemetry_otlp::new_exporter() | |
118 | + .tonic() | |
119 | + .with_endpoint(endpoint), | |
120 | + ) | |
121 | + .install_batch(opentelemetry_sdk::runtime::Tokio) | |
122 | + .map_err(|e| anyhow::anyhow!("Failed to build OTLP tracing: {}", e))?; | |
123 | + let service_name = config.service_name.clone(); | |
124 | + let tracer = provider.tracer(service_name); | |
125 | + opentelemetry::global::set_tracer_provider(provider.clone()); | |
126 | + layers.push(tracing_opentelemetry::layer().with_tracer(tracer).boxed()); | |
127 | + set_tracer_guard(provider); | |
128 | + } | |
129 | + } | |
130 | + } | |
131 | + | |
132 | + if let Some(ref path) = config.file_path { | |
133 | + let parent = Path::new(path).parent().unwrap_or(Path::new(".")); | |
134 | + std::fs::create_dir_all(parent)?; | |
135 | + let file_name = Path::new(path) | |
136 | + .file_name() | |
137 | + .unwrap_or_else(|| std::ffi::OsStr::new("justapi.log")); | |
138 | + | |
139 | + let file_appender = match config.file_rotation { | |
140 | + FileRotation::Daily => tracing_appender::rolling::daily(parent, file_name), | |
141 | + FileRotation::Hourly => tracing_appender::rolling::hourly(parent, file_name), | |
142 | + FileRotation::Never => tracing_appender::rolling::never(parent, file_name), | |
143 | + }; | |
144 | + | |
145 | + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); | |
146 | + set_guard(guard); | |
147 | + | |
148 | + let file_layer = tracing_subscriber::fmt::layer() | |
149 | + .with_writer(non_blocking) | |
150 | + .with_ansi(false) | |
151 | + .boxed(); | |
152 | + | |
153 | + layers.push(file_layer); | |
154 | + } else { | |
155 | + layers.push(fmt_layer); | |
156 | + } | |
157 | + | |
158 | + tracing_subscriber::registry() | |
159 | + .with(env_filter) | |
160 | + .with(layers) | |
161 | + .init(); | |
162 | + | |
163 | + Ok(()) | |
164 | +} | |
165 | + | |
166 | +#[cfg(feature = "opentelemetry")] | |
167 | +use opentelemetry::trace::TracerProvider as _; | |
168 | +#[cfg(feature = "opentelemetry")] | |
169 | +use opentelemetry_otlp::WithExportConfig; | |
170 | + | |
171 | +#[cfg(feature = "opentelemetry")] | |
172 | +fn set_tracer_guard(provider: opentelemetry_sdk::trace::TracerProvider) { | |
173 | + let lock = TRACER_GUARD.get_or_init(|| Mutex::new(None)); | |
174 | + *lock.lock().unwrap() = Some(provider); | |
175 | +} | |
176 | + | |
177 | +/// Initialize tracing with text format and env-filter (backward compatible). | |
178 | +pub fn init_tracing() -> Result<()> { | |
179 | + init_logging(&LoggingConfig::default()) | |
180 | +} | |
181 | + | |
182 | +/// Initialize JSON-formatted tracing with env-filter. | |
183 | +pub fn init_json_logging() -> Result<()> { | |
184 | + init_logging(&LoggingConfig { | |
185 | + format: LogFormat::Json, | |
186 | + ..Default::default() | |
187 | + }) | |
188 | +} | |
189 | + | |
190 | +/// Initialize JSON logging to a rolling file. | |
191 | +pub fn init_file_logging(path: &str) -> Result<()> { | |
192 | + init_logging(&LoggingConfig { | |
193 | + format: LogFormat::Json, | |
194 | + file_path: Some(path.to_string()), | |
195 | + ..Default::default() | |
196 | + }) | |
197 | +} | |
198 | + | |
199 | +/// Initialize tracing with an OTLP endpoint. | |
200 | +pub fn init_otlp_tracing(endpoint: &str, service_name: &str) -> Result<()> { | |
201 | + init_logging(&LoggingConfig { | |
202 | + otel_exporter: Some(OtelExporter::Oltp), | |
203 | + otlp_endpoint: Some(endpoint.to_string()), | |
204 | + service_name: service_name.to_string(), | |
205 | + ..Default::default() | |
206 | + }) | |
207 | +} | |
208 | + | |
209 | +/// Shutdown the tracing subscriber, flushing any pending spans. | |
210 | +pub fn shutdown_tracing() { | |
211 | + #[cfg(feature = "opentelemetry")] | |
212 | + { | |
213 | + if let Some(guard_lock) = TRACER_GUARD.get() { | |
214 | + if let Ok(mut guard) = guard_lock.lock() { | |
215 | + if let Some(provider) = guard.take() { | |
216 | + if let Err(e) = provider.shutdown() { | |
217 | + tracing::error!("Error shutting down tracer provider: {}", e); | |
218 | + } | |
219 | + } | |
220 | + } | |
221 | + } | |
222 | + opentelemetry::global::shutdown_tracer_provider(); | |
223 | + } | |
224 | + tracing::info!("Tracing shut down"); | |
225 | +} | |
226 | + | |
227 | +#[cfg(test)] | |
228 | +mod tests { | |
229 | + use super::*; | |
230 | + | |
231 | + #[test] | |
232 | + fn test_shutdown_does_not_panic() { | |
233 | + shutdown_tracing(); | |
234 | + } | |
235 | + | |
236 | + #[test] | |
237 | + fn test_config_defaults() { | |
238 | + let cfg = LoggingConfig::default(); | |
239 | + assert_eq!(cfg.format, LogFormat::Text); | |
240 | + assert_eq!(cfg.level, "info"); | |
241 | + assert!(cfg.file_path.is_none()); | |
242 | + } | |
243 | +} | |
0 | 244 | |