mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-02 11:13:39 -07:00
global: snapshot
This commit is contained in:
@@ -6,7 +6,7 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use brk_types::{
|
||||
AddressParam, AddressStats, AddressTxidsParam, AddressValidation, Txid, Utxo,
|
||||
AddressParam, AddressStats, AddressTxidsParam, AddressValidation, Transaction, Txid, Utxo,
|
||||
ValidateAddressParam,
|
||||
};
|
||||
|
||||
@@ -53,13 +53,13 @@ impl AddressRoutes for ApiRouter<AppState> {
|
||||
Query(params): Query<AddressTxidsParam>,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, params.limit)).await
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txs(path.address, params.after_txid, 25)).await
|
||||
}, |op| op
|
||||
.id("get_address_txs")
|
||||
.addresses_tag()
|
||||
.summary("Address transaction IDs")
|
||||
.description("Get transaction IDs for an address, newest first. Use after_txid for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.ok_response::<Vec<Txid>>()
|
||||
.summary("Address transactions")
|
||||
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.ok_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -67,20 +67,21 @@ impl AddressRoutes for ApiRouter<AppState> {
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/address/{address}/utxo",
|
||||
"/api/address/{address}/txs/chain",
|
||||
get_with(async |
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<AddressParam>,
|
||||
Query(params): Query<AddressTxidsParam>,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_utxos(path.address)).await
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txs(path.address, params.after_txid, 25)).await
|
||||
}, |op| op
|
||||
.id("get_address_utxos")
|
||||
.id("get_address_confirmed_txs")
|
||||
.addresses_tag()
|
||||
.summary("Address UTXOs")
|
||||
.description("Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)*")
|
||||
.ok_response::<Vec<Utxo>>()
|
||||
.summary("Address confirmed transactions")
|
||||
.description("Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.ok_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -109,21 +110,20 @@ impl AddressRoutes for ApiRouter<AppState> {
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/address/{address}/txs/chain",
|
||||
"/api/address/{address}/utxo",
|
||||
get_with(async |
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<AddressParam>,
|
||||
Query(params): Query<AddressTxidsParam>,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, 25)).await
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_utxos(path.address)).await
|
||||
}, |op| op
|
||||
.id("get_address_confirmed_txs")
|
||||
.id("get_address_utxos")
|
||||
.addresses_tag()
|
||||
.summary("Address confirmed transactions")
|
||||
.description("Get confirmed transaction IDs for an address, 25 per page. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.ok_response::<Vec<Txid>>()
|
||||
.summary("Address UTXOs")
|
||||
.description("Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)*")
|
||||
.ok_response::<Vec<Utxo>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
|
||||
@@ -38,6 +38,53 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/blocks/{height}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(Some(path.height))).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_blocks_from_height")
|
||||
.blocks_tag()
|
||||
.summary("Blocks from height")
|
||||
.description(
|
||||
"Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)*",
|
||||
)
|
||||
.ok_response::<Vec<BlockInfo>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/block-height/{height}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_height(path.height)).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_block_by_height")
|
||||
.blocks_tag()
|
||||
.summary("Block by height")
|
||||
.description(
|
||||
"Retrieve block information by block height. Returns block metadata including hash, timestamp, difficulty, size, weight, and transaction count.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*",
|
||||
)
|
||||
.ok_response::<BlockInfo>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/block/{hash}",
|
||||
get_with(
|
||||
@@ -86,53 +133,6 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/block-height/{height}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_height(path.height)).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_block_by_height")
|
||||
.blocks_tag()
|
||||
.summary("Block by height")
|
||||
.description(
|
||||
"Retrieve block information by block height. Returns block metadata including hash, timestamp, difficulty, size, weight, and transaction count.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*",
|
||||
)
|
||||
.ok_response::<BlockInfo>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/blocks/{height}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(Some(path.height))).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_blocks_from_height")
|
||||
.blocks_tag()
|
||||
.summary("Blocks from height")
|
||||
.description(
|
||||
"Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)*",
|
||||
)
|
||||
.ok_response::<Vec<BlockInfo>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/block/{hash}/txids",
|
||||
get_with(
|
||||
@@ -206,28 +206,6 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/mining/blocks/timestamp/{timestamp}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<TimestampParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_timestamp(path.timestamp)).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_block_by_timestamp")
|
||||
.blocks_tag()
|
||||
.summary("Block by timestamp")
|
||||
.description("Find the block closest to a given UNIX timestamp.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)*")
|
||||
.ok_response::<BlockTimestamp>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/block/{hash}/raw",
|
||||
get_with(
|
||||
@@ -252,5 +230,27 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/mining/blocks/timestamp/{timestamp}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<TimestampParam>,
|
||||
State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_timestamp(path.timestamp)).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_block_by_timestamp")
|
||||
.blocks_tag()
|
||||
.summary("Block by timestamp")
|
||||
.description("Find the block closest to a given UNIX timestamp.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)*")
|
||||
.ok_response::<BlockTimestamp>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,22 +51,6 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/fees/recommended",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.recommended_fees()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_recommended_fees")
|
||||
.mempool_tag()
|
||||
.summary("Recommended fees")
|
||||
.description("Get recommended fee rates for different confirmation targets based on current mempool state.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)*")
|
||||
.ok_response::<RecommendedFees>()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/mempool/price",
|
||||
get_with(
|
||||
@@ -87,6 +71,22 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/fees/recommended",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.recommended_fees()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_recommended_fees")
|
||||
.mempool_tag()
|
||||
.summary("Recommended fees")
|
||||
.description("Get recommended fee rates for different confirmation targets based on current mempool state.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)*")
|
||||
.ok_response::<RecommendedFees>()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/fees/mempool-blocks",
|
||||
get_with(
|
||||
|
||||
@@ -171,6 +171,74 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
|
||||
.server_error(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metric/{metric}/{index}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
state: State<AppState>,
|
||||
Path(path): Path<MetricWithIndex>,
|
||||
Query(range): Query<DataRangeFormat>|
|
||||
-> Response {
|
||||
data::handler(
|
||||
uri,
|
||||
headers,
|
||||
addr,
|
||||
Query(MetricSelection::from((path.index, path.metric, range))),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.id("get_metric")
|
||||
.metrics_tag()
|
||||
.summary("Get metric data")
|
||||
.description(
|
||||
"Fetch data for a specific metric at the given index. \
|
||||
Use query parameters to filter by date range and format (json/csv)."
|
||||
)
|
||||
.ok_response::<MetricData>()
|
||||
.csv_response()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metric/{metric}/{index}/data",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
state: State<AppState>,
|
||||
Path(path): Path<MetricWithIndex>,
|
||||
Query(range): Query<DataRangeFormat>|
|
||||
-> Response {
|
||||
data::raw_handler(
|
||||
uri,
|
||||
headers,
|
||||
addr,
|
||||
Query(MetricSelection::from((path.index, path.metric, range))),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.id("get_metric_data")
|
||||
.metrics_tag()
|
||||
.summary("Get raw metric data")
|
||||
.description(
|
||||
"Returns just the data array without the MetricData wrapper. \
|
||||
Supports the same range and format parameters as the standard endpoint."
|
||||
)
|
||||
.ok_response::<Vec<serde_json::Value>>()
|
||||
.csv_response()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metric/{metric}/{index}/latest",
|
||||
get_with(
|
||||
@@ -239,74 +307,6 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metric/{metric}/{index}/data",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
state: State<AppState>,
|
||||
Path(path): Path<MetricWithIndex>,
|
||||
Query(range): Query<DataRangeFormat>|
|
||||
-> Response {
|
||||
data::raw_handler(
|
||||
uri,
|
||||
headers,
|
||||
addr,
|
||||
Query(MetricSelection::from((path.index, path.metric, range))),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.id("get_metric_data")
|
||||
.metrics_tag()
|
||||
.summary("Get raw metric data")
|
||||
.description(
|
||||
"Returns just the data array without the MetricData wrapper. \
|
||||
Supports the same range and format parameters as the standard endpoint."
|
||||
)
|
||||
.ok_response::<Vec<serde_json::Value>>()
|
||||
.csv_response()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metric/{metric}/{index}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
state: State<AppState>,
|
||||
Path(path): Path<MetricWithIndex>,
|
||||
Query(range): Query<DataRangeFormat>|
|
||||
-> Response {
|
||||
data::handler(
|
||||
uri,
|
||||
headers,
|
||||
addr,
|
||||
Query(MetricSelection::from((path.index, path.metric, range))),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.id("get_metric")
|
||||
.metrics_tag()
|
||||
.summary("Get metric data")
|
||||
.description(
|
||||
"Fetch data for a specific metric at the given index. \
|
||||
Use query parameters to filter by date range and format (json/csv)."
|
||||
)
|
||||
.ok_response::<MetricData>()
|
||||
.csv_response()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/metrics/bulk",
|
||||
get_with(
|
||||
@@ -326,77 +326,6 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
|
||||
.not_modified(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/vecs/{variant}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
Path(variant): Path<String>,
|
||||
Query(range): Query<DataRangeFormat>,
|
||||
state: State<AppState>|
|
||||
-> Response {
|
||||
let separator = "_to_";
|
||||
let variant = variant.replace("-", "_");
|
||||
let mut split = variant.split(separator);
|
||||
|
||||
let ser_index = split.next().unwrap();
|
||||
let Ok(index) = Index::try_from(ser_index) else {
|
||||
return Error::not_found(
|
||||
format!("Index '{ser_index}' doesn't exist")
|
||||
).into_response();
|
||||
};
|
||||
|
||||
let params = MetricSelection::from((
|
||||
index,
|
||||
Metrics::from(split.collect::<Vec<_>>().join(separator)),
|
||||
range,
|
||||
));
|
||||
legacy::handler(uri, headers, addr, Query(params), state)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.metrics_tag()
|
||||
.summary("Legacy variant endpoint")
|
||||
.description(
|
||||
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` instead.\n\n\
|
||||
Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\
|
||||
Legacy endpoint for querying metrics by variant path (e.g., `day1_to_price`). \
|
||||
Returns raw data without the MetricData wrapper."
|
||||
)
|
||||
.deprecated()
|
||||
.ok_response::<serde_json::Value>()
|
||||
.not_modified(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/vecs/query",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
Query(params): Query<MetricSelectionLegacy>,
|
||||
state: State<AppState>|
|
||||
-> Response {
|
||||
let params: MetricSelection = params.into();
|
||||
legacy::handler(uri, headers, addr, Query(params), state)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.metrics_tag()
|
||||
.summary("Legacy query endpoint")
|
||||
.description(
|
||||
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` or `/api/metrics/bulk` instead.\n\n\
|
||||
Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\
|
||||
Legacy endpoint for querying metrics. Returns raw data without the MetricData wrapper."
|
||||
)
|
||||
.deprecated()
|
||||
.ok_response::<serde_json::Value>()
|
||||
.not_modified(),
|
||||
),
|
||||
)
|
||||
// Cost basis distribution endpoints
|
||||
.api_route(
|
||||
"/api/metrics/cost-basis",
|
||||
@@ -475,5 +404,77 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
// Deprecated endpoints
|
||||
.api_route(
|
||||
"/api/vecs/{variant}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
Path(variant): Path<String>,
|
||||
Query(range): Query<DataRangeFormat>,
|
||||
state: State<AppState>|
|
||||
-> Response {
|
||||
let separator = "_to_";
|
||||
let variant = variant.replace("-", "_");
|
||||
let mut split = variant.split(separator);
|
||||
|
||||
let ser_index = split.next().unwrap();
|
||||
let Ok(index) = Index::try_from(ser_index) else {
|
||||
return Error::not_found(
|
||||
format!("Index '{ser_index}' doesn't exist")
|
||||
).into_response();
|
||||
};
|
||||
|
||||
let params = MetricSelection::from((
|
||||
index,
|
||||
Metrics::from(split.collect::<Vec<_>>().join(separator)),
|
||||
range,
|
||||
));
|
||||
legacy::handler(uri, headers, addr, Query(params), state)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.metrics_tag()
|
||||
.summary("Legacy variant endpoint")
|
||||
.description(
|
||||
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` instead.\n\n\
|
||||
Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\
|
||||
Legacy endpoint for querying metrics by variant path (e.g., `day1_to_price`). \
|
||||
Returns raw data without the MetricData wrapper."
|
||||
)
|
||||
.deprecated()
|
||||
.ok_response::<serde_json::Value>()
|
||||
.not_modified(),
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/vecs/query",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
addr: Extension<SocketAddr>,
|
||||
Query(params): Query<MetricSelectionLegacy>,
|
||||
state: State<AppState>|
|
||||
-> Response {
|
||||
let params: MetricSelection = params.into();
|
||||
legacy::handler(uri, headers, addr, Query(params), state)
|
||||
.await
|
||||
.into_response()
|
||||
},
|
||||
|op| op
|
||||
.metrics_tag()
|
||||
.summary("Legacy query endpoint")
|
||||
.description(
|
||||
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` or `/api/metrics/bulk` instead.\n\n\
|
||||
Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\
|
||||
Legacy endpoint for querying metrics. Returns raw data without the MetricData wrapper."
|
||||
)
|
||||
.deprecated()
|
||||
.ok_response::<serde_json::Value>()
|
||||
.not_modified(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use aide::axum::{ApiRouter, routing::get_with};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, Uri},
|
||||
response::Redirect,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use brk_types::{
|
||||
@@ -11,7 +11,7 @@ use brk_types::{
|
||||
RewardStats, TimePeriodParam,
|
||||
};
|
||||
|
||||
use crate::{CacheStrategy, extended::TransformResponseExtended};
|
||||
use crate::{CacheStrategy, Error, extended::TransformResponseExtended};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
@@ -200,18 +200,15 @@ impl MiningRoutes for ApiRouter<AppState> {
|
||||
.api_route(
|
||||
"/api/v1/mining/blocks/fee-rates/{time_period}",
|
||||
get_with(
|
||||
async |Path(_path): Path<TimePeriodParam>| {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "wip",
|
||||
"message": "This endpoint is work in progress. Percentile fields are not yet available."
|
||||
}))
|
||||
async |Path(_path): Path<TimePeriodParam>| -> Response {
|
||||
Error::not_implemented("Fee rate percentiles are not yet available").into_response()
|
||||
},
|
||||
|op| {
|
||||
op.id("get_block_fee_rates")
|
||||
.mining_tag()
|
||||
.summary("Block fee rates (WIP)")
|
||||
.description("**Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)*")
|
||||
.ok_response::<serde_json::Value>()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
api::{
|
||||
addresses::AddressRoutes, blocks::BlockRoutes, mempool::MempoolRoutes,
|
||||
metrics::ApiMetricsRoutes, mining::MiningRoutes, server::ServerRoutes,
|
||||
@@ -39,13 +40,13 @@ pub trait ApiRoutes {
|
||||
|
||||
impl ApiRoutes for ApiRouter<AppState> {
|
||||
fn add_api_routes(self) -> Self {
|
||||
self.add_addresses_routes()
|
||||
self.add_server_routes()
|
||||
.add_metrics_routes()
|
||||
.add_block_routes()
|
||||
.add_tx_routes()
|
||||
.add_addresses_routes()
|
||||
.add_mempool_routes()
|
||||
.add_mining_routes()
|
||||
.add_tx_routes()
|
||||
.add_metrics_routes()
|
||||
.add_server_routes()
|
||||
.route("/api/server", get(Redirect::temporary("/api#tag/server")))
|
||||
.api_route(
|
||||
"/openapi.json",
|
||||
@@ -99,7 +100,9 @@ impl ApiRoutes for ApiRouter<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/{*path}",
|
||||
get(|| async { Redirect::permanent("/api") }),
|
||||
get(|| async {
|
||||
Error::not_found("Unknown API endpoint. See /api for documentation.")
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ mod compact;
|
||||
|
||||
pub use compact::ApiJson;
|
||||
|
||||
use aide::openapi::{Contact, Info, License, OpenApi, Server, ServerVariable, Tag};
|
||||
use aide::openapi::{Contact, Info, License, OpenApi, Tag};
|
||||
|
||||
use crate::VERSION;
|
||||
|
||||
@@ -159,38 +159,9 @@ All errors return structured JSON with a consistent format:
|
||||
},
|
||||
];
|
||||
|
||||
let servers = vec![Server {
|
||||
url: "{scheme}://{host}".into(),
|
||||
description: Some("BRK server".into()),
|
||||
variables: [
|
||||
(
|
||||
"scheme".into(),
|
||||
ServerVariable {
|
||||
enumeration: vec!["https".into(), "http".into()],
|
||||
default: "https".into(),
|
||||
description: Some("Protocol".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"host".into(),
|
||||
ServerVariable {
|
||||
default: "bitview.space".into(),
|
||||
description: Some(
|
||||
"Server address (e.g. bitview.space or localhost:3110)".into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
OpenApi {
|
||||
info,
|
||||
tags,
|
||||
servers,
|
||||
..OpenApi::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,55 @@ pub trait ServerRoutes {
|
||||
impl ServerRoutes for ApiRouter<AppState> {
|
||||
fn add_server_routes(self) -> Self {
|
||||
self.api_route(
|
||||
"/health",
|
||||
get_with(
|
||||
async |State(state): State<AppState>| -> axum::Json<Health> {
|
||||
let uptime = state.started_instant.elapsed();
|
||||
let tip_height = state.client.get_last_height();
|
||||
let sync = state.sync(|q| {
|
||||
let tip_height = tip_height.unwrap_or(q.indexed_height());
|
||||
q.sync_status(tip_height)
|
||||
});
|
||||
axum::Json(Health {
|
||||
status: Cow::Borrowed("healthy"),
|
||||
service: Cow::Borrowed("brk"),
|
||||
version: Cow::Borrowed(VERSION),
|
||||
timestamp: jiff::Timestamp::now().to_string(),
|
||||
started_at: state.started_at.to_string(),
|
||||
uptime_seconds: uptime.as_secs(),
|
||||
sync,
|
||||
})
|
||||
},
|
||||
|op| {
|
||||
op.id("get_health")
|
||||
.server_tag()
|
||||
.summary("Health check")
|
||||
.description("Returns the health status of the API server, including uptime information.")
|
||||
.ok_response::<Health>()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/version",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
|
||||
state
|
||||
.cached_json(&headers, CacheStrategy::Static, &uri, |_| {
|
||||
Ok(env!("CARGO_PKG_VERSION"))
|
||||
})
|
||||
.await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_version")
|
||||
.server_tag()
|
||||
.summary("API version")
|
||||
.description("Returns the current version of the API server")
|
||||
.ok_response::<String>()
|
||||
.not_modified()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/server/sync",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
|
||||
@@ -67,55 +116,6 @@ impl ServerRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/health",
|
||||
get_with(
|
||||
async |State(state): State<AppState>| -> axum::Json<Health> {
|
||||
let uptime = state.started_instant.elapsed();
|
||||
let tip_height = state.client.get_last_height();
|
||||
let sync = state.sync(|q| {
|
||||
let tip_height = tip_height.unwrap_or(q.indexed_height());
|
||||
q.sync_status(tip_height)
|
||||
});
|
||||
axum::Json(Health {
|
||||
status: Cow::Borrowed("healthy"),
|
||||
service: Cow::Borrowed("brk"),
|
||||
version: Cow::Borrowed(VERSION),
|
||||
timestamp: jiff::Timestamp::now().to_string(),
|
||||
started_at: state.started_at.to_string(),
|
||||
uptime_seconds: uptime.as_secs(),
|
||||
sync,
|
||||
})
|
||||
},
|
||||
|op| {
|
||||
op.id("get_health")
|
||||
.server_tag()
|
||||
.summary("Health check")
|
||||
.description("Returns the health status of the API server, including uptime information.")
|
||||
.ok_response::<Health>()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/version",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
|
||||
state
|
||||
.cached_json(&headers, CacheStrategy::Static, &uri, |_| {
|
||||
Ok(env!("CARGO_PKG_VERSION"))
|
||||
})
|
||||
.await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_version")
|
||||
.server_tag()
|
||||
.summary("API version")
|
||||
.description("Returns the current version of the API server")
|
||||
.ok_response::<String>()
|
||||
.not_modified()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ fn error_status(e: &BrkError) -> StatusCode {
|
||||
| BrkError::UnknownTxid
|
||||
| BrkError::NotFound(_)
|
||||
| BrkError::NoData
|
||||
| BrkError::OutOfRange(_)
|
||||
| BrkError::MetricNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
BrkError::AuthFailed => StatusCode::FORBIDDEN,
|
||||
@@ -80,6 +81,7 @@ fn error_code(e: &BrkError) -> &'static str {
|
||||
BrkError::UnknownAddress => "unknown_address",
|
||||
BrkError::UnknownTxid => "unknown_txid",
|
||||
BrkError::NotFound(_) => "not_found",
|
||||
BrkError::OutOfRange(_) => "out_of_range",
|
||||
BrkError::NoData => "no_data",
|
||||
BrkError::MetricNotFound(_) => "metric_not_found",
|
||||
BrkError::MempoolNotAvailable => "mempool_not_available",
|
||||
@@ -128,6 +130,10 @@ impl Error {
|
||||
Self::new(StatusCode::NOT_FOUND, "not_found", msg)
|
||||
}
|
||||
|
||||
pub fn not_implemented(msg: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_IMPLEMENTED, "not_implemented", msg)
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", msg)
|
||||
}
|
||||
@@ -156,7 +162,7 @@ impl IntoResponse for Error {
|
||||
let body = build_error_body(self.status, self.code, self.message);
|
||||
(
|
||||
self.status,
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
[(header::CONTENT_TYPE, "application/problem+json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
http::{header, HeaderMap, HeaderValue},
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
};
|
||||
|
||||
/// HTTP content encoding for pre-compressed caching.
|
||||
@@ -61,9 +61,9 @@ impl ContentEncoding {
|
||||
encoder.write_all(&bytes).expect("gzip compression failed");
|
||||
Bytes::from(encoder.finish().expect("gzip finish failed"))
|
||||
}
|
||||
Self::Zstd => Bytes::from(
|
||||
zstd::encode_all(bytes.as_ref(), 3).expect("zstd compression failed"),
|
||||
),
|
||||
Self::Zstd => {
|
||||
Bytes::from(zstd::encode_all(bytes.as_ref(), 3).expect("zstd compression failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,19 @@ pub trait HeaderMapExtended {
|
||||
|
||||
impl HeaderMapExtended for HeaderMap {
|
||||
fn has_etag(&self, etag: &str) -> bool {
|
||||
self.get(IF_NONE_MATCH)
|
||||
.is_some_and(|prev_etag| etag == prev_etag)
|
||||
self.get(IF_NONE_MATCH).is_some_and(|v| {
|
||||
let s = v.as_bytes();
|
||||
// Match both quoted and unquoted: "etag" or etag
|
||||
s == etag.as_bytes()
|
||||
|| (s.len() == etag.len() + 2
|
||||
&& s[0] == b'"'
|
||||
&& s[s.len() - 1] == b'"'
|
||||
&& &s[1..s.len() - 1] == etag.as_bytes())
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_etag(&mut self, etag: &str) {
|
||||
self.insert(header::ETAG, etag.parse().unwrap());
|
||||
self.insert(header::ETAG, format!("\"{etag}\"").parse().unwrap());
|
||||
}
|
||||
|
||||
fn insert_cache_control(&mut self, value: &str) {
|
||||
@@ -43,7 +50,9 @@ impl HeaderMapExtended for HeaderMap {
|
||||
fn insert_content_disposition_attachment(&mut self, filename: &str) {
|
||||
self.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\"").parse().unwrap(),
|
||||
format!("attachment; filename=\"{filename}\"")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
@@ -9,11 +10,14 @@ use std::{
|
||||
|
||||
use aide::axum::ApiRouter;
|
||||
use axum::{
|
||||
Extension,
|
||||
Extension, ServiceExt,
|
||||
body::Body,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
http::{
|
||||
Request, Response, StatusCode, Uri,
|
||||
header::{CONTENT_TYPE, VARY},
|
||||
},
|
||||
middleware::Next,
|
||||
response::Redirect,
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::get,
|
||||
serve,
|
||||
};
|
||||
@@ -25,6 +29,7 @@ use tower_http::{
|
||||
compression::CompressionLayer, cors::CorsLayer, normalize_path::NormalizePathLayer,
|
||||
timeout::TimeoutLayer, trace::TraceLayer,
|
||||
};
|
||||
use tower_layer::Layer;
|
||||
use tracing::{error, info};
|
||||
|
||||
mod api;
|
||||
@@ -101,32 +106,57 @@ impl Server {
|
||||
},
|
||||
);
|
||||
|
||||
// Wrap non-JSON client errors (e.g. axum extraction rejections) in structured JSON
|
||||
// Wrap non-JSON error responses in structured JSON
|
||||
let json_error_layer = axum::middleware::from_fn(
|
||||
async |request: Request<Body>, next: Next| -> Response<Body> {
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let response = next.run(request).await;
|
||||
if !response.status().is_client_error()
|
||||
|| response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.is_some_and(|v| v.as_bytes().starts_with(b"application/json"))
|
||||
let status = response.status();
|
||||
if status.is_success()
|
||||
|| status.is_redirection()
|
||||
|| status.is_informational()
|
||||
|| response.headers().get(CONTENT_TYPE).is_some_and(|v| {
|
||||
let b = v.as_bytes();
|
||||
b.starts_with(b"application/") && b.ends_with(b"json")
|
||||
})
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
let (parts, body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, 4096)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let msg = String::from_utf8_lossy(&bytes).to_string();
|
||||
let code = if parts.status == StatusCode::NOT_FOUND {
|
||||
"not_found"
|
||||
} else {
|
||||
"bad_request"
|
||||
let bytes = axum::body::to_bytes(body, 4096).await.unwrap_or_default();
|
||||
let msg = String::from_utf8_lossy(&bytes);
|
||||
let (code, msg) = match parts.status {
|
||||
StatusCode::NOT_FOUND => (
|
||||
"not_found",
|
||||
if msg.is_empty() {
|
||||
"Not found".into()
|
||||
} else {
|
||||
msg
|
||||
},
|
||||
),
|
||||
StatusCode::METHOD_NOT_ALLOWED => (
|
||||
"method_not_allowed",
|
||||
"Only GET requests are supported".into(),
|
||||
),
|
||||
StatusCode::GATEWAY_TIMEOUT => ("timeout", "Request timed out".into()),
|
||||
s if s.is_client_error() => (
|
||||
"bad_request",
|
||||
if msg.is_empty() {
|
||||
"Bad request".into()
|
||||
} else {
|
||||
msg
|
||||
},
|
||||
),
|
||||
_ => (
|
||||
"internal_error",
|
||||
if msg.is_empty() {
|
||||
"Internal server error".into()
|
||||
} else {
|
||||
msg
|
||||
},
|
||||
),
|
||||
};
|
||||
let msg = msg.into_owned();
|
||||
let mut response = Error::new(parts.status, code, msg).into_response();
|
||||
response.extensions_mut().extend(parts.extensions);
|
||||
response
|
||||
@@ -165,18 +195,41 @@ impl Server {
|
||||
let router = router
|
||||
.with_state(state)
|
||||
.merge(website_router)
|
||||
.layer(json_error_layer)
|
||||
.layer(compression_layer)
|
||||
.layer(response_time_layer)
|
||||
.layer(trace_layer)
|
||||
.layer(CatchPanicLayer::new())
|
||||
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send>| {
|
||||
let msg = panic
|
||||
.downcast_ref::<String>()
|
||||
.map(|s| s.as_str())
|
||||
.or_else(|| panic.downcast_ref::<&str>().copied())
|
||||
.unwrap_or("Unknown panic");
|
||||
Error::internal(msg).into_response()
|
||||
}))
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Duration::from_secs(5),
|
||||
))
|
||||
.layer(json_error_layer)
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(connect_info_layer)
|
||||
.layer(NormalizePathLayer::trim_trailing_slash());
|
||||
.layer(axum::middleware::from_fn(
|
||||
async |request: Request<Body>, next: Next| -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
// Consolidate multiple Vary headers into one
|
||||
let vary: Vec<&str> = response
|
||||
.headers()
|
||||
.get_all(VARY)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.collect();
|
||||
if vary.len() > 1 {
|
||||
let merged = vary.join(", ");
|
||||
response.headers_mut().insert(VARY, merged.parse().unwrap());
|
||||
}
|
||||
response
|
||||
},
|
||||
))
|
||||
.layer(connect_info_layer);
|
||||
|
||||
let (listener, port) = match port {
|
||||
Some(port) => {
|
||||
@@ -235,9 +288,12 @@ impl Server {
|
||||
.layer(Extension(Arc::new(openapi)))
|
||||
.layer(Extension(api_json));
|
||||
|
||||
// NormalizePath must wrap the router (not be a layer) to run before route matching
|
||||
let app = NormalizePathLayer::trim_trailing_slash().layer(router);
|
||||
|
||||
serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
ServiceExt::<Request<Body>>::into_make_service_with_connect_info::<SocketAddr>(app),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -60,9 +60,10 @@ impl AppState {
|
||||
|
||||
let full_key = format!("{}-{}-{}", uri, params.etag_str(), encoding.as_str());
|
||||
let result = self
|
||||
.get_or_insert(&full_key, async move {
|
||||
self.run(move |q| f(q, encoding)).await
|
||||
})
|
||||
.get_or_insert(
|
||||
&full_key,
|
||||
async move { self.run(move |q| f(q, encoding)).await },
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
|
||||
Reference in New Issue
Block a user