server: snapshot

This commit is contained in:
nym21
2025-12-15 16:32:45 +01:00
parent 882a3525af
commit 825a4a77c0
100 changed files with 2677 additions and 3438 deletions
+3 -1
View File
@@ -15,7 +15,6 @@ brk_computer = { workspace = true }
brk_error = { workspace = true }
brk_fetcher = { workspace = true }
brk_indexer = { workspace = true }
brk_iterator = { workspace = true }
brk_logger = { workspace = true }
brk_mcp = { workspace = true }
brk_query = { workspace = true }
@@ -33,3 +32,6 @@ serde_json = { workspace = true }
tokio = { workspace = true }
tower-http = { version = "0.6.8", features = ["compression-full", "trace"] }
tracing = "0.1.43"
[dev-dependencies]
brk_mempool = { workspace = true }
+19 -218
View File
@@ -1,238 +1,39 @@
# brk_server
HTTP server providing REST API access to Bitcoin analytics data
HTTP server for the Bitcoin Research Kit.
[![Crates.io](https://img.shields.io/crates/v/brk_server.svg)](https://crates.io/crates/brk_server)
[![Documentation](https://docs.rs/brk_server/badge.svg)](https://docs.rs/brk_server)
## Overview
This crate provides a high-performance HTTP server built on `axum` that exposes Bitcoin blockchain analytics data through a comprehensive REST API. It integrates with the entire BRK ecosystem, serving data from indexers, computers, and parsers with intelligent caching, compression, and multiple output formats.
This crate provides an HTTP server that exposes BRK's blockchain data through a REST API. It serves as the web interface layer for the Bitcoin Research Kit, making data accessible to applications, dashboards, and research tools.
**Key Features:**
Built on `axum` with automatic OpenAPI documentation via Scalar.
- RESTful API for blockchain data queries with flexible filtering
- Multiple output formats: JSON, CSV
- Intelligent caching system with ETags and conditional requests
- HTTP compression (Gzip, Brotli, Deflate, Zstd) for bandwidth efficiency
- Static file serving for web interfaces and documentation
- Bitcoin address and transaction lookup endpoints
- Vector database query interface with pagination
- Health monitoring and status endpoints
**Target Use Cases:**
- Bitcoin data APIs for applications and research
- Web-based blockchain explorers and analytics dashboards
- Research data export and analysis tools
- Integration with external systems requiring Bitcoin data
## Installation
```bash
cargo add brk_server
```
## Quick Start
## Usage
```rust
use brk_server::Server;
use brk_query::Interface;
use std::path::PathBuf;
use brk_query::AsyncQuery;
// Initialize interface with your data sources
let interface = Interface::new(/* your config */);
let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool));
let server = Server::new(&query, None);
// Optional static file serving directory
let files_path = Some(PathBuf::from("./web"));
// Create and start server
let server = Server::new(interface, files_path);
// Start server with optional MCP (Model Context Protocol) support
// Starts on port 3110 (or next available)
server.serve(true).await?;
```
## API Overview
Once running:
- **API Documentation**: `http://localhost:3110/api`
- **OpenAPI Spec**: `http://localhost:3110/api.json`
### Core Endpoints
## Features
**Blockchain Queries:**
- `GET /api/address/{address}` - Address information, balance, transaction counts
- `GET /api/tx/{txid}` - Transaction details including version, locktime
- `GET /api/vecs/{variant}` - Vector database queries with filtering
**System Information:**
- `GET /api/vecs/index-count` - Total number of indexes available
- `GET /api/vecs/id-count` - Vector ID statistics
- `GET /api/vecs/indexes` - List of available data indexes
- `GET /health` - Service health status
- `GET /version` - Server version information
### Vector Database API
**Query Interface:**
- `GET /api/vecs/query` - Generic vector query with parameters
- `GET /api/vecs/{variant}?from={start}&to={end}&format={format}` - Range queries
**Supported Parameters:**
- `from` / `to`: Range filtering (height, timestamp, date-based)
- `format`: Output format (json, csv)
- Pagination parameters for large datasets
### Address API Response Format
```json
{
"address": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2",
"type": "p2pkh",
"index": 12345,
"chain_stats": {
"funded_txo_sum": 500000000,
"spent_txo_sum": 400000000,
"utxo_count": 5,
"balance": 100000000,
"balance_usd": 4200.5,
"realized_value": 450000000,
"avg_cost_basis": 45000.0
}
}
```
## Examples
### Basic Server Setup
```rust
use brk_server::Server;
use brk_query::Interface;
// Initialize with BRK interface
let interface = Interface::builder()
.with_indexer_path("./data/indexer")
.with_computer_path("./data/computer")
.build()?;
let server = Server::new(interface, None);
// Server automatically finds available port starting from 3110
server.serve(false).await?;
```
### Address Balance Lookup
```bash
# Get address information
curl http://localhost:3110/api/address/1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2
# Response includes balance, transaction counts, USD value
{
"address": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2",
"type": "p2pkh",
"chain_stats": {
"balance": 100000000,
"balance_usd": 4200.50,
"utxo_count": 5
}
}
```
### Data Export Queries
```bash
# Export height-to-price data as CSV
curl "http://localhost:3110/api/vecs/height-to-price?from=800000&to=800100&format=csv" \
-H "Accept-Encoding: gzip"
# Query with caching - subsequent requests return 304 Not Modified
curl "http://localhost:3110/api/vecs/dateindex-to-price-ohlc?from=0&to=1000" \
-H "If-None-Match: \"etag-hash\""
```
### Transaction Details
```bash
# Get transaction information
curl http://localhost:3110/api/tx/abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
# Response includes version, locktime, and internal indexing
{
"txid": "abcdef...",
"index": 98765,
"version": 2,
"locktime": 0
}
```
## Architecture
### Server Stack
- **HTTP Framework**: `axum` with async/await for high concurrency
- **Compression**: Multi-algorithm support (Gzip, Brotli, Deflate, Zstd)
- **Caching**: `quick_cache` with LRU eviction and ETag validation
- **Tracing**: Request/response logging with latency tracking
- **Static Files**: Optional web interface serving
### Caching Strategy
The server implements intelligent caching:
- **ETags**: Generated from data version and query parameters
- **Conditional Requests**: 304 Not Modified responses for unchanged data
- **Memory Cache**: LRU cache with configurable capacity (5,000 entries)
- **Cache Control**: `must-revalidate` headers for data consistency
### Request Processing
1. **Route Matching**: Path-based routing to appropriate handlers
2. **Parameter Validation**: Query parameter parsing and validation
3. **Data Retrieval**: Interface calls to indexer/computer components
4. **Caching Logic**: ETag generation and cache lookup
5. **Format Conversion**: JSON/CSV output formatting
6. **Compression**: Response compression based on Accept-Encoding
7. **Response**: HTTP response with appropriate headers
### Static File Serving
Optional static file serving supports:
- Web interface hosting for blockchain explorers
- Documentation and API reference serving
- Asset serving (CSS, JS, images) with proper MIME types
- Directory browsing with index.html fallback
## Configuration
### Environment Variables
The server automatically configures itself but respects:
- Port selection: Starts at 3110, increments if unavailable
- Compression: Enabled by default for all supported algorithms
- CORS: Permissive headers for cross-origin requests
### Memory Management
- Cache size: 5,000 entries by default
- Request weight limits: 65MB maximum per query
- Timeout handling: 50ms cache guard timeout
- Compression: Adaptive based on content type and size
## Code Analysis Summary
**Main Components**: `Server` struct with `AppState` containing interface, cache, and file paths \
**HTTP Framework**: Built on `axum` with middleware for compression, tracing, and CORS \
**API Routes**: Address lookup, transaction details, vector queries, and system information \
**Caching Layer**: `quick_cache` integration with ETag-based conditional requests \
**Data Integration**: Direct interface to BRK indexer, computer, parser, and fetcher components \
**Static Serving**: Optional file serving for web interfaces and documentation \
**Architecture**: Async HTTP server with intelligent caching and multi-format data export capabilities
---
_This README was generated by Claude Code_
- **REST API** for addresses, blocks, transactions, mempool, mining stats, and metrics
- **OpenAPI documentation** with interactive Scalar UI
- **Multiple formats**: JSON and CSV output
- **HTTP caching**: ETag-based conditional requests
- **Compression**: Gzip, Brotli, Deflate, Zstd
- **MCP support**: Model Context Protocol for AI integrations
- **Static file serving**: Optional web interface hosting
@@ -1,15 +1,10 @@
use std::{
path::Path,
thread::{self, sleep},
time::Duration,
};
use std::{path::Path, thread};
use brk_computer::Computer;
use brk_error::Result;
use brk_fetcher::Fetcher;
use brk_indexer::Indexer;
use brk_iterator::Blocks;
use brk_mempool::Mempool;
use brk_query::AsyncQuery;
use brk_reader::Reader;
use brk_rpc::{Auth, Client};
@@ -30,10 +25,7 @@ fn run() -> Result<()> {
brk_logger::init(Some(Path::new(".log")))?;
let bitcoin_dir = Client::default_bitcoin_path();
// let bitcoin_dir = Path::new("/Volumes/WD_BLACK1/bitcoin");
let outputs_dir = Path::new(&std::env::var("HOME").unwrap()).join(".brk");
// let outputs_dir = Path::new("../../_outputs");
let client = Client::new(
Client::default_url(),
@@ -41,51 +33,45 @@ fn run() -> Result<()> {
)?;
let reader = Reader::new(bitcoin_dir.join("blocks"), &client);
let blocks = Blocks::new(&client, &reader);
let mut indexer = Indexer::forced_import(&outputs_dir)?;
let indexer = Indexer::forced_import(&outputs_dir)?;
let fetcher = Some(Fetcher::import(true, None)?);
let computer = Computer::forced_import(&outputs_dir, &indexer, fetcher)?;
let mut computer = Computer::forced_import(&outputs_dir, &indexer, fetcher)?;
let mempool = Mempool::new(&client);
let mempool_clone = mempool.clone();
thread::spawn(move || {
mempool_clone.start();
});
let exit = Exit::new();
exit.set_ctrlc_handler();
let query = AsyncQuery::build(&reader, &indexer, &computer, None);
let future = async move {
let server = Server::new(&query, None);
tokio::spawn(async move {
server.serve(true).await.unwrap();
});
Ok(()) as Result<()>
};
let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool));
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let _handle = runtime.spawn(future);
// Option 1: block_on to run and properly propagate errors
runtime.block_on(async move {
let server = Server::new(&query, None);
loop {
client.wait_for_synced_node()?;
let handle = tokio::spawn(async move { server.serve(true).await });
let last_height = client.get_last_height()?;
info!("{} blocks found.", u32::from(last_height) + 1);
let starting_indexes = indexer.checked_index(&blocks, &client, &exit)?;
computer.compute(&indexer, starting_indexes, &reader, &exit)?;
info!("Waiting for new blocks...");
while last_height == client.get_last_height()? {
sleep(Duration::from_secs(1))
// Await the handle to catch both panics and errors
match handle.await {
Ok(Ok(())) => info!("Server shut down cleanly"),
Ok(Err(e)) => log::error!("Server error: {e:?}"),
Err(e) => {
// JoinError - either panic or cancellation
if e.is_panic() {
log::error!("Server panicked: {:?}", e.into_panic());
} else {
log::error!("Server task cancelled");
}
}
}
}
Ok(()) as Result<()>
})
}
+9 -37
View File
@@ -2,16 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
response::{Redirect, Response},
response::Redirect,
routing::get,
};
use brk_query::validate_address;
use brk_types::{Address, AddressStats, AddressTxidsParam, AddressValidation, Txid, Utxo};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -31,11 +27,7 @@ impl AddressRoutes for ApiRouter<AppState> {
Path(address): Path<Address>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_address(address).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address(address)).await
}, |op| op
.addresses_tag()
.summary("Address information")
@@ -55,11 +47,7 @@ impl AddressRoutes for ApiRouter<AppState> {
Query(params): Query<AddressTxidsParam>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_address_txids(address, params.after_txid, params.limit).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(address, params.after_txid, params.limit)).await
}, |op| op
.addresses_tag()
.summary("Address transaction IDs")
@@ -78,11 +66,7 @@ impl AddressRoutes for ApiRouter<AppState> {
Path(address): Path<Address>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_address_utxos(address).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_utxos(address)).await
}, |op| op
.addresses_tag()
.summary("Address UTXOs")
@@ -101,17 +85,13 @@ impl AddressRoutes for ApiRouter<AppState> {
Path(address): Path<Address>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_address_mempool_txids(address).await.to_json_response(&etag)
// Mempool txs for an address - use MaxAge since it's volatile
state.cached_json(&headers, CacheStrategy::MaxAge(5), move |q| q.address_mempool_txids(address)).await
}, |op| op
.addresses_tag()
.summary("Address mempool transactions")
.description("Get unconfirmed transaction IDs for an address from the mempool (up to 50).")
.ok_response::<Vec<Txid>>()
.not_modified()
.bad_request()
.not_found()
.server_error()
@@ -125,11 +105,7 @@ impl AddressRoutes for ApiRouter<AppState> {
Query(params): Query<AddressTxidsParam>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_address_txids(address, params.after_txid, 25).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(address, params.after_txid, 25)).await
}, |op| op
.addresses_tag()
.summary("Address confirmed transactions")
@@ -148,11 +124,7 @@ impl AddressRoutes for ApiRouter<AppState> {
Path(address): Path<String>,
State(state): State<AppState>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
Response::new_json(validate_address(&address), etag)
state.cached_json(&headers, CacheStrategy::Static, move |_q| Ok(AddressValidation::from_address(&address))).await
}, |op| op
.addresses_tag()
.summary("Validate address")
+12 -59
View File
@@ -2,7 +2,7 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
response::{Redirect, Response},
response::Redirect,
routing::get,
};
use brk_query::BLOCK_TXS_PAGE_SIZE;
@@ -11,10 +11,7 @@ use brk_types::{
BlockTimestamp, Height, HeightPath, StartHeightPath, TimestampPath, Transaction, Txid,
};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -35,11 +32,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_block(path.hash).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block(&path.hash)).await
},
|op| {
op.blocks_tag()
@@ -61,14 +54,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_status(path.hash)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_status(&path.hash)).await
},
|op| {
op.blocks_tag()
@@ -90,14 +76,8 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<HeightPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_by_height(Height::from(path.height))
.await
.to_json_response(&etag)
let height = Height::from(path.height);
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_height(height)).await
},
|op| {
op.blocks_tag()
@@ -119,12 +99,8 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<StartHeightPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
let start_height = path.start_height.map(Height::from);
state.get_blocks(start_height).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.blocks(start_height)).await
},
|op| {
op.blocks_tag()
@@ -145,11 +121,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_block_txids(path.hash).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_txids(&path.hash)).await
},
|op| {
op.blocks_tag()
@@ -171,11 +143,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashStartIndexPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_block_txs(path.hash, path.start_index).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_txs(&path.hash, path.start_index)).await
},
|op| {
op.blocks_tag()
@@ -198,11 +166,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashTxIndexPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_block_txid_at_index(path.hash, path.index).await.to_display_response(&etag)
state.cached_text(&headers, CacheStrategy::Height, move |q| q.block_txid_at_index(&path.hash, path.index).map(|t| t.to_string())).await
},
|op| {
op.blocks_tag()
@@ -224,14 +188,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<TimestampPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_by_timestamp(path.timestamp)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_timestamp(path.timestamp)).await
},
|op| {
op.blocks_tag()
@@ -251,11 +208,7 @@ impl BlockRoutes for ApiRouter<AppState> {
async |headers: HeaderMap,
Path(path): Path<BlockHashPath>,
State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_block_raw(path.hash).await.to_bytes_response(&etag)
state.cached_bytes(&headers, CacheStrategy::Height, move |q| q.block_raw(&path.hash)).await
},
|op| {
op.blocks_tag()
+6 -29
View File
@@ -2,15 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::State,
http::HeaderMap,
response::{Redirect, Response},
response::Redirect,
routing::get,
};
use brk_types::{MempoolBlock, MempoolInfo, RecommendedFees, Txid};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -26,18 +23,13 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/mempool/info",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_mempool_info().await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_info()).await
},
|op| {
op.mempool_tag()
.summary("Mempool statistics")
.description("Get current mempool statistics including transaction count, total vsize, and total fees.")
.ok_response::<MempoolInfo>()
.not_modified()
.server_error()
},
),
@@ -46,18 +38,13 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/mempool/txids",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_mempool_txids().await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_txids()).await
},
|op| {
op.mempool_tag()
.summary("Mempool transaction IDs")
.description("Get all transaction IDs currently in the mempool.")
.ok_response::<Vec<Txid>>()
.not_modified()
.server_error()
},
),
@@ -66,18 +53,13 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/v1/fees/recommended",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_recommended_fees().await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::MaxAge(3), |q| q.recommended_fees()).await
},
|op| {
op.mempool_tag()
.summary("Recommended fees")
.description("Get recommended fee rates for different confirmation targets based on current mempool state.")
.ok_response::<RecommendedFees>()
.not_modified()
.server_error()
},
),
@@ -86,18 +68,13 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/v1/fees/mempool-blocks",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_mempool_blocks().await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_blocks()).await
},
|op| {
op.mempool_tag()
.summary("Projected mempool blocks")
.description("Get projected blocks from the mempool for fee estimation. Each block contains statistics about transactions that would be included if a block were mined now.")
.ok_response::<Vec<MempoolBlock>>()
.not_modified()
.server_error()
},
),
+56 -85
View File
@@ -1,31 +1,29 @@
use std::time::Duration;
use axum::{
Json,
body::Body,
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_error::{Error, Result};
use brk_query::{Output, Params};
use brk_query::{MetricSelection, Output};
use brk_types::Format;
use quick_cache::sync::GuardResult;
use vecdb::Stamp;
use crate::{HeaderMapExtended, ResponseExtended};
use crate::{CacheStrategy, cache::CacheParams, extended::HeaderMapExtended};
use super::AppState;
/// Maximum allowed request weight in bytes (650KB)
const MAX_WEIGHT: usize = 65 * 10_000;
pub async fn handler(
uri: Uri,
headers: HeaderMap,
query: Query<Params>,
query: Query<MetricSelection>,
State(state): State<AppState>,
) -> Response {
match req_to_response_res(uri, headers, query, state) {
match req_to_response_res(uri, headers, query, state).await {
Ok(response) => response,
Err(error) => {
let mut response =
@@ -36,91 +34,64 @@ pub async fn handler(
}
}
fn req_to_response_res(
async fn req_to_response_res(
uri: Uri,
headers: HeaderMap,
Query(params): Query<Params>,
AppState {
query: interface,
cache,
..
}: AppState,
) -> Result<Response> {
todo!();
Query(params): Query<MetricSelection>,
AppState { query, cache, .. }: AppState,
) -> brk_error::Result<Response> {
let format = params.format();
let height = query.sync(|q| q.height());
let to = params.to();
// let vecs = interface.search(&params)?;
let cache_params = CacheParams::resolve(&CacheStrategy::height_with(format!("{to:?}")), || height.into());
// if vecs.is_empty() {
// return Ok(Json(vec![] as Vec<usize>).into_response());
// }
if cache_params.matches_etag(&headers) {
let mut response = (StatusCode::NOT_MODIFIED, "").into_response();
response.headers_mut().insert_cors();
return Ok(response);
}
// let from = params.from();
// let to = params.to();
// let format = params.format();
let cache_key = format!("{}{}{}", uri.path(), uri.query().unwrap_or(""), cache_params.etag_str());
let guard_res = cache.get_value_or_guard(&cache_key, Some(Duration::from_millis(50)));
// // TODO: From and to should be capped here
let mut response = if let GuardResult::Value(v) = guard_res {
Response::new(Body::from(v))
} else {
match query
.run(move |q| q.search_and_format_checked(params, MAX_WEIGHT))
.await?
{
Output::CSV(s) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(s.clone().into());
}
s.into_response()
}
Output::Json(v) => {
let json = v.to_vec();
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(json.clone().into());
}
json.into_response()
}
}
};
// let weight = vecs
// .iter()
// .map(|(_, v)| v.range_weight(from, to))
// .sum::<usize>();
let headers = response.headers_mut();
headers.insert_cors();
if let Some(etag) = &cache_params.etag {
headers.insert_etag(etag);
}
headers.insert_cache_control(&cache_params.cache_control);
// if weight > MAX_WEIGHT {
// return Err(Error::String(format!(
// "Request is too heavy, max weight is {MAX_WEIGHT} bytes"
// )));
// }
match format {
Format::CSV => {
headers.insert_content_disposition_attachment();
headers.insert_content_type_text_csv()
}
Format::JSON => headers.insert_content_type_application_json(),
}
// // TODO: height should be from vec, but good enough for now
// let etag = vecs
// .first()
// .unwrap()
// .1
// .etag(Stamp::from(interface.get_height()), to);
// if headers.has_etag(etag) {
// return Ok(Response::new_not_modified());
// }
// let guard_res = cache.get_value_or_guard(
// &format!("{}{}{etag}", uri.path(), uri.query().unwrap_or("")),
// Some(Duration::from_millis(50)),
// );
// let mut response = if let GuardResult::Value(v) = guard_res {
// Response::new(Body::from(v))
// } else {
// match interface.format(vecs, &params.rest)? {
// Output::CSV(s) => {
// if let GuardResult::Guard(g) = guard_res {
// let _ = g.insert(s.clone().into());
// }
// s.into_response()
// }
// Output::Json(v) => {
// let json = v.to_vec();
// if let GuardResult::Guard(g) = guard_res {
// let _ = g.insert(json.clone().into());
// }
// json.into_response()
// }
// }
// };
// let headers = response.headers_mut();
// headers.insert_cors();
// headers.insert_etag(&etag);
// headers.insert_cache_control_must_revalidate();
// match format {
// Format::CSV => {
// headers.insert_content_disposition_attachment();
// headers.insert_content_type_text_csv()
// }
// Format::JSON => headers.insert_content_type_application_json(),
// }
// Ok(response)
Ok(response)
}
+34 -61
View File
@@ -1,18 +1,15 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, Uri},
http::{HeaderMap, Uri},
response::{IntoResponse, Redirect, Response},
routing::get,
};
use brk_query::{PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt};
use brk_query::{DataRangeFormat, MetricSelection, MetricSelectionLegacy, PaginatedMetrics, Pagination};
use brk_traversable::TreeNode;
use brk_types::{Index, IndexInfo, Limit, Metric, MetricCount, Metrics};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -34,11 +31,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
Response::new_json(state.metric_count().await, etag)
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.metric_count())).await
},
|op| op
.metrics_tag()
@@ -55,11 +48,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
Response::new_json( state.get_indexes().await, etag)
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.get_indexes().to_vec())).await
},
|op| op
.metrics_tag()
@@ -77,13 +66,9 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |
headers: HeaderMap,
State(state): State<AppState>,
Query(pagination): Query<PaginationParam>
Query(pagination): Query<Pagination>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
Response::new_json(state.get_metrics(pagination).await, etag)
state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.get_metrics(pagination))).await
},
|op| op
.metrics_tag()
@@ -96,12 +81,8 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.api_route(
"/api/metrics/catalog",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| -> Response {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
Response::new_json(state.get_metrics_catalog().await, etag)
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.get_metrics_catalog().clone())).await
},
|op| op
.metrics_tag()
@@ -122,11 +103,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
Path(metric): Path<Metric>,
Query(limit): Query<Limit>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
state.match_metric(metric, limit).await.to_json_response(etag)
state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.match_metric(&metric, limit))).await
},
|op| op
.metrics_tag()
@@ -144,20 +121,18 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Path(metric): Path<Metric>
| {
let etag = VERSION;
if headers.has_etag(etag) {
return Response::new_not_modified();
}
if let Some(indexes) = state.metric_to_indexes(metric.clone()).await {
return Response::new_json(indexes, etag)
}
// REMOVE UNWRAP !!
let value = if let Some(first) = state.match_metric(metric.clone(), Limit::MIN).await.unwrap().first() {
format!("Could not find '{metric}', did you mean '{first}' ?")
} else {
format!("Could not find '{metric}'.")
};
Response::new_json_with(StatusCode::NOT_FOUND, value, etag)
state.cached_json(&headers, CacheStrategy::Static, move |q| {
if let Some(indexes) = q.metric_to_indexes(metric.clone()) {
return Ok(indexes.clone())
}
Err(brk_error::Error::String(
if let Some(first) = q.match_metric(&metric, Limit::MIN).first() {
format!("Could not find '{metric}', did you mean '{first}' ?")
} else {
format!("Could not find '{metric}'.")
}
))
}).await
},
|op| op
.metrics_tag()
@@ -173,7 +148,6 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
)
// WIP
.route("/api/metrics/bulk", get(data::handler))
// WIP
.route(
"/api/metric/{metric}/{index}",
get(
@@ -181,16 +155,15 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
headers: HeaderMap,
state: State<AppState>,
Path((metric, index)): Path<(Metric, Index)>,
Query(params_opt): Query<ParamsOpt>|
Query(range): Query<DataRangeFormat>|
-> Response {
todo!();
// data::handler(
// uri,
// headers,
// Query(Params::from(((index, metric), params_opt))),
// state,
// )
// .await
data::handler(
uri,
headers,
Query(MetricSelection::from((index, metric, range))),
state,
)
.await
},
),
)
@@ -202,7 +175,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
get(
async |uri: Uri,
headers: HeaderMap,
Query(params): Query<ParamsDeprec>,
Query(params): Query<MetricSelectionLegacy>,
state: State<AppState>|
-> Response {
data::handler(uri, headers, Query(params.into()), state).await
@@ -218,7 +191,7 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
Path(variant): Path<String>,
Query(params_opt): Query<ParamsOpt>,
Query(range): Query<DataRangeFormat>,
state: State<AppState>|
-> Response {
let separator = "_to_";
@@ -230,10 +203,10 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
return format!("Index {ser_index} doesn't exist").into_response();
};
let params = Params::from((
let params = MetricSelection::from((
index,
Metrics::from(split.collect::<Vec<_>>().join(separator)),
params_opt,
range,
));
data::handler(uri, headers, Query(params), state).await
},
+25 -115
View File
@@ -2,7 +2,7 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
response::{Redirect, Response},
response::Redirect,
routing::get,
};
use brk_types::{
@@ -11,10 +11,7 @@ use brk_types::{
PoolSlugPath, PoolsSummary, RewardStats, TimePeriodPath,
};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -32,14 +29,7 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/difficulty-adjustment",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_difficulty_adjustment()
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, |q| q.difficulty_adjustment()).await
},
|op| {
op.mining_tag()
@@ -55,11 +45,8 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/pools",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-pools");
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_all_pools().await.to_json_response(&etag)
// Pool list is static, only changes on code update
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.all_pools())).await
},
|op| {
op.mining_tag()
@@ -72,17 +59,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/pools/:time_period",
"/api/v1/mining/pools/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_mining_pools(path.time_period)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("{:?}", path.time_period)), move |q| q.mining_pools(path.time_period)).await
},
|op| {
op.mining_tag()
@@ -95,17 +75,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/pool/:slug",
"/api/v1/mining/pool/{slug}",
get_with(
async |headers: HeaderMap, Path(path): Path<PoolSlugPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-{}-{:?}", state.get_height().await, path.slug);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_pool_detail(path.slug)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(path.slug), move |q| q.pool_detail(path.slug)).await
},
|op| {
op.mining_tag()
@@ -122,14 +95,7 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/hashrate",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-hashrate-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_hashrate(None)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with("hashrate"), |q| q.hashrate(None)).await
},
|op| {
op.mining_tag()
@@ -142,17 +108,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/hashrate/:time_period",
"/api/v1/mining/hashrate/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-hashrate-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_hashrate(Some(path.time_period))
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("hashrate-{:?}", path.time_period)), move |q| q.hashrate(Some(path.time_period))).await
},
|op| {
op.mining_tag()
@@ -168,14 +127,7 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/difficulty-adjustments",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
let etag = format!("{VERSION}-diff-adj-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_difficulty_adjustments(None)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with("diff-adj"), |q| q.difficulty_adjustments(None)).await
},
|op| {
op.mining_tag()
@@ -188,17 +140,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/difficulty-adjustments/:time_period",
"/api/v1/mining/difficulty-adjustments/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-diff-adj-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_difficulty_adjustments(Some(path.time_period))
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("diff-adj-{:?}", path.time_period)), move |q| q.difficulty_adjustments(Some(path.time_period))).await
},
|op| {
op.mining_tag()
@@ -211,17 +156,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/blocks/fees/:time_period",
"/api/v1/mining/blocks/fees/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-fees-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_fees(path.time_period)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("fees-{:?}", path.time_period)), move |q| q.block_fees(path.time_period)).await
},
|op| {
op.mining_tag()
@@ -234,17 +172,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/blocks/rewards/:time_period",
"/api/v1/mining/blocks/rewards/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-rewards-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_rewards(path.time_period)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("rewards-{:?}", path.time_period)), move |q| q.block_rewards(path.time_period)).await
},
|op| {
op.mining_tag()
@@ -257,17 +188,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/blocks/fee-rates/:time_period",
"/api/v1/mining/blocks/fee-rates/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-feerates-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_fee_rates(path.time_period)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("feerates-{:?}", path.time_period)), move |q| q.block_fee_rates(path.time_period)).await
},
|op| {
op.mining_tag()
@@ -280,17 +204,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/blocks/sizes-weights/:time_period",
"/api/v1/mining/blocks/sizes-weights/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-sizes-{}-{:?}", state.get_height().await, path.time_period);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_block_sizes_weights(path.time_period)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("sizes-{:?}", path.time_period)), move |q| q.block_sizes_weights(path.time_period)).await
},
|op| {
op.mining_tag()
@@ -303,17 +220,10 @@ impl MiningRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/v1/mining/reward-stats/:block_count",
"/api/v1/mining/reward-stats/{block_count}",
get_with(
async |headers: HeaderMap, Path(path): Path<BlockCountPath>, State(state): State<AppState>| {
let etag = format!("{VERSION}-reward-stats-{}-{}", state.get_height().await, path.block_count);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state
.get_reward_stats(path.block_count)
.await
.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::height_with(format!("reward-stats-{}", path.block_count)), move |q| q.reward_stats(path.block_count)).await
},
|op| {
op.mining_tag()
+6 -2
View File
@@ -6,6 +6,7 @@ use aide::{
};
use axum::{
Extension, Json,
extract::State,
http::HeaderMap,
response::{Html, Redirect, Response},
routing::get,
@@ -13,7 +14,7 @@ use axum::{
use brk_types::Health;
use crate::{
VERSION,
CacheStrategy, VERSION,
api::{
addresses::AddressRoutes, blocks::BlockRoutes, mempool::MempoolRoutes,
metrics::ApiMetricsRoutes, mining::MiningRoutes, transactions::TxRoutes,
@@ -49,12 +50,15 @@ impl ApiRoutes for ApiRouter<AppState> {
.api_route(
"/version",
get_with(
async || -> Json<&'static str> { Json(VERSION) },
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, |_| Ok(env!("CARGO_PKG_VERSION"))).await
},
|op| {
op.server_tag()
.summary("API version")
.description("Returns the current version of the API server")
.ok_response::<String>()
.not_modified()
},
),
)
+21 -8
View File
@@ -29,7 +29,8 @@ pub fn create_openapi() -> OpenApi {
Tag {
name: "Addresses".to_string(),
description: Some(
"Explore Bitcoin addresses."
"Query Bitcoin address data including balances, transaction history, and UTXOs. \
Supports all address types: P2PKH, P2SH, P2WPKH, P2WSH, and P2TR."
.to_string()
),
..Default::default()
@@ -37,7 +38,17 @@ pub fn create_openapi() -> OpenApi {
Tag {
name: "Blocks".to_string(),
description: Some(
"Explore Bitcoin blocks."
"Retrieve block data by hash or height. Access block headers, transaction lists, \
and raw block bytes."
.to_string()
),
..Default::default()
},
Tag {
name: "Mempool".to_string(),
description: Some(
"Monitor unconfirmed transactions and fee estimates. Get mempool statistics, \
transaction IDs, and recommended fee rates for different confirmation targets."
.to_string()
),
..Default::default()
@@ -45,8 +56,8 @@ pub fn create_openapi() -> OpenApi {
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."
"Access Bitcoin network metrics and time-series data. Query historical statistics \
across various indexes (date, week, month, year, halving epoch) with JSON or CSV output."
.to_string()
),
..Default::default()
@@ -54,7 +65,8 @@ pub fn create_openapi() -> OpenApi {
Tag {
name: "Mining".to_string(),
description: Some(
"Explore mining related endpoints."
"Mining statistics including pool distribution, hashrate, difficulty adjustments, \
block rewards, and fee rates across configurable time periods."
.to_string()
),
..Default::default()
@@ -62,15 +74,16 @@ pub fn create_openapi() -> OpenApi {
Tag {
name: "Server".to_string(),
description: Some(
"Metadata and utility endpoints for API status, health checks, and system information."
"API metadata and health monitoring. Version information and service status."
.to_string()
),
..Default::default()
..Default::default()
},
Tag {
name: "Transactions".to_string(),
description: Some(
"Explore Bitcoin transactions."
"Retrieve transaction data by txid. Access full transaction details, confirmation \
status, raw hex, and output spend information."
.to_string()
),
..Default::default()
+7 -30
View File
@@ -2,15 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
response::{Redirect, Response},
response::Redirect,
routing::get,
};
use brk_types::{Transaction, TxOutspend, TxStatus, TxidPath, TxidVoutPath};
use crate::{
VERSION,
extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended},
};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use super::AppState;
@@ -31,11 +28,7 @@ impl TxRoutes for ApiRouter<AppState> {
Path(txid): Path<TxidPath>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_transaction(txid).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction(txid)).await
},
|op| op
.transactions_tag()
@@ -58,11 +51,7 @@ impl TxRoutes for ApiRouter<AppState> {
Path(txid): Path<TxidPath>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_transaction_status(txid).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction_status(txid)).await
},
|op| op
.transactions_tag()
@@ -85,11 +74,7 @@ impl TxRoutes for ApiRouter<AppState> {
Path(txid): Path<TxidPath>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_transaction_hex(txid).await.to_text_response(&etag)
state.cached_text(&headers, CacheStrategy::Height, move |q| q.transaction_hex(txid)).await
},
|op| op
.transactions_tag()
@@ -112,12 +97,8 @@ impl TxRoutes for ApiRouter<AppState> {
Path(path): Path<TxidVoutPath>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
let txid = TxidPath { txid: path.txid };
state.get_tx_outspend(txid, path.vout).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspend(txid, path.vout)).await
},
|op| op
.transactions_tag()
@@ -140,11 +121,7 @@ impl TxRoutes for ApiRouter<AppState> {
Path(txid): Path<TxidPath>,
State(state): State<AppState>
| {
let etag = format!("{VERSION}-{}", state.get_height().await);
if headers.has_etag(&etag) {
return Response::new_not_modified();
}
state.get_tx_outspends(txid).await.to_json_response(&etag)
state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspends(txid)).await
},
|op| op
.transactions_tag()
+108
View File
@@ -0,0 +1,108 @@
use axum::http::HeaderMap;
use crate::{VERSION, extended::HeaderMapExtended};
/// Minimum confirmations before data is considered immutable
pub const MIN_CONFIRMATIONS: u32 = 6;
/// Cache strategy for HTTP responses.
///
/// # Future optimization: Immutable caching for blocks/txs
///
/// The `Immutable` variant supports caching deeply-confirmed blocks/txs forever
/// (1 year, `immutable` directive). To use it, you need the confirmation count:
///
/// ```ignore
/// // Example: cache block by hash as immutable if deeply confirmed
/// let confirmations = current_height - block_height + 1;
/// let prefix = *BlockHashPrefix::from(&hash);
/// state.cached_json(&headers, CacheStrategy::immutable(prefix, confirmations), |q| q.block(&hash)).await
/// ```
///
/// Currently all block/tx handlers use `Height` for simplicity since determining
/// confirmations requires knowing the block height upfront (an extra lookup).
/// This could be optimized by either:
/// 1. Including confirmation count in the response type
/// 2. Doing a lightweight height lookup before the main query
pub enum CacheStrategy {
/// Immutable data (blocks by hash with 6+ confirmations)
/// Etag = VERSION-{prefix:x}, Cache-Control: immutable, 1yr
/// Falls back to Height if < 6 confirmations
Immutable { prefix: u64, confirmations: u32 },
/// Data that changes with each new block (addresses, block-by-height)
/// Etag = VERSION-{height}, Cache-Control: must-revalidate
Height,
/// Data that changes with height + depends on parameter
/// Etag = VERSION-{height}-{suffix}, Cache-Control: must-revalidate
HeightWith(String),
/// Static data (validate-address, metrics catalog)
/// Etag = VERSION only, Cache-Control: 1hr
Static,
/// Volatile data (mempool) - no etag, just max-age
/// Cache-Control: max-age={seconds}
MaxAge(u64),
}
impl CacheStrategy {
/// Create Immutable strategy - pass *prefix (deref BlockHashPrefix/TxidPrefix to u64)
pub fn immutable(prefix: u64, confirmations: u32) -> Self {
Self::Immutable {
prefix,
confirmations,
}
}
/// Create HeightWith from any Display type
pub fn height_with(suffix: impl std::fmt::Display) -> Self {
Self::HeightWith(suffix.to_string())
}
}
/// Resolved cache parameters
pub struct CacheParams {
pub etag: Option<String>,
pub cache_control: String,
}
impl CacheParams {
pub fn etag_str(&self) -> &str {
self.etag.as_deref().unwrap_or("")
}
pub fn matches_etag(&self, headers: &HeaderMap) -> bool {
self.etag.as_ref().is_some_and(|etag| headers.has_etag(etag))
}
pub fn resolve(strategy: &CacheStrategy, height: impl FnOnce() -> u32) -> Self {
use CacheStrategy::*;
match strategy {
Immutable {
prefix,
confirmations,
} if *confirmations >= MIN_CONFIRMATIONS => Self {
etag: Some(format!("{VERSION}-{prefix:x}")),
cache_control: "public, max-age=31536000, immutable".into(),
},
Immutable { .. } | Height => Self {
etag: Some(format!("{VERSION}-{}", height())),
cache_control: "public, max-age=1, must-revalidate".into(),
},
HeightWith(suffix) => Self {
etag: Some(format!("{VERSION}-{}-{suffix}", height())),
cache_control: "public, max-age=1, must-revalidate".into(),
},
Static => Self {
etag: Some(VERSION.to_string()),
cache_control: "public, max-age=1, must-revalidate".into(),
},
MaxAge(secs) => Self {
etag: None,
cache_control: format!("public, max-age={secs}"),
},
}
}
}
+7 -8
View File
@@ -27,6 +27,7 @@ pub trait HeaderMapExtended {
fn check_if_modified_since(&self, path: &Path) -> Result<(ModifiedState, DateTime)>;
fn check_if_modified_since_(&self, duration: Duration) -> Result<(ModifiedState, DateTime)>;
fn insert_cache_control(&mut self, value: &str);
fn insert_cache_control_must_revalidate(&mut self);
fn insert_cache_control_immutable(&mut self);
fn insert_etag(&mut self, etag: &str);
@@ -56,18 +57,16 @@ impl HeaderMapExtended for HeaderMap {
self.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, "*".parse().unwrap());
}
fn insert_cache_control(&mut self, value: &str) {
self.insert(header::CACHE_CONTROL, value.parse().unwrap());
}
fn insert_cache_control_must_revalidate(&mut self) {
self.insert(
header::CACHE_CONTROL,
"public, max-age=1, must-revalidate".parse().unwrap(),
);
self.insert_cache_control("public, max-age=1, must-revalidate");
}
fn insert_cache_control_immutable(&mut self) {
self.insert(
header::CACHE_CONTROL,
"public, max-age=31536000, immutable".parse().unwrap(),
);
self.insert_cache_control("public, max-age=31536000, immutable");
}
fn insert_content_disposition_attachment(&mut self) {
@@ -6,6 +6,7 @@ use axum::{
use serde::Serialize;
use super::header_map::HeaderMapExtended;
use crate::cache::CacheParams;
pub trait ResponseExtended
where
@@ -16,12 +17,17 @@ where
where
T: Serialize;
fn new_json_with<T>(status: StatusCode, value: T, etag: &str) -> Self
where
T: Serialize;
fn new_json_cached<T>(value: T, params: &CacheParams) -> Self
where
T: Serialize;
fn new_text(value: &str, etag: &str) -> Self;
fn new_text_with(status: StatusCode, value: &str, etag: &str) -> Self;
fn new_text_cached(value: &str, params: &CacheParams) -> Self;
fn new_bytes(value: Vec<u8>, etag: &str) -> Self;
fn new_bytes_with(status: StatusCode, value: Vec<u8>, etag: &str) -> Self;
fn new_bytes_cached(value: Vec<u8>, params: &CacheParams) -> Self;
}
impl ResponseExtended for Response<Body> {
@@ -85,4 +91,46 @@ impl ResponseExtended for Response<Body> {
headers.insert_etag(etag);
response
}
fn new_json_cached<T>(value: T, params: &CacheParams) -> Self
where
T: Serialize,
{
let bytes = serde_json::to_vec(&value).unwrap();
let mut response = Response::builder().body(bytes.into()).unwrap();
let headers = response.headers_mut();
headers.insert_cors();
headers.insert_content_type_application_json();
headers.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
response
}
fn new_text_cached(value: &str, params: &CacheParams) -> Self {
let mut response = Response::builder()
.body(value.to_string().into())
.unwrap();
let headers = response.headers_mut();
headers.insert_cors();
headers.insert_content_type_text_plain();
headers.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
response
}
fn new_bytes_cached(value: Vec<u8>, params: &CacheParams) -> Self {
let mut response = Response::builder().body(value.into()).unwrap();
let headers = response.headers_mut();
headers.insert_cors();
headers.insert_content_type_octet_stream();
headers.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
response
}
}
-15
View File
@@ -1,5 +1,3 @@
use std::fmt::Display;
use axum::{http::StatusCode, response::Response};
use brk_error::{Error, Result};
use serde::Serialize;
@@ -14,9 +12,6 @@ pub trait ResultExtended<T> {
fn to_text_response(self, etag: &str) -> Response
where
T: AsRef<str>;
fn to_display_response(self, etag: &str) -> Response
where
T: Display;
fn to_bytes_response(self, etag: &str) -> Response
where
T: Into<Vec<u8>>;
@@ -59,16 +54,6 @@ impl<T> ResultExtended<T> for Result<T> {
}
}
fn to_display_response(self, etag: &str) -> Response
where
T: Display,
{
match self.with_status() {
Ok(value) => Response::new_text(&value.to_string(), etag),
Err((status, message)) => Response::new_text_with(status, &message, etag),
}
}
fn to_bytes_response(self, etag: &str) -> Response
where
T: Into<Vec<u8>>,
+2 -2
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use aide::axum::ApiRouter;
use axum::routing::get;
use axum::{response::Redirect, routing::get};
use super::AppState;
@@ -19,7 +19,7 @@ impl FilesRoutes for ApiRouter<AppState> {
self.route("/{*path}", get(file_handler))
.route("/", get(index_handler))
} else {
self
self.route("/", get(Redirect::temporary("/api")))
}
}
}
+77 -11
View File
@@ -1,6 +1,6 @@
#![doc = include_str!("../README.md")]
#![doc = "\n## Example\n\n```rust"]
#![doc = include_str!("../examples/main.rs")]
#![doc = include_str!("../examples/server.rs")]
#![doc = "```"]
use std::{ops::Deref, path::PathBuf, sync::Arc, time::Duration};
@@ -28,10 +28,12 @@ use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use tracing::Span;
mod api;
pub mod cache;
mod extended;
mod files;
use api::*;
pub use cache::{CacheParams, CacheStrategy};
use extended::*;
#[derive(Clone)]
@@ -48,6 +50,71 @@ impl Deref for AppState {
}
}
impl AppState {
/// JSON response with caching
pub async fn cached_json<T, F>(
&self,
headers: &axum::http::HeaderMap,
strategy: CacheStrategy,
f: F,
) -> axum::http::Response<axum::body::Body>
where
T: serde::Serialize + Send + 'static,
F: FnOnce(&brk_query::Query) -> brk_error::Result<T> + Send + 'static,
{
let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into()));
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_json_cached(&value, &params),
Err(e) => ResultExtended::<T>::to_json_response(Err(e), params.etag_str()),
}
}
/// Text response with caching
pub async fn cached_text<T, F>(
&self,
headers: &axum::http::HeaderMap,
strategy: CacheStrategy,
f: F,
) -> axum::http::Response<axum::body::Body>
where
T: AsRef<str> + Send + 'static,
F: FnOnce(&brk_query::Query) -> brk_error::Result<T> + Send + 'static,
{
let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into()));
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_text_cached(value.as_ref(), &params),
Err(e) => ResultExtended::<T>::to_text_response(Err(e), params.etag_str()),
}
}
/// Binary response with caching
pub async fn cached_bytes<T, F>(
&self,
headers: &axum::http::HeaderMap,
strategy: CacheStrategy,
f: F,
) -> axum::http::Response<axum::body::Body>
where
T: Into<Vec<u8>> + Send + 'static,
F: FnOnce(&brk_query::Query) -> brk_error::Result<T> + Send + 'static,
{
let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into()));
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_bytes_cached(value.into(), &params),
Err(e) => ResultExtended::<T>::to_bytes_response(Err(e), params.etag_str()),
}
}
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Server(AppState);
@@ -132,21 +199,20 @@ impl Server {
.layer(response_uri_layer)
.layer(trace_layer);
let mut port = 3110;
const BASE_PORT: u16 = 3110;
const MAX_PORT: u16 = BASE_PORT + 100;
let mut listener;
loop {
listener = TcpListener::bind(format!("0.0.0.0:{port}")).await;
if listener.is_ok() {
break;
let mut port = BASE_PORT;
let listener = loop {
match TcpListener::bind(format!("0.0.0.0:{port}")).await {
Ok(l) => break l,
Err(_) if port < MAX_PORT => port += 1,
Err(e) => return Err(e.into()),
}
port += 1;
}
};
info!("Starting server on port {port}...");
let listener = listener.unwrap();
let mut openapi = create_openapi();
serve(
listener,