Skip to content

Commit 4fd554c

Browse files
authored
Merge pull request #63 from chemicalcommando/chem
Chem
2 parents 752a6d6 + 8430f17 commit 4fd554c

10 files changed

Lines changed: 688 additions & 32 deletions

File tree

packages/core/src/api/fees.rs

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ use std::sync::Arc;
22

33
use async_trait::async_trait;
44
use axum::{
5+
body::Body,
56
extract::{Query, State},
6-
http::StatusCode,
7+
http::{HeaderMap, StatusCode, header},
8+
response::Response,
79
Json,
810
};
911
use chrono::{DateTime, Duration, Utc};
@@ -16,6 +18,7 @@ use crate::error::AppError;
1618
use crate::insights::{FeeDataPoint, FeeInsightsEngine, TrendIndicator, TrendStrength};
1719
use crate::services::horizon::HorizonClient;
1820
use crate::store::FeeHistoryStore;
21+
use super::headers::{cache_control, compute_etag, if_none_match_matches, last_modified};
1922

2023
/// Shared state type for the fees route.
2124
pub type FeesState = Arc<FeesApiState>;
@@ -73,32 +76,99 @@ pub struct CurrentFeeResponse {
7376
pub percentiles: PercentileFees,
7477
}
7578

79+
const FEES_CURRENT_MAX_AGE: u32 = 5;
80+
const FEES_CURRENT_SWR: u32 = 10;
81+
const FEES_HISTORY_MAX_AGE: u32 = 30;
82+
const FEES_HISTORY_SWR: u32 = 60;
83+
84+
async fn resolve_last_modified(state: &FeesState) -> axum::http::HeaderValue {
85+
let timestamp = match state.insights_engine.as_ref() {
86+
Some(engine) => engine
87+
.read()
88+
.await
89+
.get_last_update()
90+
.unwrap_or_else(Utc::now),
91+
None => Utc::now(),
92+
};
93+
last_modified(timestamp)
94+
}
95+
96+
fn not_modified_response(
97+
max_age: u32,
98+
swr: u32,
99+
etag: &str,
100+
last_modified_value: axum::http::HeaderValue,
101+
) -> Response {
102+
Response::builder()
103+
.status(StatusCode::NOT_MODIFIED)
104+
.header(header::CACHE_CONTROL, cache_control(max_age, swr))
105+
.header(header::ETAG, etag)
106+
.header(header::LAST_MODIFIED, last_modified_value)
107+
.body(Body::empty())
108+
.expect("304 response should be valid")
109+
}
110+
111+
fn json_cache_response(
112+
max_age: u32,
113+
swr: u32,
114+
etag: &str,
115+
last_modified_value: axum::http::HeaderValue,
116+
body: Vec<u8>,
117+
) -> Response {
118+
Response::builder()
119+
.status(StatusCode::OK)
120+
.header(header::CONTENT_TYPE, "application/json")
121+
.header(header::CACHE_CONTROL, cache_control(max_age, swr))
122+
.header(header::ETAG, etag)
123+
.header(header::LAST_MODIFIED, last_modified_value)
124+
.body(Body::from(body))
125+
.expect("cached response should be valid")
126+
}
127+
76128
pub async fn current_fees(
77129
State(state): State<FeesState>,
78-
) -> Result<Json<CurrentFeeResponse>, AppError> {
79-
{
130+
request_headers: HeaderMap,
131+
) -> Result<Response, AppError> {
132+
let cached = {
80133
let cache = state.fee_cache.lock().await;
81134
if cache.is_fresh() {
82-
if let Some(cached) = cache.get() {
83-
return Ok(Json(cached));
84-
}
135+
cache.get()
136+
} else {
137+
None
85138
}
86-
}
87-
88-
let provider = state
89-
.fee_stats_provider
90-
.as_ref()
91-
.ok_or_else(|| {
139+
};
140+
let payload = if let Some(cached) = cached {
141+
cached
142+
} else {
143+
let provider = state.fee_stats_provider.as_ref().ok_or_else(|| {
92144
AppError::Config("Fee stats provider missing from fees state".to_string())
93145
})?;
94-
let fresh = provider.fetch_current_fees().await?;
95-
96-
{
146+
let fresh = provider.fetch_current_fees().await?;
97147
let mut cache = state.fee_cache.lock().await;
98148
cache.set(fresh.clone());
149+
fresh
150+
};
151+
152+
let body = serde_json::to_vec(&payload).map_err(|err| AppError::Parse(err.to_string()))?;
153+
let etag = compute_etag(&body);
154+
let last_modified_value = resolve_last_modified(&state).await;
155+
156+
if if_none_match_matches(&request_headers, &etag) {
157+
return Ok(not_modified_response(
158+
FEES_CURRENT_MAX_AGE,
159+
FEES_CURRENT_SWR,
160+
&etag,
161+
last_modified_value,
162+
));
99163
}
100164

101-
Ok(Json(fresh))
165+
Ok(json_cache_response(
166+
FEES_CURRENT_MAX_AGE,
167+
FEES_CURRENT_SWR,
168+
&etag,
169+
last_modified_value,
170+
body,
171+
))
102172
}
103173

104174
#[derive(Debug, Deserialize)]
@@ -128,7 +198,8 @@ pub struct FeeHistoryResponse {
128198
pub async fn fee_history(
129199
State(state): State<FeesState>,
130200
Query(params): Query<FeeHistoryQuery>,
131-
) -> Result<Json<FeeHistoryResponse>, (StatusCode, Json<Value>)> {
201+
request_headers: HeaderMap,
202+
) -> Result<Response, (StatusCode, Json<Value>)> {
132203
let window = params.window.unwrap_or_else(|| "1h".to_string());
133204
let duration = parse_window(&window).ok_or_else(|| {
134205
(
@@ -145,14 +216,39 @@ pub async fn fee_history(
145216
};
146217
let summary = compute_summary(&fees);
147218

148-
Ok(Json(FeeHistoryResponse {
219+
let payload = FeeHistoryResponse {
149220
window,
150221
from,
151222
to,
152223
data_points: fees.len(),
153224
fees,
154225
summary,
155-
}))
226+
};
227+
let body = serde_json::to_vec(&payload).map_err(|err| {
228+
(
229+
StatusCode::INTERNAL_SERVER_ERROR,
230+
Json(json!({ "error": format!("Failed to serialize fee history: {}", err) })),
231+
)
232+
})?;
233+
let etag = compute_etag(&body);
234+
let last_modified_value = resolve_last_modified(&state).await;
235+
236+
if if_none_match_matches(&request_headers, &etag) {
237+
return Ok(not_modified_response(
238+
FEES_HISTORY_MAX_AGE,
239+
FEES_HISTORY_SWR,
240+
&etag,
241+
last_modified_value,
242+
));
243+
}
244+
245+
Ok(json_cache_response(
246+
FEES_HISTORY_MAX_AGE,
247+
FEES_HISTORY_SWR,
248+
&etag,
249+
last_modified_value,
250+
body,
251+
))
156252
}
157253

158254
fn parse_window(value: &str) -> Option<Duration> {
@@ -525,6 +621,50 @@ mod tests {
525621
assert_eq!(mock.calls(), 2, "expired cache should trigger refetch");
526622
}
527623

624+
#[tokio::test]
625+
async fn current_fees_returns_304_when_if_none_match_matches() {
626+
let mock = MockFeeStatsProvider::new(vec![make_current_fee_response("100")]);
627+
let state = make_fee_state_with_provider(Arc::new(mock), StdDuration::from_secs(60));
628+
629+
let app = Router::new()
630+
.route("/fees/current", get(current_fees))
631+
.with_state(state);
632+
633+
let first = app
634+
.clone()
635+
.oneshot(
636+
Request::builder()
637+
.uri("/fees/current")
638+
.body(Body::empty())
639+
.unwrap(),
640+
)
641+
.await
642+
.unwrap();
643+
assert_eq!(first.status(), StatusCode::OK);
644+
let etag = first
645+
.headers()
646+
.get("etag")
647+
.expect("missing etag header")
648+
.to_str()
649+
.unwrap()
650+
.to_string();
651+
652+
let second = app
653+
.oneshot(
654+
Request::builder()
655+
.uri("/fees/current")
656+
.header("if-none-match", etag)
657+
.body(Body::empty())
658+
.unwrap(),
659+
)
660+
.await
661+
.unwrap();
662+
663+
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
664+
let body = to_bytes(second.into_body(), usize::MAX).await.unwrap();
665+
assert!(body.is_empty(), "304 response should not include body");
666+
}
667+
528668
#[tokio::test]
529669
async fn fee_history_returns_data_points_and_summary_for_supported_windows() {
530670
for window in ["1h", "6h", "24h"] {

packages/core/src/api/headers.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use std::collections::hash_map::DefaultHasher;
2+
use std::hash::{Hash, Hasher};
3+
4+
use axum::http::{HeaderMap, HeaderValue, header};
5+
use chrono::{DateTime, Utc};
6+
7+
/// Compute a weakly-stable quoted ETag from response bytes.
8+
pub fn compute_etag(body: &[u8]) -> String {
9+
let mut hasher = DefaultHasher::new();
10+
body.hash(&mut hasher);
11+
format!("\"{:x}\"", hasher.finish())
12+
}
13+
14+
/// Build a Cache-Control value using max-age and stale-while-revalidate.
15+
pub fn cache_control(max_age: u32, swr: u32) -> HeaderValue {
16+
HeaderValue::from_str(&format!(
17+
"max-age={}, stale-while-revalidate={}",
18+
max_age, swr
19+
))
20+
.expect("cache-control header value should be valid")
21+
}
22+
23+
/// Build an RFC 7231 HTTP-date for Last-Modified.
24+
pub fn last_modified(timestamp: DateTime<Utc>) -> HeaderValue {
25+
HeaderValue::from_str(&timestamp.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
26+
.expect("last-modified header value should be valid")
27+
}
28+
29+
/// Returns true when `If-None-Match` contains `*` or the exact current ETag.
30+
pub fn if_none_match_matches(headers: &HeaderMap, current_etag: &str) -> bool {
31+
headers
32+
.get(header::IF_NONE_MATCH)
33+
.and_then(|value| value.to_str().ok())
34+
.map(|raw| {
35+
raw.split(',')
36+
.map(|tag| tag.trim())
37+
.any(|tag| tag == "*" || tag == current_etag)
38+
})
39+
.unwrap_or(false)
40+
}
41+
42+
#[cfg(test)]
43+
mod tests {
44+
use super::*;
45+
46+
#[test]
47+
fn etag_is_quoted() {
48+
let etag = compute_etag(br#"{"ok":true}"#);
49+
assert!(etag.starts_with('"'));
50+
assert!(etag.ends_with('"'));
51+
}
52+
53+
#[test]
54+
fn if_none_match_matches_exact_tag() {
55+
let mut headers = HeaderMap::new();
56+
headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"abc\""));
57+
58+
assert!(if_none_match_matches(&headers, "\"abc\""));
59+
assert!(!if_none_match_matches(&headers, "\"def\""));
60+
}
61+
}

packages/core/src/api/health.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
use axum::response::IntoResponse;
1+
use axum::{
2+
body::Body,
3+
http::{HeaderValue, StatusCode, header},
4+
response::{IntoResponse, Response},
5+
};
26

37
pub async fn health() -> impl IntoResponse {
4-
"ok"
5-
}
8+
Response::builder()
9+
.status(StatusCode::OK)
10+
.header(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))
11+
.body(Body::from("ok"))
12+
.expect("health response should be valid")
13+
}

packages/core/src/api/insights.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
//! Insights API endpoints
22
33
use axum::{
4+
body::Body,
45
extract::State,
5-
http::StatusCode,
6-
response::Json,
6+
http::{HeaderMap, StatusCode, header},
7+
response::{Json, Response},
78
routing::get,
89
Router,
910
};
1011
use serde_json::Value;
1112
use std::sync::Arc;
1213
use tokio::sync::RwLock;
1314

14-
use crate::insights::{FeeInsightsEngine, CurrentInsights, RollingAverages, FeeExtremes, CongestionTrends};
15+
use crate::insights::{CongestionTrends, FeeExtremes, FeeInsightsEngine, RollingAverages};
16+
use super::headers::{cache_control, compute_etag, if_none_match_matches, last_modified};
1517

1618
/// Shared state for the insights API
1719
pub type InsightsState = Arc<RwLock<FeeInsightsEngine>>;
@@ -30,10 +32,37 @@ pub fn create_insights_router(insights_engine: InsightsState) -> Router {
3032
/// Get current insights
3133
async fn get_current_insights(
3234
State(engine): State<InsightsState>,
33-
) -> Result<Json<CurrentInsights>, (StatusCode, Json<Value>)> {
35+
request_headers: HeaderMap,
36+
) -> Result<Response, (StatusCode, Json<Value>)> {
3437
let engine = engine.read().await;
3538
let insights = engine.get_current_insights();
36-
Ok(Json(insights))
39+
let body = serde_json::to_vec(&insights).map_err(|err| {
40+
(
41+
StatusCode::INTERNAL_SERVER_ERROR,
42+
Json(serde_json::json!({ "error": format!("Failed to serialize insights: {}", err) })),
43+
)
44+
})?;
45+
let etag = compute_etag(&body);
46+
let last_modified_value = last_modified(insights.last_updated);
47+
48+
if if_none_match_matches(&request_headers, &etag) {
49+
return Ok(Response::builder()
50+
.status(StatusCode::NOT_MODIFIED)
51+
.header(header::CACHE_CONTROL, cache_control(10, 20))
52+
.header(header::ETAG, etag.as_str())
53+
.header(header::LAST_MODIFIED, last_modified_value)
54+
.body(Body::empty())
55+
.expect("304 insights response should be valid"));
56+
}
57+
58+
Ok(Response::builder()
59+
.status(StatusCode::OK)
60+
.header(header::CONTENT_TYPE, "application/json")
61+
.header(header::CACHE_CONTROL, cache_control(10, 20))
62+
.header(header::ETAG, etag.as_str())
63+
.header(header::LAST_MODIFIED, last_modified_value)
64+
.body(Body::from(body))
65+
.expect("insights response should be valid"))
3766
}
3867

3968
/// Get rolling averages
@@ -80,4 +109,4 @@ async fn get_insights_health(
80109
});
81110

82111
Ok(Json(health_info))
83-
}
112+
}

packages/core/src/api/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ pub mod health;
22
pub mod fees;
33
pub mod insights;
44
pub mod alerts;
5-
5+
pub mod headers;
66

0 commit comments

Comments
 (0)