mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-27 18:58:10 -07:00
server: snapshot
This commit is contained in:
@@ -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
@@ -1,238 +1,39 @@
|
||||
# brk_server
|
||||
|
||||
HTTP server providing REST API access to Bitcoin analytics data
|
||||
HTTP server for the Bitcoin Research Kit.
|
||||
|
||||
[](https://crates.io/crates/brk_server)
|
||||
[](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<()>
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
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, ¶ms.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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,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()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.cache_control);
|
||||
if let Some(etag) = ¶ms.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(¶ms.cache_control);
|
||||
if let Some(etag) = ¶ms.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(¶ms.cache_control);
|
||||
if let Some(etag) = ¶ms.etag {
|
||||
headers.insert_etag(etag);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms),
|
||||
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(), ¶ms),
|
||||
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(), ¶ms),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user