server: api doc part 2

This commit is contained in:
nym21
2025-10-07 22:10:32 +02:00
parent 7ff79c3164
commit a53f89c849
19 changed files with 640 additions and 465 deletions

View File

@@ -0,0 +1,229 @@
use std::str::FromStr;
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use bitcoin::{Address as BitcoinAddress, Network, PublicKey, ScriptBuf};
use brk_structs::{
AddressBytes, AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats,
TypeIndex,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::{AnyIterableVec, VecIterator};
use super::AppState;
#[derive(Debug, Serialize, JsonSchema)]
/// Address information
struct AddressInfo {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
address: String,
#[schemars(example = OutputType::P2PK65)]
r#type: OutputType,
#[schemars(example = TypeIndex::new(0))]
type_index: TypeIndex,
/// Total satoshis ever sent from this address
#[schemars(example = Sats::new(0))]
total_sent: Sats,
/// Total satoshis ever received by this address
#[schemars(example = Sats::new(5001008380))]
total_received: Sats,
/// Number of unspent transaction outputs (UTXOs)
#[schemars(example = 10)]
utxo_count: u32,
/// Current spendable balance in satoshis (total_received - total_sent)
#[schemars(example = Sats::new(5001008380))]
balance: Sats,
/// Current balance value in USD at current market price
#[schemars(example = Some(Dollars::mint(6_157_891.64)))]
balance_usd: Option<Dollars>,
/// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(6.2)))]
estimated_total_invested: Option<Dollars>,
/// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(0.12)))]
estimated_avg_entry_price: Option<Dollars>,
//
// Transaction count?
// First/last activity timestamps?
// Realized/unrealized gains?
// Current value (balance × current price)?
// "address": address,
// "type": output_type,
// "index": addri,
// "chain_stats": {
// "funded_txo_count": null,
// "funded_txo_sum": addr_data.received,
// "spent_txo_count": null,
// "spent_txo_sum": addr_data.sent,
// "utxo_count": addr_data.utxos,
// "balance": amount,
// "balance_usd": price.map_or(Value::new(), |p| {
// Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap())
// }),
// "realized_value": addr_data.realized_cap,
// "tx_count": null,
// "avg_cost_basis": addr_data.realized_price()
// },
// "mempool_stats": null
}
#[derive(Deserialize, JsonSchema)]
struct AddressPath {
/// Bitcoin address string
address: String,
}
async fn get_address_info(
Path(AddressPath { address }): Path<AddressPath>,
state: State<AppState>,
) -> Result<Json<AddressInfo>, StatusCode> {
let interface = state.interface;
let indexer = interface.indexer();
let computer = interface.computer();
let stores = &indexer.stores;
let script = if let Ok(address) = BitcoinAddress::from_str(&address) {
if !address.is_valid_for_network(Network::Bitcoin) {
return Err(StatusCode::BAD_REQUEST);
}
let address = address.assume_checked();
address.script_pubkey()
} else if let Ok(pubkey) = PublicKey::from_str(&address) {
ScriptBuf::new_p2pk(&pubkey)
} else {
return Err(StatusCode::BAD_REQUEST);
};
let type_ = OutputType::from(&script);
let Ok(bytes) = AddressBytes::try_from((&script, type_)) else {
return Err(StatusCode::BAD_REQUEST);
};
let hash = AddressBytesHash::from((&bytes, type_));
let Ok(Some(type_index)) = stores
.addressbyteshash_to_typeindex
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err(StatusCode::NOT_FOUND);
};
let stateful = &computer.stateful;
let price = computer.price.as_ref().map(|v| {
*v.timeindexes_to_price_close
.dateindex
.as_ref()
.unwrap()
.iter()
.last()
.unwrap()
.1
.into_owned()
});
let any_address_index = match type_ {
OutputType::P2PK33 => stateful
.p2pk33addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PK65 => stateful
.p2pk65addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PKH => stateful
.p2pkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2SH => stateful
.p2shaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2TR => stateful
.p2traddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WPKH => stateful
.p2wpkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WSH => stateful
.p2wshaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2A => stateful
.p2aaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
_ => return Err(StatusCode::BAD_REQUEST),
};
let address_data = match any_address_index.to_enum() {
AnyAddressDataIndexEnum::Loaded(index) => stateful
.loadedaddressindex_to_loadedaddressdata
.iter()
.unwrap_get_inner(index),
AnyAddressDataIndexEnum::Empty(index) => stateful
.emptyaddressindex_to_emptyaddressdata
.iter()
.unwrap_get_inner(index)
.into(),
};
let balance = address_data.balance();
Ok(Json(AddressInfo {
address: address.to_string(),
r#type: type_,
type_index,
utxo_count: address_data.utxo_count,
total_sent: address_data.sent,
total_received: address_data.received,
balance,
balance_usd: price.map(|p| p * Bitcoin::from(balance)),
estimated_total_invested: price.map(|_| address_data.realized_cap),
estimated_avg_entry_price: price.map(|_| address_data.realized_price()),
}))
}
fn get_address_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Address information")
.description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).")
.response_with::<400, (), _>(|res|
res.description("Invalid address format or unsupported address type")
)
.response_with::<404, (), _>(|res|
res.description("Address not found in the blockchain (no transaction history)")
)
}
pub trait AddressesRoutes {
fn add_addresses_routes(self) -> Self;
}
impl AddressesRoutes for ApiRouter<AppState> {
fn add_addresses_routes(self) -> Self {
self.api_route(
"/api/chain/address/{address}",
get_with(get_address_info, get_address_info_docs),
)
}
}

View File

View File

@@ -1,363 +1,18 @@
use std::{
fs::File,
io::{Cursor, Read, Seek, SeekFrom},
str::FromStr,
};
use aide::axum::ApiRouter;
use aide::{
axum::{ApiRouter, IntoApiResponse, routing::get_with},
transform::TransformOperation,
};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use bitcoin::{
Address as BitcoinAddress, Network, Transaction as BitcoinTransaction, consensus::Decodable,
};
use brk_parser::XORIndex;
use brk_structs::{
AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats, TxIndex, Txid,
TxidPrefix, TypeIndex,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::{AnyIterableVec, VecIterator};
use crate::api::chain::{addresses::AddressesRoutes, transactions::TransactionsRoutes};
use super::AppState;
pub trait ApiExplorerRoutes {
fn add_api_explorer_routes(self) -> Self;
mod addresses;
mod transactions;
pub trait ChainRoutes {
fn add_chain_routes(self) -> Self;
}
#[derive(Debug, Serialize, JsonSchema)]
struct AddressDetails {
/// Bitcoin address string
address: String,
r#type: OutputType,
type_index: TypeIndex,
/// Total satoshis ever sent from this address
total_sent: Sats,
/// Total satoshis ever received by this address
total_received: Sats,
/// Number of unspent transaction outputs (UTXOs)
utxo_count: u32,
/// Current spendable balance in satoshis (total_received - total_sent)
balance: Sats,
/// Current balance value in USD at current market price
balance_usd: Option<Dollars>,
/// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations
estimated_total_invested: Option<Dollars>,
/// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations
estimated_avg_entry_price: Option<Dollars>,
//
// Transaction count?
// First/last activity timestamps?
// Realized/unrealized gains?
// Current value (balance × current price)?
// "address": address,
// "type": output_type,
// "index": addri,
// "chain_stats": {
// "funded_txo_count": null,
// "funded_txo_sum": addr_data.received,
// "spent_txo_count": null,
// "spent_txo_sum": addr_data.sent,
// "utxo_count": addr_data.utxos,
// "balance": amount,
// "balance_usd": price.map_or(Value::new(), |p| {
// Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap())
// }),
// "realized_value": addr_data.realized_cap,
// "tx_count": null,
// "avg_cost_basis": addr_data.realized_price()
// },
// "mempool_stats": null
}
#[derive(Serialize)]
struct TxResponse {
txid: Txid,
index: TxIndex,
tx: BitcoinTransaction,
}
#[derive(Deserialize, JsonSchema)]
struct AddressPath {
/// Bitcoin address string
address: String,
}
#[derive(Deserialize, JsonSchema)]
struct TypeIndexPath {
/// Index of type
index: TypeIndex,
}
async fn get_address_details_from_address(
Path(AddressPath { address }): Path<AddressPath>,
state: State<AppState>,
) -> impl IntoApiResponse {
let Ok(address) = BitcoinAddress::from_str(&address) else {
return StatusCode::BAD_REQUEST.into_response();
};
if !address.is_valid_for_network(Network::Bitcoin) {
return StatusCode::BAD_REQUEST.into_response();
}
let address = address.assume_checked();
let interface = state.interface;
let indexer = interface.indexer();
let computer = interface.computer();
let stores = &indexer.stores;
let hash = AddressBytesHash::from(&address);
let r#type = OutputType::from(&address);
let Ok(Some(type_index)) = stores
.addressbyteshash_to_typeindex
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return StatusCode::NOT_FOUND.into_response();
};
let stateful = &computer.stateful;
let price = computer.price.as_ref().map(|v| {
*v.timeindexes_to_price_close
.dateindex
.as_ref()
.unwrap()
.iter()
.last()
.unwrap()
.1
.into_owned()
});
let any_address_index = match r#type {
OutputType::P2PK33 => stateful
.p2pk33addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PK65 => stateful
.p2pk65addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PKH => stateful
.p2pkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2SH => stateful
.p2shaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2TR => stateful
.p2traddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WPKH => stateful
.p2wpkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WSH => stateful
.p2wshaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2A => stateful
.p2aaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
_ => unreachable!(),
};
let address_data = match any_address_index.to_enum() {
AnyAddressDataIndexEnum::Loaded(index) => stateful
.loadedaddressindex_to_loadedaddressdata
.iter()
.unwrap_get_inner(index),
AnyAddressDataIndexEnum::Empty(index) => stateful
.emptyaddressindex_to_emptyaddressdata
.iter()
.unwrap_get_inner(index)
.into(),
};
let balance = address_data.balance();
Json(AddressDetails {
address: address.to_string(),
r#type,
type_index,
utxo_count: address_data.utxo_count,
total_sent: address_data.sent,
total_received: address_data.received,
balance,
balance_usd: price.map(|p| p * Bitcoin::from(balance)),
estimated_total_invested: price.map(|_| address_data.realized_cap),
estimated_avg_entry_price: price.map(|_| address_data.realized_price()),
})
.into_response()
}
fn get_address_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Get address information")
.description("Get Bitcoin address details")
.response_with::<200, Json<AddressDetails>, _>(|res| {
res.example(AddressDetails {
address: "bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38"
.to_string(),
r#type: OutputType::P2WSH,
type_index: TypeIndex::new(26158889),
total_sent: Sats::new(5498948012620),
total_received: Sats::new(5557954331207),
utxo_count: 195,
balance: Sats::new(59006318587),
balance_usd: Some(Dollars::mint(73757839.22)),
estimated_total_invested: Some(Dollars::mint(71943052.66)),
estimated_avg_entry_price: Some(Dollars::mint(121900.0)),
})
})
.response_with::<400, (), _>(|res| res.description("The address provided was invalid"))
.response_with::<404, (), _>(|res| res.description("The address provided was not found"))
}
impl ApiExplorerRoutes for ApiRouter<AppState> {
fn add_api_explorer_routes(self) -> Self {
self.api_route(
"/api/chain/address/{address}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2pk65/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2pk33/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2pkh/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2sh/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2wpkh/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2wsh/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2tr/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.api_route(
"/api/chain/address/p2a/{index}",
get_with(get_address_details_from_address, get_address_docs),
)
.route(
"/api/chain/tx/{txid}",
get(
async |Path(txid): Path<String>, state: State<AppState>| -> Response {
let Ok(txid) = bitcoin::Txid::from_str(&txid) else {
return "Invalid txid".into_response();
};
let txid = Txid::from(txid);
let prefix = TxidPrefix::from(&txid);
let interface = state.interface;
let indexer = interface.indexer();
let Ok(Some(txindex)) = indexer
.stores
.txidprefix_to_txindex
.get(&prefix)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return "Unknown transaction".into_response();
};
let txid = indexer
.vecs
.txindex_to_txid
.iter()
.unwrap_get_inner(txindex);
let parser = interface.parser();
let computer = interface.computer();
let position = computer
.blks
.txindex_to_position
.iter()
.unwrap_get_inner(txindex);
let len = indexer
.vecs
.txindex_to_total_size
.iter()
.unwrap_get_inner(txindex);
let blk_index_to_blk_path = parser.blk_index_to_blk_path();
let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else {
return "Unknown blk index".into_response();
};
let mut xori = XORIndex::default();
xori.add_assign(position.offset() as usize);
let Ok(mut file) = File::open(blk_path) else {
return "Error opening blk file".into_response();
};
if file
.seek(SeekFrom::Start(position.offset() as u64))
.is_err()
{
return "Error seeking position in blk file".into_response();
}
let mut buffer = vec![0u8; *len as usize];
if file.read_exact(&mut buffer).is_err() {
return "File fail read exact".into_response();
}
xori.bytes(&mut buffer, parser.xor_bytes());
let mut reader = Cursor::new(buffer);
let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else {
return "Error decoding transaction".into_response();
};
let response = TxResponse {
txid,
index: txindex,
tx,
};
let bytes = sonic_rs::to_vec(&response).unwrap();
Response::builder()
.header("content-type", "application/json")
.body(bytes.into())
.unwrap()
},
),
)
impl ChainRoutes for ApiRouter<AppState> {
fn add_chain_routes(self) -> Self {
self.add_addresses_routes().add_transactions_routes()
}
}

View File

@@ -0,0 +1,158 @@
use std::{
fs::File,
io::{Cursor, Read, Seek, SeekFrom},
str::FromStr,
};
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use bitcoin::{Transaction as BitcoinTransaction, consensus::Decodable};
use brk_parser::XORIndex;
use brk_structs::{TxIndex, Txid, TxidPrefix};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::VecIterator;
use super::AppState;
#[derive(Serialize, JsonSchema)]
/// Transaction Information
struct TransactionInfo {
#[schemars(
with = "String",
example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
)]
txid: Txid,
#[schemars(example = TxIndex::new(0))]
index: TxIndex,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
tx: BitcoinTransaction,
}
#[derive(Deserialize, JsonSchema)]
struct TxidPath {
/// Bitcoin transaction id
txid: String,
}
async fn get_transaction_info(
Path(TxidPath { txid }): Path<TxidPath>,
state: State<AppState>,
) -> Result<Response, StatusCode> {
let Ok(txid) = bitcoin::Txid::from_str(&txid) else {
return Err(StatusCode::BAD_REQUEST);
};
let txid = Txid::from(txid);
let prefix = TxidPrefix::from(&txid);
let interface = state.interface;
let indexer = interface.indexer();
let Ok(Some(index)) = indexer
.stores
.txidprefix_to_txindex
.get(&prefix)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err(StatusCode::NOT_FOUND);
};
let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index);
let parser = interface.parser();
let computer = interface.computer();
let position = computer
.blks
.txindex_to_position
.iter()
.unwrap_get_inner(index);
let len = indexer
.vecs
.txindex_to_total_size
.iter()
.unwrap_get_inner(index);
let blk_index_to_blk_path = parser.blk_index_to_blk_path();
let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let mut xori = XORIndex::default();
xori.add_assign(position.offset() as usize);
let Ok(mut file) = File::open(blk_path) else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
if file
.seek(SeekFrom::Start(position.offset() as u64))
.is_err()
{
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let mut buffer = vec![0u8; *len as usize];
if file.read_exact(&mut buffer).is_err() {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
xori.bytes(&mut buffer, parser.xor_bytes());
let mut reader = Cursor::new(buffer);
let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let tx_info = TransactionInfo { txid, index, tx };
let bytes = sonic_rs::to_vec(&tx_info).unwrap();
Ok(Response::builder()
.header("content-type", "application/json")
.body(bytes.into())
.unwrap())
}
fn get_transaction_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Transaction information")
.description(
"Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.",
)
.response_with::<200, Json<TransactionInfo>, _>(|res| res)
.response_with::<400, (), _>(|res| {
res.description(
"Invalid transaction ID format (must be a valid 64-character hex string)",
)
})
.response_with::<404, (), _>(|res| {
res.description("Transaction not found in the blockchain")
})
.response_with::<500, (), _>(|res| {
res.description(
"Internal server error while reading transaction data from blockchain files",
)
})
}
pub trait TransactionsRoutes {
fn add_transactions_routes(self) -> Self;
}
impl TransactionsRoutes for ApiRouter<AppState> {
fn add_transactions_routes(self) -> Self {
self.api_route(
"/api/chain/tx/{txid}",
get_with(get_transaction_info, get_transaction_info_docs),
)
}
}

View File

@@ -1,4 +1,4 @@
use aide::axum::ApiRouter;
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Json,
extract::{Path, Query, State},
@@ -6,7 +6,11 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
use brk_interface::{Index, PaginationParam, Params, ParamsDeprec, ParamsOpt};
use brk_interface::{
Index, Indexes, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt,
};
use schemars::JsonSchema;
use serde::Serialize;
use crate::{
VERSION,
@@ -23,23 +27,62 @@ pub trait ApiMetricsRoutes {
const TO_SEPARATOR: &str = "_to_";
#[derive(Debug, Serialize, JsonSchema)]
/// Metric count statistics - distinct metrics and total metric-index combinations
struct MetricCount {
#[schemars(example = 3141)]
/// Number of unique metrics available (e.g., realized_price, market_cap)
distinct_metrics: usize,
#[schemars(example = 21000)]
/// Total number of metric-index combinations across all timeframes
total_endpoints: usize,
}
impl ApiMetricsRoutes for ApiRouter<AppState> {
fn add_api_metrics_routes(self) -> Self {
self.route(
self.api_route(
"/api/metrics/count",
get(async |State(app_state): State<AppState>| -> Response {
Json(sonic_rs::json!({
"distinct": app_state.interface.distinct_metric_count(),
"total": app_state.interface.total_metric_count(),
}))
.into_response()
}),
get_with(
async |State(app_state): State<AppState>| -> Json<MetricCount> {
Json(MetricCount {
distinct_metrics: app_state.interface.distinct_metric_count(),
total_endpoints: app_state.interface.total_metric_count(),
})
},
|op| {
op.tag("Metrics")
.summary("Metric count")
.description("Current metric count")
},
),
)
.route(
.api_route(
"/api/metrics/indexes",
get(async |State(app_state): State<AppState>| -> Response {
Json(app_state.interface.get_indexes()).into_response()
}),
get_with(
async |State(app_state): State<AppState>| -> Json<&Indexes> {
Json(app_state.interface.get_indexes())
},
|op| {
op.tag("Metrics")
.summary("Metric indexes")
.description("Available metric indexes and their accepted variants")
},
),
)
.api_route(
"/api/metrics/list",
get_with(
async |State(app_state): State<AppState>,
Query(pagination): Query<PaginationParam>|
-> Json<PaginatedMetrics> {
Json(app_state.interface.get_metrics(pagination))
},
|op| {
op.tag("Metrics")
.summary("Metrics list")
.description("Paginated list of available metrics")
},
),
)
.route(
"/api/metrics/catalog",
@@ -65,16 +108,6 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
},
),
)
.route(
"/api/metrics/list",
get(
async |State(app_state): State<AppState>,
Query(pagination): Query<PaginationParam>|
-> Response {
Json(app_state.interface.get_metrics(pagination)).into_response()
},
),
)
// TODO:
// .route(
// "/api/metrics/search",

View File

@@ -1,7 +1,17 @@
use aide::axum::ApiRouter;
use axum::{response::Html, routing::get};
use std::sync::Arc;
use crate::api::{chain::ApiExplorerRoutes, metrics::ApiMetricsRoutes};
use aide::{
axum::{ApiRouter, routing::get_with},
openapi::{Info, OpenApi, Tag},
};
use axum::{Extension, Json, response::Html, routing::get};
use schemars::JsonSchema;
use serde::Serialize;
use crate::{
VERSION,
api::{chain::ChainRoutes, metrics::ApiMetricsRoutes},
};
use super::AppState;
@@ -12,10 +22,99 @@ pub trait ApiRoutes {
fn add_api_routes(self) -> Self;
}
#[derive(Debug, Serialize, JsonSchema)]
/// Server health status
struct Health {
status: String,
service: String,
timestamp: String,
}
impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_api_explorer_routes()
self.add_chain_routes()
.add_api_metrics_routes()
.api_route(
"/version",
get_with(
async || -> Json<&'static str> { Json(VERSION) },
|op| {
op.tag("Server")
.summary("API version")
.description("Returns the current version of the API server")
},
),
)
.api_route(
"/health",
get_with(
async || -> Json<Health> {
Json(Health {
status: "healthy".to_string(),
service: "brk-server".to_string(),
timestamp: jiff::Timestamp::now().to_string(),
})
},
|op| {
op.tag("Server")
.summary("Health check")
.description("Returns the health status of the API server")
},
),
)
.route(
"/api.json",
get(
async |Extension(api): Extension<Arc<OpenApi>>| -> Json<Arc<OpenApi>> {
Json(api)
},
),
)
.route("/api", get(Html::from(include_str!("./scalar.html"))))
}
}
pub fn create_openapi() -> OpenApi {
let tags = vec![
Tag {
name: "Chain".to_string(),
description: Some(
"Explore Bitcoin blockchain data: addresses, transactions, blocks, balances, and UTXOs."
.to_string()
),
..Default::default()
},
Tag {
name: "Metrics".to_string(),
description: Some(
"Access Bitcoin network metrics and time-series data. Query historical and real-time \
statistics across various blockchain dimensions and aggregation levels."
.to_string()
),
..Default::default()
},
Tag {
name: "Server".to_string(),
description: Some(
"Metadata and utility endpoints for API status, health checks, and system information."
.to_string()
),
..Default::default()
},
];
OpenApi {
info: Info {
title: "Bitcoin Research Kit API".to_string(),
description: Some(
"API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\
⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service."
.to_string(),
),
version: format!("v{VERSION}"),
..Info::default()
},
tags,
..OpenApi::default()
}
}

View File

@@ -1,7 +1,11 @@
<!doctype html>
<html>
<head>
<title>Scalar API Reference</title>
<title>BRK API</title>
<meta
name="description"
content="Bitcoin Research Kit's API documentation via Scalar"
/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
@@ -15,11 +19,9 @@
<script>
Scalar.createApiReference("#app", {
url: "/api.json",
metaData: {
title: "BRK API",
description: "Bitcoin Research Kit's API documentation via Scalar",
hideClientButton: true,
},
hideClientButton: true,
telemetry: false,
// showToolbar: "never",
});
</script>
</body>