Skip to content

Commit de860ba

Browse files
committed
feat(02-04): add block and last_block route handlers and register routes
- routes/block.rs: GET /v0/block/:height with singleflight dedup, X-Upstream-Source header, verbose 502 on all-upstreams-fail - routes/last_block.rs: GET /v0/last_block/final proxies to fastnear (no cache), updates tip_height AtomicU64 - routes/mod.rs: register /v0/block/:height and /v0/last_block/final alongside existing /healthz and /readyz
1 parent e41ad5e commit de860ba

File tree

3 files changed

+149
-1
lines changed

3 files changed

+149
-1
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use axum::{
2+
extract::{Path, State},
3+
http::{HeaderValue, StatusCode, header},
4+
response::{IntoResponse, Response},
5+
};
6+
use serde_json::json;
7+
8+
use crate::state::AppState;
9+
use crate::upstream::fetch_block_deduped;
10+
11+
/// GET /v0/block/:height
12+
///
13+
/// Fetches a block by height using the singleflight dedup + fallback chain.
14+
/// On success, returns block JSON with `X-Upstream-Source` and `Content-Type` headers.
15+
/// On failure (all upstreams exhausted), returns 502 with verbose per-source error details.
16+
pub async fn block_handler(
17+
State(state): State<AppState>,
18+
Path(height): Path<u64>,
19+
) -> Response {
20+
// height=0 is technically valid in NEAR but often indicates a misconfiguration.
21+
// Reject it early with a descriptive 400 to avoid confusing upstream errors.
22+
if height == 0 {
23+
let body = json!({ "error": "block height must be greater than 0" });
24+
return (
25+
StatusCode::BAD_REQUEST,
26+
[(header::CONTENT_TYPE, "application/json")],
27+
axum::Json(body),
28+
)
29+
.into_response();
30+
}
31+
32+
match fetch_block_deduped(&state, height).await {
33+
Ok((bytes, source)) => {
34+
// Build response with correct headers.
35+
// `bytes::Bytes` can be used directly as an axum response body.
36+
let source_header =
37+
HeaderValue::from_static(source);
38+
39+
(
40+
StatusCode::OK,
41+
[
42+
(header::CONTENT_TYPE, HeaderValue::from_static("application/json")),
43+
(
44+
header::HeaderName::from_static("x-upstream-source"),
45+
source_header,
46+
),
47+
],
48+
bytes,
49+
)
50+
.into_response()
51+
}
52+
Err(errors) => {
53+
// All upstreams failed — return 502 with verbose per-source error details
54+
// (locked decision in CONTEXT.md: verbose upstream error responses).
55+
let body = json!({
56+
"error": "all upstream sources failed",
57+
"height": height,
58+
"sources": errors,
59+
});
60+
tracing::error!(height, error_count = errors.len(), "block request failed: all upstreams exhausted");
61+
(
62+
StatusCode::BAD_GATEWAY,
63+
[(header::CONTENT_TYPE, "application/json")],
64+
axum::Json(body),
65+
)
66+
.into_response()
67+
}
68+
}
69+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use std::sync::atomic::Ordering;
2+
3+
use axum::{
4+
extract::State,
5+
http::{HeaderValue, StatusCode, header},
6+
response::{IntoResponse, Response},
7+
};
8+
use serde_json::json;
9+
10+
use crate::state::AppState;
11+
12+
/// GET /v0/last_block/final
13+
///
14+
/// Proxies the request to fastnear in real time — this response is NOT cached
15+
/// (per ENDP-02) because the chain tip changes every ~1 second.
16+
///
17+
/// On success, updates `tip_height` AtomicU64 so the background eviction task
18+
/// knows which blocks are "recent" and eligible for TTL eviction.
19+
///
20+
/// Returns the raw fastnear JSON with `X-Upstream-Source: fastnear` header.
21+
pub async fn last_block_final_handler(State(state): State<AppState>) -> Response {
22+
match state.fastnear.fetch_last_block_final().await {
23+
Ok(bytes) => {
24+
// Attempt to extract the block height from the JSON response to update
25+
// tip_height. fastnear's last_block/final response format contains
26+
// `block_height` at the top level (for the compact format) or nested
27+
// under `block.header.height` (for the full format). Try both.
28+
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
29+
let tip = value
30+
.get("block_height")
31+
.and_then(|v| v.as_u64())
32+
.or_else(|| {
33+
value
34+
.get("block")
35+
.and_then(|b| b.get("header"))
36+
.and_then(|h| h.get("height"))
37+
.and_then(|v| v.as_u64())
38+
});
39+
40+
if let Some(height) = tip {
41+
state.tip_height.store(height, Ordering::Relaxed);
42+
tracing::debug!(height, "tip_height updated from last_block/final");
43+
}
44+
}
45+
46+
(
47+
StatusCode::OK,
48+
[
49+
(header::CONTENT_TYPE, HeaderValue::from_static("application/json")),
50+
(
51+
header::HeaderName::from_static("x-upstream-source"),
52+
HeaderValue::from_static("fastnear"),
53+
),
54+
],
55+
bytes,
56+
)
57+
.into_response()
58+
}
59+
Err(e) => {
60+
tracing::error!(error = %e, "last_block/final fetch from fastnear failed");
61+
let body = json!({ "error": e.to_string() });
62+
(
63+
StatusCode::BAD_GATEWAY,
64+
[(header::CONTENT_TYPE, "application/json")],
65+
axum::Json(body),
66+
)
67+
.into_response()
68+
}
69+
}
70+
}

apps/block-proxy/src/routes/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
pub mod block;
12
pub mod health;
3+
pub mod last_block;
24
pub mod readyz;
35

46
use axum::{Router, http::HeaderName, routing::get};
@@ -10,14 +12,21 @@ use tower_http::{
1012

1113
use crate::state::AppState;
1214

13-
use self::{health::health_handler, readyz::readyz_handler};
15+
use self::{
16+
block::block_handler,
17+
health::health_handler,
18+
last_block::last_block_final_handler,
19+
readyz::readyz_handler,
20+
};
1421

1522
pub fn build_router(state: AppState) -> Router {
1623
let x_request_id = HeaderName::from_static("x-request-id");
1724

1825
Router::new()
1926
.route("/healthz", get(health_handler))
2027
.route("/readyz", get(readyz_handler))
28+
.route("/v0/block/:height", get(block_handler))
29+
.route("/v0/last_block/final", get(last_block_final_handler))
2130
.layer(
2231
ServiceBuilder::new()
2332
.layer(SetRequestIdLayer::new(

0 commit comments

Comments
 (0)