Skip to content

Commit 675792c

Browse files
committed
Merge PR #1021: fix(api): resolve compilation errors and complete backend hardening refactor
2 parents eeefc94 + 720115b commit 675792c

16 files changed

Lines changed: 192 additions & 101 deletions

services/api/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async-trait = "0.1"
3737
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
3838
serde = { version = "1", features = ["derive"] }
3939
serde_json = "1"
40-
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
40+
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "derive"] }
4141
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync"] }
4242
tokio-util = { version = "0.7", features = ["rt"] }
4343
tower = { version = "0.5", features = ["util"] }
@@ -60,6 +60,7 @@ base64 = "0.22"
6060
subtle = "2.5"
6161
rand = { version = "0.8", features = ["getrandom"] }
6262
ipnet = "2"
63+
fastrand = "2.4.1"
6364
utoipa = { version = "4", features = ["yaml"] }
6465

6566
[features]
@@ -69,8 +70,8 @@ redis-integration = []
6970

7071
[dev-dependencies]
7172
criterion = { version = "0.5", features = ["html_reports"] }
72-
testcontainers = "0.23"
73-
testcontainers-modules = { version = "0.11", features = ["redis"] }
73+
testcontainers = { version = "0.23" }
74+
testcontainers-modules = { version = "0.11", features = ["redis", "postgres"] }
7475
axum = { version = "0.7", features = ["macros"] }
7576
serde_json = "1"
7677

services/api/src/audit.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::net::IpAddr;
22

3-
pub mod client_ip;
4-
pub use client_ip::{extract_client_ip, trusted_cidrs_from_env};
3+
pub use crate::client_ip::{extract_client_ip, trusted_cidrs_from_env};
54

65
use chrono::{DateTime, Utc};
76
use serde::{Deserialize, Serialize};

services/api/src/audit_middleware.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
pub mod body_redact;
2-
pub use body_redact::{body_logging_enabled, redact_sensitive, truncate_body};
1+
pub use crate::body_redact::{body_logging_enabled, redact_sensitive, truncate_body};
32

43
use std::sync::Arc;
54

services/api/src/blockchain.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ impl BlockchainClient {
793793
total_events = all_events.len(),
794794
"fetch_events_since paginated"
795795
);
796-
self.metrics.observe_invalidation("events_pagination_pages", pages);
796+
self.metrics.observe_invalidation("events_pagination_pages", pages as usize);
797797
}
798798

799799
Ok(all_events)

services/api/src/cache/mod.rs

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,8 @@ use std::{
77
time::{Duration, Instant},
88
};
99

10-
use redis::redis_module::RedisResult;
11-
12-
1310
use anyhow::Context;
14-
use deadpool_redis::{Config as PoolConfig, Pool, Runtime};
11+
use deadpool_redis::{Config as PoolConfig, Pool};
1512
use redis::AsyncCommands;
1613
use serde::{de::DeserializeOwned, Serialize};
1714

@@ -276,8 +273,9 @@ impl RedisCache {
276273
}
277274

278275
// Deterministically hash the tag so the metadata key is stable.
276+
use std::hash::{Hash, Hasher};
279277
let mut hasher = std::collections::hash_map::DefaultHasher::new();
280-
std::hash::Hash::hash(&tag, &mut hasher);
278+
tag.cache_keys().join("|").hash(&mut hasher);
281279
let tag_hash = format!("{:x}", hasher.finish());
282280

283281
let zset_key = self.tag_cfg.tag_key(&tag_hash);
@@ -324,18 +322,19 @@ impl RedisCache {
324322
"#,
325323
);
326324

327-
let mut over_evicted: i64 = 0;
325+
let script = std::sync::Arc::new(script);
328326
self.exec(|mut conn| {
329327
let zset_key = zset_key.clone();
330328
let seq_key = seq_key.clone();
331329
let keys = tag_keys.clone();
330+
let script = script.clone();
332331
async move {
333332
let mut argv: Vec<String> = Vec::with_capacity(2 + keys.len());
334333
argv.push(tag_ttl_secs.to_string());
335334
argv.push(cap.to_string());
336335
argv.extend(keys);
337336

338-
over_evicted = script
337+
let _: i64 = script
339338
.key(&zset_key)
340339
.key(&seq_key)
341340
.arg(tag_ttl_secs)
@@ -347,7 +346,6 @@ impl RedisCache {
347346
})
348347
.await?;
349348

350-
// Note: we don't need the evicted count for correctness.
351349
Ok(())
352350
}
353351

@@ -450,11 +448,14 @@ impl RedisCache {
450448
T: DeserializeOwned,
451449
{
452450
let key = key.to_owned();
453-
self.exec(|mut conn| async move {
454-
let val: Option<String> = conn.get(&key).await?;
455-
match val {
456-
Some(raw) => Ok(Some(serde_json::from_str(&raw)?)),
457-
None => Ok(None),
451+
self.exec(|mut conn| {
452+
let key = key.clone();
453+
async move {
454+
let val: Option<String> = conn.get(&key).await?;
455+
match val {
456+
Some(raw) => Ok(Some(serde_json::from_str(&raw)?)),
457+
None => Ok(None),
458+
}
458459
}
459460
})
460461
.await
@@ -480,9 +481,12 @@ impl RedisCache {
480481

481482
pub async fn del(&self, key: &str) -> anyhow::Result<()> {
482483
let key = key.to_owned();
483-
self.exec(|mut conn| async move {
484-
let _: usize = conn.del(&key).await?;
485-
Ok(())
484+
self.exec(|mut conn| {
485+
let key = key.clone();
486+
async move {
487+
let _: usize = conn.del(&key).await?;
488+
Ok(())
489+
}
486490
})
487491
.await
488492
}
@@ -511,23 +515,25 @@ impl RedisCache {
511515
let pattern = pattern.to_owned();
512516

513517
loop {
514-
let pattern_clone = pattern.clone();
515518
let (next_cursor, batch_deleted) = self
516-
.exec(|mut conn| async move {
517-
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
518-
.arg(cursor)
519-
.arg("MATCH")
520-
.arg(&pattern_clone)
521-
.arg("COUNT")
522-
.arg(100u64)
523-
.query_async(&mut conn)
524-
.await?;
525-
let deleted = if keys.is_empty() {
526-
0
527-
} else {
528-
conn.del(keys).await?
529-
};
530-
Ok((next_cursor, deleted))
519+
.exec(|mut conn| {
520+
let pattern_clone = pattern.clone();
521+
async move {
522+
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
523+
.arg(cursor)
524+
.arg("MATCH")
525+
.arg(&pattern_clone)
526+
.arg("COUNT")
527+
.arg(100u64)
528+
.query_async(&mut conn)
529+
.await?;
530+
let deleted = if keys.is_empty() {
531+
0
532+
} else {
533+
conn.del(keys).await?
534+
};
535+
Ok((next_cursor, deleted))
536+
}
531537
})
532538
.await?;
533539

services/api/src/compression.rs

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,25 @@
1-
use tower_http::compression::predicate::{NotForContentType, Predicate};
1+
use axum::http::{header, Extensions, HeaderMap, StatusCode, Version};
22
use tower_http::compression::CompressionLayer;
33

4-
fn should_compress_text_based(content_type: Option<&str>) -> bool {
5-
let Some(ct) = content_type else {
6-
// If we can't determine content type, avoid wasting CPU.
7-
return false;
8-
};
4+
type CompressFn = fn(StatusCode, Version, &HeaderMap, &Extensions) -> bool;
95

10-
// Remove common parameters like `charset=utf-8`.
6+
fn should_compress(
7+
_: StatusCode,
8+
_: Version,
9+
headers: &HeaderMap,
10+
_: &Extensions,
11+
) -> bool {
12+
let ct = headers
13+
.get(header::CONTENT_TYPE)
14+
.and_then(|h| h.to_str().ok())
15+
.unwrap_or("");
1116
let ct = ct.split(';').next().unwrap_or(ct).trim();
12-
13-
// Only compress text-ish payloads.
14-
// Note: application/json is explicitly included.
1517
ct == "application/json" || ct.starts_with("text/")
1618
}
1719

18-
pub fn compression_layer() -> CompressionLayer {
19-
// Exclude already-compressed/binary formats to avoid CPU waste.
20-
// (This primarily protects against cases where `content_type` might be
21-
// missing/incorrect while still keeping the middleware safe.)
22-
let not_for_binary = NotForContentType::new(vec![
23-
"application/zip",
24-
"application/gzip",
25-
"application/x-gzip",
26-
"application/x-zip-compressed",
27-
"application/pdf",
28-
"image/jpeg",
29-
"image/png",
30-
"image/webp",
31-
"image/gif",
32-
"image/svg+xml",
33-
"audio/mpeg",
34-
"audio/mp4",
35-
"video/mp4",
36-
"application/octet-stream",
37-
"application/x-bzip2",
38-
"application/x-7z-compressed",
39-
]);
40-
20+
pub fn compression_layer() -> CompressionLayer<CompressFn> {
4121
CompressionLayer::new()
4222
.gzip(true)
4323
.br(true)
44-
// Only apply compression to text-based responses.
45-
.compress_when(Predicate::from_fn(should_compress_text_based))
46-
.filter(not_for_binary)
24+
.compress_when(should_compress as CompressFn)
4725
}
48-

services/api/src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ impl CorsConfig {
6767
.filter(|s| !s.is_empty())
6868
.collect()
6969
})
70-
.unwrap_or_else(|| {
70+
.unwrap_or_else(|_| {
7171
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
7272
.iter()
7373
.map(|s| s.to_string())
@@ -81,7 +81,7 @@ impl CorsConfig {
8181
.filter(|s| !s.is_empty())
8282
.collect()
8383
})
84-
.unwrap_or_else(|| {
84+
.unwrap_or_else(|_| {
8585
["content-type", "authorization"]
8686
.iter()
8787
.map(|s| s.to_string())

services/api/src/db.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ impl Database {
136136
/// Snapshot pool size/idle into Prometheus gauges.
137137
/// Call this just before rendering `/metrics` so the values are current.
138138
pub fn record_pool_metrics(&self) {
139-
self.metrics.record_pool_metrics(self.pool.size(), self.pool.num_idle());
139+
self.metrics.observe_pool_connections("primary", self.pool.size() as i64, self.pool.num_idle() as i64);
140140
}
141141

142142
pub async fn new(
@@ -934,7 +934,6 @@ impl Database {
934934
format!("{:x}", hasher.finalize())
935935
}
936936
}
937-
}
938937

939938
#[cfg(test)]
940939
mod tests {

services/api/src/email/service.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use anyhow::{Context, Result};
2-
use redis::AsyncCommands as _;
32
use serde_json::Value;
43
use sha2::{Digest, Sha256};
54
use std::time::Duration;
@@ -164,7 +163,7 @@ impl EmailService {
164163
// --- idempotency check ---
165164
if let (Some(cache), Some(key)) = (&self.cache, idem_key) {
166165
let redis_key = format!("email:idem:{key}");
167-
let mut conn = cache.manager.clone();
166+
let mut conn = cache.get_connection().await.context("idempotency Redis connection failed")?;
168167

169168
// Try SET NX — only succeeds for the first send.
170169
let acquired: Option<String> = redis::cmd("SET")

services/api/src/handlers.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use crate::content_type::require_json_content_type;
21
use std::{
32
sync::Arc,
43
time::{Duration, Instant},
@@ -752,13 +751,13 @@ pub async fn content(
752751
let cursor = query.cursor();
753752
let endpoint = "content";
754753

755-
let cache_key = keys::api_content(limit);
754+
let cache_key = keys::api_content(limit.into());
756755
let ttl = Duration::from_secs(60 * 60);
757756

758757
let (payload, hit) = state
759758
.cache
760759
.get_or_set_json(&cache_key, ttl, || async {
761-
let data = state.db.content_cached(limit).await?;
760+
let data = state.db.content_cached(limit.into()).await?;
762761
Ok(data)
763762
})
764763
.await
@@ -974,11 +973,11 @@ pub async fn blockchain_user_bets(
974973

975974
let page_data = state
976975
.blockchain
977-
.user_bets_page(&user, page, page_size)
976+
.user_bets_page(&user, page, page_size.into())
978977
.await
979978
.map_err(into_api_error)?;
980979

981-
let has_more = (page + 1) * page_size < page_data.total;
980+
let has_more = (page + 1) * (page_size as i64) < page_data.total;
982981
let next_cursor = if has_more {
983982
Some((page + 1).to_string())
984983
} else {
@@ -1080,13 +1079,13 @@ pub async fn warm_critical_caches(state: Arc<AppState>) -> anyhow::Result<()> {
10801079

10811080
let (mut succeeded, mut failed) = (0usize, 0usize);
10821081

1083-
warm!("db.statistics", state.db.statistics_cached().map(|r| r.map(|_| ())), succeeded, failed);
1084-
warm!("db.featured_markets", state.db.featured_markets_cached(state.config.featured_limit).map(|r| r.map(|_| ())), succeeded, failed);
1085-
warm!("blockchain.health", state.blockchain.health_check_cached().map(|r| r.map(|_| ())), succeeded, failed);
1086-
warm!("blockchain.platform_stats", state.blockchain.platform_statistics_cached().map(|r| r.map(|_| ())), succeeded, failed);
1087-
warm!("api.statistics", statistics(State(state.clone())).map(|r| r.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}"))), succeeded, failed);
1088-
warm!("api.featured_markets", featured_markets(State(state.clone()), Query(PaginationQuery::default())).map(|r| r.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}"))), succeeded, failed);
1089-
warm!("api.content", content(State(state.clone()), Query(PaginationQuery::default())).map(|r| r.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}"))), succeeded, failed);
1082+
warm!("db.statistics", state.db.statistics_cached(), succeeded, failed);
1083+
warm!("db.featured_markets", state.db.featured_markets_cached(state.config.featured_limit), succeeded, failed);
1084+
warm!("blockchain.health", state.blockchain.health_check_cached(), succeeded, failed);
1085+
warm!("blockchain.platform_stats", state.blockchain.platform_statistics_cached(), succeeded, failed);
1086+
warm!("api.statistics", async { statistics(State(state.clone())).await.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}")) }, succeeded, failed);
1087+
warm!("api.featured_markets", async { featured_markets(State(state.clone()), Query(PaginationQuery::default())).await.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}")) }, succeeded, failed);
1088+
warm!("api.content", async { content(State(state.clone()), Query(PaginationQuery::default())).await.map(|_| ()).map_err(|e| anyhow::anyhow!("{e:?}")) }, succeeded, failed);
10901089

10911090
tracing::info!(succeeded, failed, total = succeeded + failed, "cache warming complete");
10921091
Ok(())

0 commit comments

Comments
 (0)