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
Generated
+1
View File
@@ -1168,6 +1168,7 @@ dependencies = [
"quick_cache",
"schemars 1.0.4",
"serde",
"serde_json",
"sonic-rs 0.5.5",
"tokio",
"tower-http",
+16 -3
View File
@@ -1,4 +1,7 @@
use std::fmt::{self, Debug};
use std::{
collections::BTreeMap,
fmt::{self, Debug},
};
use brk_error::Error;
use brk_structs::{
@@ -9,9 +12,19 @@ use brk_structs::{
QuarterIndex, SemesterIndex, TxIndex, UnknownOutputIndex, WeekIndex, YearIndex,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
#[derive(Default, Serialize, JsonSchema)]
/// Indexes and their accepted variants
pub struct Indexes(BTreeMap<Index, &'static [&'static str]>);
impl Indexes {
pub fn new(tree: BTreeMap<Index, &'static [&'static str]>) -> Self {
Self(tree)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Index {
#[schemars(description = "Date/day index")]
+4 -3
View File
@@ -25,10 +25,11 @@ mod params;
mod vecs;
pub use format::Format;
pub use index::Index;
pub use index::*;
pub use output::{Output, Value};
pub use pagination::{PaginatedIndexParam, PaginationParam};
pub use params::{Params, ParamsDeprec, ParamsOpt};
pub use vecs::PaginatedMetrics;
use vecs::Vecs;
use crate::vecs::{IndexToVec, MetricToVec};
@@ -236,11 +237,11 @@ impl<'a> Interface<'a> {
self.vecs.total_metric_count
}
pub fn get_indexes(&self) -> &BTreeMap<&'static str, &'static [&'static str]> {
pub fn get_indexes(&self) -> &Indexes {
&self.vecs.indexes
}
pub fn get_metrics(&self, pagination: PaginationParam) -> &[&str] {
pub fn get_metrics(&'static self, pagination: PaginationParam) -> PaginatedMetrics {
self.vecs.metrics(pagination)
}
+3 -3
View File
@@ -1,9 +1,9 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::{Index, deser::de_unquote_usize};
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
pub struct PaginationParam {
#[schemars(description = "Pagination index")]
#[serde(default, alias = "p", deserialize_with = "de_unquote_usize")]
@@ -11,7 +11,7 @@ pub struct PaginationParam {
}
impl PaginationParam {
const PER_PAGE: usize = 1_000;
pub const PER_PAGE: usize = 1_000;
pub fn start(&self, len: usize) -> usize {
(self.page.unwrap_or_default() * Self::PER_PAGE).clamp(0, len)
+34 -9
View File
@@ -4,9 +4,14 @@ use brk_computer::Computer;
use brk_indexer::Indexer;
use brk_traversable::{Traversable, TreeNode};
use derive_deref::{Deref, DerefMut};
use schemars::JsonSchema;
use serde::Serialize;
use vecdb::AnyCollectableVec;
use crate::pagination::{PaginatedIndexParam, PaginationParam};
use crate::{
index::Indexes,
pagination::{PaginatedIndexParam, PaginationParam},
};
use super::index::Index;
@@ -15,7 +20,7 @@ pub struct Vecs<'a> {
pub metric_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
pub index_to_metric_to_vec: BTreeMap<Index, MetricToVec<'a>>,
pub metrics: Vec<&'a str>,
pub indexes: BTreeMap<&'static str, &'static [&'static str]>,
pub indexes: Indexes,
pub distinct_metric_count: usize,
pub total_metric_count: usize,
pub catalog: Option<TreeNode>,
@@ -62,11 +67,12 @@ impl<'a> Vecs<'a> {
.values()
.map(|tree| tree.len())
.sum::<usize>();
this.indexes = this
.index_to_metric_to_vec
.keys()
.map(|i| (i.serialize_long(), i.possible_values()))
.collect::<BTreeMap<_, _>>();
this.indexes = Indexes::new(
this.index_to_metric_to_vec
.keys()
.map(|i| (*i, i.possible_values()))
.collect::<BTreeMap<_, _>>(),
);
this.metric_to_indexes = this
.metric_to_index_to_vec
.iter()
@@ -129,11 +135,16 @@ impl<'a> Vecs<'a> {
}
}
pub fn metrics(&self, pagination: PaginationParam) -> &[&'_ str] {
pub fn metrics(&'static self, pagination: PaginationParam) -> PaginatedMetrics {
let len = self.metrics.len();
let start = pagination.start(len);
let end = pagination.end(len);
&self.metrics[start..end]
PaginatedMetrics {
current_page: pagination.page.unwrap_or_default(),
total_pages: len / PaginationParam::PER_PAGE,
metrics: &self.metrics[start..end],
}
}
pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec<&'static str>> {
@@ -160,3 +171,17 @@ pub struct IndexToVec<'a>(BTreeMap<Index, &'a dyn AnyCollectableVec>);
#[derive(Default, Deref, DerefMut)]
pub struct MetricToVec<'a>(BTreeMap<&'a str, &'a dyn AnyCollectableVec>);
/// A paginated list of available metric names (1000 per page)
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaginatedMetrics {
/// Current page number (0-indexed)
#[schemars(example = 0)]
current_page: usize,
/// Total number of pages available
#[schemars(example = 21000)]
total_pages: usize,
/// List of metric names (max 1000 per page)
#[schemars(example = ["price_open", "price_close", "realized_price", "..."])]
metrics: &'static [&'static str],
}
+2 -2
View File
@@ -10,7 +10,7 @@ rust-version.workspace = true
build = "build.rs"
[dependencies]
aide = { workspace = true }
aide = { workspace = true , features = ["axum-json", "axum-query"] }
axum = { workspace = true }
bitcoin = { workspace = true }
bitcoincore-rpc = { workspace = true }
@@ -29,7 +29,7 @@ log = { workspace = true }
quick_cache = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
# serde_json = { workspace = true }
serde_json = { workspace = true }
sonic-rs = { workspace = true }
tokio = { workspace = true }
tower-http = { version = "0.6.6", features = ["compression-full", "trace"] }
@@ -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),
)
}
}
+10 -355
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()
}
}
@@ -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),
)
}
}
+57 -24
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",
+103 -4
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()
}
}
+8 -6
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>
+7 -34
View File
@@ -5,13 +5,10 @@
use std::{path::PathBuf, sync::Arc, time::Duration};
use aide::{
axum::{ApiRouter, IntoApiResponse},
openapi::{Info, OpenApi},
};
use aide::axum::ApiRouter;
use api::ApiRoutes;
use axum::{
Extension, Json,
Extension,
body::{Body, Bytes},
http::{Request, Response, StatusCode, Uri},
middleware::Next,
@@ -36,6 +33,8 @@ mod files;
use extended::*;
use crate::api::create_openapi;
#[derive(Clone)]
pub struct AppState {
interface: &'static Interface<'static>,
@@ -102,15 +101,6 @@ impl Server {
.add_api_routes()
.add_files_routes(state.path.as_ref())
.add_mcp_routes(state.interface, mcp)
.api_route("/version", aide::axum::routing::get(version))
.route(
"/health",
get(Json(sonic_rs::json!({
"status": "healthy",
"service": "brk-server",
"timestamp": jiff::Timestamp::now().to_string()
}))),
)
.route(
"/discord",
get(Redirect::temporary("https://discord.gg/WACpShCB7M")),
@@ -130,7 +120,6 @@ impl Server {
get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#hosting-as-a-service")),
)
.route("/nostr", get(Redirect::temporary("https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44")))
.route("/api.json", get(serve_api))
.with_state(state)
.layer(compression_layer)
.layer(response_uri_layer)
@@ -147,24 +136,16 @@ impl Server {
port += 1;
}
let mut api = OpenApi {
info: Info {
title: "Bitcoin Research Kit API".to_string(),
description: Some("A documentation for using BRK's API".to_string()),
..Info::default()
},
..OpenApi::default()
};
info!("Starting server on port {port}...");
let listener = listener.unwrap();
let mut openapi = create_openapi();
serve(
listener,
router
.finish_api(&mut api)
.layer(Extension(Arc::new(api)))
.finish_api(&mut openapi)
.layer(Extension(Arc::new(openapi)))
.into_make_service(),
)
.await?;
@@ -172,11 +153,3 @@ impl Server {
Ok(())
}
}
async fn serve_api(Extension(api): Extension<Arc<OpenApi>>) -> impl IntoApiResponse {
Json(api)
}
async fn version() -> impl IntoApiResponse {
Json(VERSION)
}
@@ -55,12 +55,6 @@ impl AddressBytes {
}
}
impl From<&Address> for AddressBytes {
fn from(value: &Address) -> Self {
Self::try_from((&value.script_pubkey(), OutputType::from(value))).unwrap()
}
}
impl TryFrom<(&ScriptBuf, OutputType)> for AddressBytes {
type Error = Error;
fn try_from(tuple: (&ScriptBuf, OutputType)) -> Result<Self, Self::Error> {
@@ -1,4 +1,3 @@
use bitcoin::Address;
use byteview::ByteView;
use derive_deref::Deref;
use zerocopy::{FromBytes, IntoBytes};
@@ -22,12 +21,6 @@ use super::{AddressBytes, OutputType};
)]
pub struct AddressBytesHash([u8; 8]);
impl From<&Address> for AddressBytesHash {
fn from(value: &Address) -> Self {
Self::from((&AddressBytes::from(value), OutputType::from(value)))
}
}
impl From<(&AddressBytes, OutputType)> for AddressBytesHash {
fn from((address_bytes, outputtype): (&AddressBytes, OutputType)) -> Self {
let mut slice = rapidhash::v3::rapidhash_v3(address_bytes.as_slice()).to_le_bytes();
+2 -8
View File
@@ -1,7 +1,7 @@
use bitcoin::{Address, AddressType, ScriptBuf, opcodes::all::OP_PUSHBYTES_2};
use bitcoin::{AddressType, ScriptBuf, opcodes::all::OP_PUSHBYTES_2};
use brk_error::Error;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use strum::Display;
use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout};
@@ -632,12 +632,6 @@ impl From<&ScriptBuf> for OutputType {
}
}
impl From<&Address> for OutputType {
fn from(value: &Address) -> Self {
Self::from(&value.script_pubkey())
}
}
impl From<AddressType> for OutputType {
fn from(value: AddressType) -> Self {
match value {
+4 -1
View File
@@ -2,10 +2,13 @@ use std::{fmt, mem};
use bitcoin::hashes::Hash;
use derive_deref::Deref;
use schemars::JsonSchema;
use serde::{Serialize, Serializer};
use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout};
#[derive(Debug, Deref, Clone, PartialEq, Eq, Immutable, IntoBytes, KnownLayout, FromBytes)]
#[derive(
Debug, Deref, Clone, PartialEq, Eq, Immutable, IntoBytes, KnownLayout, FromBytes, JsonSchema,
)]
pub struct Txid([u8; 32]);
impl From<bitcoin::Txid> for Txid {
@@ -3,6 +3,7 @@ use std::ops::{Add, AddAssign};
use allocative::Allocative;
use byteview::ByteView;
use derive_deref::{Deref, DerefMut};
use schemars::JsonSchema;
use serde::Serialize;
use vecdb::{CheckedSub, PrintableIndex, StoredCompressed};
use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout};
@@ -29,6 +30,7 @@ use super::StoredU32;
Serialize,
StoredCompressed,
Allocative,
JsonSchema,
)]
pub struct TxIndex(u32);