global: metrics -> series rename

This commit is contained in:
nym21
2026-03-16 14:31:50 +01:00
parent bc06567bb0
commit ae2dd43073
95 changed files with 8907 additions and 8415 deletions
@@ -10,55 +10,52 @@ use axum::{
use brk_traversable::TreeNode;
use brk_types::{
CostBasisCohortParam, CostBasisFormatted, CostBasisParams, CostBasisQuery, DataRangeFormat,
Date, Index, IndexInfo, Metric, MetricCount, MetricData, MetricInfo, MetricParam,
MetricSelection, MetricSelectionLegacy, MetricWithIndex, Metrics, PaginatedMetrics, Pagination,
SearchQuery,
Date, DetailedSeriesCount, Index, IndexInfo, PaginatedSeries, Pagination, SearchQuery, Series,
SeriesData, SeriesInfo, SeriesList, SeriesSelection, SeriesSelectionLegacy,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{CacheStrategy, Error, extended::TransformResponseExtended};
use super::AppState;
use super::series::legacy;
mod bulk;
mod data;
mod legacy;
/// Maximum allowed request weight in bytes (650KB)
const MAX_WEIGHT: usize = 65 * 10_000;
/// Maximum allowed request weight for localhost (50MB)
const MAX_WEIGHT_LOCALHOST: usize = 50 * 1_000_000;
/// Cache control header for metric data responses
const CACHE_CONTROL: &str = "public, max-age=1, must-revalidate";
/// Returns the max weight for a request based on the client address.
/// Localhost requests get a generous limit, external requests get a stricter one.
fn max_weight(addr: &SocketAddr) -> usize {
if addr.ip().is_loopback() {
MAX_WEIGHT_LOCALHOST
} else {
MAX_WEIGHT
}
/// Legacy path parameter for `/api/metric/{metric}`
#[derive(Deserialize, JsonSchema)]
struct LegacySeriesParam {
metric: Series,
}
pub trait ApiMetricsRoutes {
fn add_metrics_routes(self) -> Self;
/// Legacy path parameters for `/api/metric/{metric}/{index}`
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct LegacySeriesWithIndex {
metric: Series,
index: Index,
}
impl ApiMetricsRoutes for ApiRouter<AppState> {
fn add_metrics_routes(self) -> Self {
self.api_route(
pub trait ApiMetricsLegacyRoutes {
fn add_metrics_legacy_routes(self) -> Self;
}
impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
fn add_metrics_legacy_routes(self) -> Self {
self
// --- Deprecated /api/metrics routes ---
.api_route(
"/api/metrics",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.metrics_catalog().clone())).await
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_catalog().clone())).await
},
|op| op
.id("get_metrics_tree")
.id("get_metrics_tree_deprecated")
.metrics_tag()
.summary("Metrics catalog")
.deprecated()
.summary("Metrics catalog (deprecated)")
.description(
"Returns the complete hierarchical catalog of available metrics organized as a tree structure. \
Metrics are grouped by categories and subcategories."
"**DEPRECATED** - Use `/api/series` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<TreeNode>()
.not_modified(),
@@ -72,14 +69,18 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.metric_count())).await
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_count())).await
},
|op| op
.id("get_metrics_count")
.id("get_metrics_count_deprecated")
.metrics_tag()
.summary("Metric count")
.description("Returns the number of metrics available per index type.")
.ok_response::<Vec<MetricCount>>()
.deprecated()
.summary("Metric count (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/count` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<DetailedSeriesCount>()
.not_modified(),
),
)
@@ -94,11 +95,13 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.indexes().to_vec())).await
},
|op| op
.id("get_indexes")
.id("get_indexes_deprecated")
.metrics_tag()
.summary("List available indexes")
.deprecated()
.summary("List available indexes (deprecated)")
.description(
"Returns all available indexes with their accepted query aliases. Use any alias when querying metrics."
"**DEPRECATED** - Use `/api/series/indexes` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<IndexInfo>>()
.not_modified(),
@@ -113,14 +116,18 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(pagination): Query<Pagination>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.metrics(pagination))).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.series_list(pagination))).await
},
|op| op
.id("list_metrics")
.id("list_metrics_deprecated")
.metrics_tag()
.summary("Metrics list")
.description("Paginated flat list of all available metric names. Use `page` query param for pagination.")
.ok_response::<PaginatedMetrics>()
.deprecated()
.summary("Metrics list (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/list` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<PaginatedSeries>()
.not_modified(),
),
)
@@ -133,18 +140,45 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(query): Query<SearchQuery>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.search_metrics(&query))).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.search_series(&query))).await
},
|op| op
.id("search_metrics")
.id("search_metrics_deprecated")
.metrics_tag()
.summary("Search metrics")
.description("Fuzzy search for metrics by name. Supports partial matches and typos.")
.ok_response::<Vec<Metric>>()
.deprecated()
.summary("Search metrics (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/search` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<&str>>()
.not_modified()
.server_error(),
),
)
.api_route(
"/api/metrics/bulk",
get_with(
|uri: Uri, headers: HeaderMap, addr: Extension<SocketAddr>, query: Query<SeriesSelection>, state: State<AppState>| async move {
legacy::handler(uri, headers, addr, query, state)
.await
.into_response()
},
|op| op
.id("get_metrics_bulk_deprecated")
.metrics_tag()
.deprecated()
.summary("Bulk metric data (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/bulk` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<SeriesData>>()
.csv_response()
.not_modified(),
),
)
// --- Deprecated /api/metric/{metric} routes ---
.api_route(
"/api/metric/{metric}",
get_with(
@@ -152,20 +186,22 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricParam>
Path(path): Path<LegacySeriesParam>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
q.metric_info(&path.metric).ok_or_else(|| q.metric_not_found_error(&path.metric))
q.series_info(&path.metric).ok_or_else(|| q.series_not_found_error(&path.metric))
}).await
},
|op| op
.id("get_metric_info")
.id("get_metric_info_deprecated")
.metrics_tag()
.summary("Get metric info")
.deprecated()
.summary("Get metric info (deprecated)")
.description(
"Returns the supported indexes and value type for the specified metric."
"**DEPRECATED** - Use `/api/series/{series}` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<MetricInfo>()
.ok_response::<SeriesInfo>()
.not_modified()
.not_found()
.server_error(),
@@ -178,28 +214,24 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
addr: Extension<SocketAddr>,
state: State<AppState>,
Path(path): Path<MetricWithIndex>,
Path(path): Path<LegacySeriesWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::handler(
uri,
headers,
addr,
Query(MetricSelection::from((path.index, path.metric, range))),
state,
)
.await
.into_response()
let params = SeriesSelection::from((path.index, path.metric, range));
legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},
|op| op
.id("get_metric")
.id("get_metric_deprecated")
.metrics_tag()
.summary("Get metric data")
.deprecated()
.summary("Get metric data (deprecated)")
.description(
"Fetch data for a specific metric at the given index. \
Use query parameters to filter by date range and format (json/csv)."
"**DEPRECATED** - Use `/api/series/{series}/{index}` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<MetricData>()
.ok_response::<SeriesData>()
.csv_response()
.not_modified()
.not_found(),
@@ -212,26 +244,22 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
addr: Extension<SocketAddr>,
state: State<AppState>,
Path(path): Path<MetricWithIndex>,
Path(path): Path<LegacySeriesWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::raw_handler(
uri,
headers,
addr,
Query(MetricSelection::from((path.index, path.metric, range))),
state,
)
.await
.into_response()
let params = SeriesSelection::from((path.index, path.metric, range));
legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},
|op| op
.id("get_metric_data")
.id("get_metric_data_deprecated")
.metrics_tag()
.summary("Get raw metric data")
.deprecated()
.summary("Get raw metric data (deprecated)")
.description(
"Returns just the data array without the MetricData wrapper. \
Supports the same range and format parameters as the standard endpoint."
"**DEPRECATED** - Use `/api/series/{series}/{index}/data` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<serde_json::Value>>()
.csv_response()
@@ -245,7 +273,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricWithIndex>| {
Path(path): Path<LegacySeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.latest(&path.metric, path.index)
@@ -253,11 +281,13 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| op
.id("get_metric_latest")
.id("get_metric_latest_deprecated")
.metrics_tag()
.summary("Get latest metric value")
.deprecated()
.summary("Get latest metric value (deprecated)")
.description(
"Returns the single most recent value for a metric, unwrapped (not inside a MetricData object)."
"**DEPRECATED** - Use `/api/series/{series}/{index}/latest` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<serde_json::Value>()
.not_found(),
@@ -269,7 +299,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricWithIndex>| {
Path(path): Path<LegacySeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.len(&path.metric, path.index)
@@ -277,10 +307,14 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| op
.id("get_metric_len")
.id("get_metric_len_deprecated")
.metrics_tag()
.summary("Get metric data length")
.description("Returns the total number of data points for a metric at the given index.")
.deprecated()
.summary("Get metric data length (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/{series}/{index}/len` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<usize>()
.not_found(),
),
@@ -291,7 +325,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricWithIndex>| {
Path(path): Path<LegacySeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.version(&path.metric, path.index)
@@ -299,34 +333,19 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| op
.id("get_metric_version")
.id("get_metric_version_deprecated")
.metrics_tag()
.summary("Get metric version")
.description("Returns the current version of a metric. Changes when the metric data is updated.")
.deprecated()
.summary("Get metric version (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/{series}/{index}/version` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<brk_types::Version>()
.not_found(),
),
)
.api_route(
"/api/metrics/bulk",
get_with(
|uri, headers, addr, query, state| async move {
bulk::handler(uri, headers, addr, query, state).await.into_response()
},
|op| op
.id("get_metrics")
.metrics_tag()
.summary("Bulk metric data")
.description(
"Fetch multiple metrics in a single request. Supports filtering by index and date range. \
Returns an array of MetricData objects. For a single metric, use `get_metric` instead."
)
.ok_response::<Vec<MetricData>>()
.csv_response()
.not_modified(),
),
)
// Cost basis distribution endpoints
// --- Deprecated cost basis routes ---
.api_route(
"/api/metrics/cost-basis",
get_with(
@@ -336,10 +355,14 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| {
op.id("get_cost_basis_cohorts")
op.id("get_cost_basis_cohorts_deprecated")
.metrics_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.deprecated()
.summary("Available cost basis cohorts (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/cost-basis` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<String>>()
.server_error()
},
@@ -359,10 +382,14 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| {
op.id("get_cost_basis_dates")
op.id("get_cost_basis_dates_deprecated")
.metrics_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.deprecated()
.summary("Available cost basis dates (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/cost-basis/{cohort}/dates` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<Vec<Date>>()
.not_found()
.server_error()
@@ -389,14 +416,13 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.await
},
|op| {
op.id("get_cost_basis")
op.id("get_cost_basis_deprecated")
.metrics_tag()
.summary("Cost basis distribution")
.deprecated()
.summary("Cost basis distribution (deprecated)")
.description(
"Get the cost basis distribution for a cohort on a specific date.\n\n\
Query params:\n\
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100\n\
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
"**DEPRECATED** - Use `/api/series/cost-basis/{cohort}/{date}` instead.\n\n\
Sunset date: 2027-01-01."
)
.ok_response::<CostBasisFormatted>()
.not_found()
@@ -404,7 +430,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
},
),
)
// Deprecated endpoints
// --- Deprecated /api/vecs/ routes (moved from series module) ---
.api_route(
"/api/vecs/{variant}",
get_with(
@@ -426,9 +452,9 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
).into_response();
};
let params = MetricSelection::from((
let params = SeriesSelection::from((
index,
Metrics::from(split.collect::<Vec<_>>().join(separator)),
SeriesList::from(split.collect::<Vec<_>>().join(separator)),
range,
));
legacy::handler(uri, headers, addr, Query(params), state)
@@ -439,10 +465,10 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.metrics_tag()
.summary("Legacy variant endpoint")
.description(
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` instead.\n\n\
"**DEPRECATED** - Use `/api/series/{series}/{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."
Legacy endpoint for querying series by variant path (e.g., `day1_to_price`). \
Returns raw data without the SeriesData wrapper."
)
.deprecated()
.ok_response::<serde_json::Value>()
@@ -455,10 +481,10 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
Query(params): Query<MetricSelectionLegacy>,
Query(params): Query<SeriesSelectionLegacy>,
state: State<AppState>|
-> Response {
let params: MetricSelection = params.into();
let params: SeriesSelection = params.into();
legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
@@ -467,9 +493,9 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.metrics_tag()
.summary("Legacy query endpoint")
.description(
"**DEPRECATED** - Use `/api/metric/{metric}/{index}` or `/api/metrics/bulk` instead.\n\n\
"**DEPRECATED** - Use `/api/series/{series}/{index}` or `/api/series/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."
Legacy endpoint for querying series. Returns raw data without the SeriesData wrapper."
)
.deprecated()
.ok_response::<serde_json::Value>()
+6 -4
View File
@@ -15,8 +15,8 @@ use crate::{
Error,
api::{
addresses::AddressRoutes, blocks::BlockRoutes, mempool::MempoolRoutes,
metrics::ApiMetricsRoutes, mining::MiningRoutes, server::ServerRoutes,
transactions::TxRoutes,
metrics_legacy::ApiMetricsLegacyRoutes, mining::MiningRoutes,
series::ApiSeriesRoutes, server::ServerRoutes, transactions::TxRoutes,
},
extended::{ResponseExtended, TransformResponseExtended},
};
@@ -26,7 +26,8 @@ use super::AppState;
mod addresses;
mod blocks;
mod mempool;
mod metrics;
mod metrics_legacy;
mod series;
mod mining;
mod openapi;
mod server;
@@ -41,7 +42,8 @@ pub trait ApiRoutes {
impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_server_routes()
.add_metrics_routes()
.add_series_routes()
.add_metrics_legacy_routes()
.add_block_routes()
.add_tx_routes()
.add_addresses_routes()
+19 -12
View File
@@ -22,12 +22,12 @@ pub fn create_openapi() -> OpenApi {
let info = Info {
title: "Bitcoin Research Kit".to_string(),
description: Some(
r#"API for querying Bitcoin blockchain data and on-chain metrics.
r#"API for querying Bitcoin blockchain data and on-chain series.
### Features
- **Metrics**: Thousands of time-series metrics across multiple indexes (date, block height, etc.)
- **[Mempool.space](https://mempool.space/docs/api/rest) compatible** (WIP): Most non-metrics endpoints follow the mempool.space API format
- **Series**: Thousands of time-series across multiple indexes (date, block height, etc.)
- **[Mempool.space](https://mempool.space/docs/api/rest) compatible** (WIP): Most non-series endpoints follow the mempool.space API format
- **Multiple formats**: JSON and CSV output
- **LLM-optimized**: [`/llms.txt`](/llms.txt) for discovery, [`/api.json`](/api.json) compact OpenAPI spec for tool use (full spec at [`/openapi.json`](/openapi.json))
@@ -35,9 +35,9 @@ pub fn create_openapi() -> OpenApi {
```bash
curl -s https://bitview.space/api/block-height/0
curl -s https://bitview.space/api/metrics/search?q=price
curl -s https://bitview.space/api/metric/price/day
curl -s https://bitview.space/api/metric/price/day/latest
curl -s https://bitview.space/api/series/search?q=price
curl -s https://bitview.space/api/series/price/day
curl -s https://bitview.space/api/series/price/day/latest
```
### Errors
@@ -48,7 +48,7 @@ All errors return structured JSON with a consistent format:
{
"error": {
"type": "not_found",
"code": "metric_not_found",
"code": "series_not_found",
"message": "'foo' not found, did you mean 'bar'?",
"doc_url": "https://bitcoinresearchkit.org/api"
}
@@ -56,7 +56,7 @@ All errors return structured JSON with a consistent format:
```
- **`type`**: Error category — `invalid_request` (400), `forbidden` (403), `not_found` (404), `unavailable` (503), or `internal` (500)
- **`code`**: Machine-readable error code (e.g. `invalid_address`, `metric_not_found`, `weight_exceeded`)
- **`code`**: Machine-readable error code (e.g. `invalid_address`, `series_not_found`, `weight_exceeded`)
- **`message`**: Human-readable description
- **`doc_url`**: Link to API documentation
@@ -97,13 +97,20 @@ All errors return structured JSON with a consistent format:
),
..Default::default()
},
Tag {
name: "Series".to_string(),
description: Some(
"Access thousands of Bitcoin network time-series data. Query historical statistics \
across various indexes (date, week, month, block height) with JSON or CSV output.\n\n\
**Note:** Series names are subject to change while the project is in active development."
.to_string(),
),
..Default::default()
},
Tag {
name: "Metrics".to_string(),
description: Some(
"Access thousands of Bitcoin network metrics and time-series data. Query historical statistics \
across various indexes (date, week, month, block height) with JSON or CSV output.\n\n\
**Note:** Metric names are subject to change while the project is in active development."
.to_string(),
"Deprecated — use Series".to_string(),
),
..Default::default()
},
@@ -7,11 +7,11 @@ use axum::{
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_types::{Format, MetricSelection, Output};
use brk_types::{Format, Output, SeriesSelection};
use crate::{
Result,
api::metrics::{CACHE_CONTROL, max_weight},
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
};
@@ -21,7 +21,7 @@ pub async fn handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
@@ -9,11 +9,11 @@ use axum::{
};
use brk_error::Result as BrkResult;
use brk_query::{Query as BrkQuery, ResolvedQuery};
use brk_types::{Format, MetricOutput, MetricSelection, Output};
use brk_types::{Format, Output, SeriesOutput, SeriesSelection};
use crate::{
Result,
api::metrics::{CACHE_CONTROL, max_weight},
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
};
@@ -23,7 +23,7 @@ pub async fn handler(
uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
Query(params): Query<SeriesSelection>,
state: State<AppState>,
) -> Result<Response> {
format_and_respond(uri, headers, addr, params, state, |q, r| q.format(r)).await
@@ -33,7 +33,7 @@ pub async fn raw_handler(
uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
Query(params): Query<SeriesSelection>,
state: State<AppState>,
) -> Result<Response> {
format_and_respond(uri, headers, addr, params, state, |q, r| q.format_raw(r)).await
@@ -43,9 +43,9 @@ async fn format_and_respond(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
params: MetricSelection,
params: SeriesSelection,
state: State<AppState>,
formatter: fn(&BrkQuery, ResolvedQuery) -> BrkResult<MetricOutput>,
formatter: fn(&BrkQuery, ResolvedQuery) -> BrkResult<SeriesOutput>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = state
@@ -7,15 +7,15 @@ use axum::{
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_types::{Format, MetricSelection, OutputLegacy};
use brk_types::{Format, OutputLegacy, SeriesSelection};
use crate::{
Result,
api::metrics::{CACHE_CONTROL, max_weight},
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
};
const SUNSET: &str = "2027-01-01T00:00:00Z";
pub const SUNSET: &str = "2027-01-01T00:00:00Z";
use super::AppState;
@@ -23,7 +23,7 @@ pub async fn handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
+407
View File
@@ -0,0 +1,407 @@
use std::net::SocketAddr;
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Extension,
extract::{Path, Query, State},
http::{HeaderMap, Uri},
response::{IntoResponse, Response},
};
use brk_traversable::TreeNode;
use brk_types::{
CostBasisCohortParam, CostBasisFormatted, CostBasisParams, CostBasisQuery, DataRangeFormat,
Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery, SeriesCount, SeriesData,
SeriesInfo, SeriesParam, SeriesSelection, SeriesWithIndex,
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
mod bulk;
mod data;
pub mod legacy;
/// Maximum allowed request weight in bytes (650KB)
const MAX_WEIGHT: usize = 65 * 10_000;
/// Maximum allowed request weight for localhost (50MB)
const MAX_WEIGHT_LOCALHOST: usize = 50 * 1_000_000;
/// Cache control header for series data responses
const CACHE_CONTROL: &str = "public, max-age=1, must-revalidate";
/// Returns the max weight for a request based on the client address.
/// Localhost requests get a generous limit, external requests get a stricter one.
fn max_weight(addr: &SocketAddr) -> usize {
if addr.ip().is_loopback() {
MAX_WEIGHT_LOCALHOST
} else {
MAX_WEIGHT
}
}
pub trait ApiSeriesRoutes {
fn add_series_routes(self) -> Self;
}
impl ApiSeriesRoutes for ApiRouter<AppState> {
fn add_series_routes(self) -> Self {
self.api_route(
"/api/series",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_catalog().clone())).await
},
|op| op
.id("get_series_tree")
.series_tag()
.summary("Series catalog")
.description(
"Returns the complete hierarchical catalog of available series organized as a tree structure. \
Series are grouped by categories and subcategories."
)
.ok_response::<TreeNode>()
.not_modified(),
),
)
.api_route(
"/api/series/count",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_count())).await
},
|op| op
.id("get_series_count")
.series_tag()
.summary("Series count")
.description("Returns the number of series available per index type.")
.ok_response::<Vec<SeriesCount>>()
.not_modified(),
),
)
.api_route(
"/api/series/indexes",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.indexes().to_vec())).await
},
|op| op
.id("get_indexes")
.series_tag()
.summary("List available indexes")
.description(
"Returns all available indexes with their accepted query aliases. Use any alias when querying series."
)
.ok_response::<Vec<IndexInfo>>()
.not_modified(),
),
)
.api_route(
"/api/series/list",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Query(pagination): Query<Pagination>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.series_list(pagination))).await
},
|op| op
.id("list_series")
.series_tag()
.summary("Series list")
.description("Paginated flat list of all available series names. Use `page` query param for pagination.")
.ok_response::<PaginatedSeries>()
.not_modified(),
),
)
.api_route(
"/api/series/search",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Query(query): Query<SearchQuery>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.search_series(&query))).await
},
|op| op
.id("search_series")
.series_tag()
.summary("Search series")
.description("Fuzzy search for series by name. Supports partial matches and typos.")
.ok_response::<Vec<&str>>()
.not_modified()
.server_error(),
),
)
.api_route(
"/api/series/{series}",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<SeriesParam>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
q.series_info(&path.series).ok_or_else(|| q.series_not_found_error(&path.series))
}).await
},
|op| op
.id("get_series_info")
.series_tag()
.summary("Get series info")
.description(
"Returns the supported indexes and value type for the specified series."
)
.ok_response::<SeriesInfo>()
.not_modified()
.not_found()
.server_error(),
),
)
.api_route(
"/api/series/{series}/{index}",
get_with(
async |uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
state: State<AppState>,
Path(path): Path<SeriesWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::handler(
uri,
headers,
addr,
Query(SeriesSelection::from((path.index, path.series, range))),
state,
)
.await
.into_response()
},
|op| op
.id("get_series")
.series_tag()
.summary("Get series data")
.description(
"Fetch data for a specific series at the given index. \
Use query parameters to filter by date range and format (json/csv)."
)
.ok_response::<SeriesData>()
.csv_response()
.not_modified()
.not_found(),
),
)
.api_route(
"/api/series/{series}/{index}/data",
get_with(
async |uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
state: State<AppState>,
Path(path): Path<SeriesWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::raw_handler(
uri,
headers,
addr,
Query(SeriesSelection::from((path.index, path.series, range))),
state,
)
.await
.into_response()
},
|op| op
.id("get_series_data")
.series_tag()
.summary("Get raw series data")
.description(
"Returns just the data array without the SeriesData 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/series/{series}/{index}/latest",
get_with(
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<SeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.latest(&path.series, path.index)
})
.await
},
|op| op
.id("get_series_latest")
.series_tag()
.summary("Get latest series value")
.description(
"Returns the single most recent value for a series, unwrapped (not inside a SeriesData object)."
)
.ok_response::<serde_json::Value>()
.not_found(),
),
)
.api_route(
"/api/series/{series}/{index}/len",
get_with(
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<SeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.len(&path.series, path.index)
})
.await
},
|op| op
.id("get_series_len")
.series_tag()
.summary("Get series data length")
.description("Returns the total number of data points for a series at the given index.")
.ok_response::<usize>()
.not_found(),
),
)
.api_route(
"/api/series/{series}/{index}/version",
get_with(
async |uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<SeriesWithIndex>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.version(&path.series, path.index)
})
.await
},
|op| op
.id("get_series_version")
.series_tag()
.summary("Get series version")
.description("Returns the current version of a series. Changes when the series data is updated.")
.ok_response::<brk_types::Version>()
.not_found(),
),
)
.api_route(
"/api/series/bulk",
get_with(
|uri, headers, addr, query, state| async move {
bulk::handler(uri, headers, addr, query, state).await.into_response()
},
|op| op
.id("get_series_bulk")
.series_tag()
.summary("Bulk series data")
.description(
"Fetch multiple series in a single request. Supports filtering by index and date range. \
Returns an array of SeriesData objects. For a single series, use `get_series` instead."
)
.ok_response::<Vec<SeriesData>>()
.csv_response()
.not_modified(),
),
)
// Cost basis distribution endpoints
.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.series_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.ok_response::<Vec<String>>()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.series_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.ok_response::<Vec<Date>>()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.series_tag()
.summary("Cost basis distribution")
.description(
"Get the cost basis distribution for a cohort on a specific date.\n\n\
Query params:\n\
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100\n\
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
)
.ok_response::<CostBasisFormatted>()
.not_found()
.server_error()
},
),
)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub enum CacheStrategy {
/// Etag = VERSION-{height}, Cache-Control: must-revalidate
Height,
/// Static/immutable data (blocks by hash, validate-address, metrics catalog)
/// Static/immutable data (blocks by hash, validate-address, series catalog)
/// Etag = VERSION only, Cache-Control: must-revalidate
Static,
+7 -7
View File
@@ -23,7 +23,7 @@ struct ErrorDetail {
/// Error category: "invalid_request", "forbidden", "not_found", "unavailable", or "internal"
#[schemars(with = "String")]
r#type: &'static str,
/// Machine-readable error code (e.g. "invalid_address", "metric_not_found")
/// Machine-readable error code (e.g. "invalid_address", "series_not_found")
#[schemars(with = "String")]
code: &'static str,
/// Human-readable description
@@ -50,8 +50,8 @@ fn error_status(e: &BrkError) -> StatusCode {
| BrkError::InvalidAddress
| BrkError::UnsupportedType(_)
| BrkError::Parse(_)
| BrkError::NoMetrics
| BrkError::MetricUnsupportedIndex { .. }
| BrkError::NoSeries
| BrkError::SeriesUnsupportedIndex { .. }
| BrkError::WeightExceeded { .. } => StatusCode::BAD_REQUEST,
BrkError::UnknownAddress
@@ -59,7 +59,7 @@ fn error_status(e: &BrkError) -> StatusCode {
| BrkError::NotFound(_)
| BrkError::NoData
| BrkError::OutOfRange(_)
| BrkError::MetricNotFound(_) => StatusCode::NOT_FOUND,
| BrkError::SeriesNotFound(_) => StatusCode::NOT_FOUND,
BrkError::AuthFailed => StatusCode::FORBIDDEN,
BrkError::MempoolNotAvailable => StatusCode::SERVICE_UNAVAILABLE,
@@ -75,15 +75,15 @@ fn error_code(e: &BrkError) -> &'static str {
BrkError::InvalidNetwork => "invalid_network",
BrkError::UnsupportedType(_) => "unsupported_type",
BrkError::Parse(_) => "parse_error",
BrkError::NoMetrics => "no_metrics",
BrkError::MetricUnsupportedIndex { .. } => "metric_unsupported_index",
BrkError::NoSeries => "no_series",
BrkError::SeriesUnsupportedIndex { .. } => "series_unsupported_index",
BrkError::WeightExceeded { .. } => "weight_exceeded",
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::SeriesNotFound(_) => "series_not_found",
BrkError::MempoolNotAvailable => "mempool_not_available",
BrkError::AuthFailed => "auth_failed",
_ => "internal_error",
@@ -11,6 +11,7 @@ pub trait TransformResponseExtended<'t> {
fn mempool_tag(self) -> Self;
fn metrics_tag(self) -> Self;
fn mining_tag(self) -> Self;
fn series_tag(self) -> Self;
fn server_tag(self) -> Self;
fn transactions_tag(self) -> Self;
@@ -55,6 +56,10 @@ impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> {
self.tag("Metrics")
}
fn series_tag(self) -> Self {
self.tag("Series")
}
fn mining_tag(self) -> Self {
self.tag("Mining")
}