swadhinbiswas

[email protected]
2026-07-07 11:00:00 +0600 committed NaN years ago
b030aa7
commit b030aa7
11 files changed
+4135 -0
▼crates/justapi-core/src/alerting.rs
+221-0
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
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
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
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
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
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
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
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
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
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
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