diff --git a/Cargo.lock b/Cargo.lock index a64f78084..0f2517f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,6 +1163,7 @@ dependencies = [ "brk_mcp", "brk_parser", "brk_structs", + "brk_traversable", "jiff", "log", "quick_cache", @@ -1231,6 +1232,7 @@ name = "brk_traversable" version = "0.0.111" dependencies = [ "brk_traversable_derive", + "schemars 1.0.4", "serde", "vecdb", ] diff --git a/Cargo.toml b/Cargo.toml index 4511dce24..8f439dc8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ panic = "abort" debug-assertions = false [workspace.dependencies] -aide = { version = "0.15.1", features = ["axum-json"], package = "brk-aide" } +aide = { version = "0.15.1", features = ["axum-json", "axum-query"], package = "brk-aide" } allocative = { version = "0.3.4", features = ["parking_lot"] } axum = "0.8.6" bitcoin = { version = "0.32.7", features = ["serde"] } diff --git a/crates/brk_binder/src/js.rs b/crates/brk_binder/src/js.rs index cbc12280b..6f4bb65f1 100644 --- a/crates/brk_binder/src/js.rs +++ b/crates/brk_binder/src/js.rs @@ -4,8 +4,8 @@ use std::{ path::Path, }; -use brk_interface::{Index, Interface}; -use brk_structs::pools; +use brk_interface::Interface; +use brk_structs::{Index, pools}; use super::VERSION; diff --git a/crates/brk_computer/src/grouped/from_dateindex.rs b/crates/brk_computer/src/grouped/from_dateindex.rs index e22253266..524568eec 100644 --- a/crates/brk_computer/src/grouped/from_dateindex.rs +++ b/crates/brk_computer/src/grouped/from_dateindex.rs @@ -150,22 +150,33 @@ where T: ComputedType, { fn to_tree_node(&self) -> brk_traversable::TreeNode { - brk_traversable::TreeNode::List( + let dateindex_extra_node = self.dateindex_extra.to_tree_node(); + brk_traversable::TreeNode::Branch( [ - self.dateindex.as_ref().map(|nested| nested.to_tree_node()), - Some(self.dateindex_extra.to_tree_node()), - Some(self.weekindex.to_tree_node()), - Some(self.monthindex.to_tree_node()), - Some(self.quarterindex.to_tree_node()), - Some(self.semesterindex.to_tree_node()), - Some(self.yearindex.to_tree_node()), - Some(self.decadeindex.to_tree_node()), + self.dateindex + .as_ref() + .map(|nested| ("dateindex".to_string(), nested.to_tree_node())), + if dateindex_extra_node.is_empty() { + None + } else { + Some(("dateindex_extra".to_string(), dateindex_extra_node)) + }, + Some(("weekindex".to_string(), self.weekindex.to_tree_node())), + Some(("monthindex".to_string(), self.monthindex.to_tree_node())), + Some(("quarterindex".to_string(), self.quarterindex.to_tree_node())), + Some(( + "semesterindex".to_string(), + self.semesterindex.to_tree_node(), + )), + Some(("yearindex".to_string(), self.yearindex.to_tree_node())), + Some(("decadeindex".to_string(), self.decadeindex.to_tree_node())), ] .into_iter() .flatten() .collect(), ) - .collect_unique_leaves() + .merge_branches() + .unwrap() } fn iter_any_collectable(&self) -> impl Iterator { diff --git a/crates/brk_computer/src/grouped/from_height.rs b/crates/brk_computer/src/grouped/from_height.rs index 219cbbc96..01071e010 100644 --- a/crates/brk_computer/src/grouped/from_height.rs +++ b/crates/brk_computer/src/grouped/from_height.rs @@ -206,24 +206,38 @@ where T: ComputedType, { fn to_tree_node(&self) -> brk_traversable::TreeNode { - brk_traversable::TreeNode::List( + let height_extra_node = self.height_extra.to_tree_node(); + brk_traversable::TreeNode::Branch( [ - self.height.as_ref().map(|nested| nested.to_tree_node()), - Some(self.height_extra.to_tree_node()), - Some(self.dateindex.to_tree_node()), - Some(self.weekindex.to_tree_node()), - Some(self.difficultyepoch.to_tree_node()), - Some(self.monthindex.to_tree_node()), - Some(self.quarterindex.to_tree_node()), - Some(self.semesterindex.to_tree_node()), - Some(self.yearindex.to_tree_node()), - Some(self.decadeindex.to_tree_node()), + self.height + .as_ref() + .map(|nested| ("height".to_string(), nested.to_tree_node())), + if height_extra_node.is_empty() { + None + } else { + Some(("height_extra".to_string(), height_extra_node)) + }, + Some(("dateindex".to_string(), self.dateindex.to_tree_node())), + Some(("weekindex".to_string(), self.weekindex.to_tree_node())), + Some(( + "difficultyepoch".to_string(), + self.difficultyepoch.to_tree_node(), + )), + Some(("monthindex".to_string(), self.monthindex.to_tree_node())), + Some(("quarterindex".to_string(), self.quarterindex.to_tree_node())), + Some(( + "semesterindex".to_string(), + self.semesterindex.to_tree_node(), + )), + Some(("yearindex".to_string(), self.yearindex.to_tree_node())), + Some(("decadeindex".to_string(), self.decadeindex.to_tree_node())), ] .into_iter() .flatten() .collect(), ) - .collect_unique_leaves() + .merge_branches() + .unwrap() } fn iter_any_collectable(&self) -> impl Iterator { diff --git a/crates/brk_computer/src/grouped/from_height_strict.rs b/crates/brk_computer/src/grouped/from_height_strict.rs index 67dc3ee2f..0567cfcc2 100644 --- a/crates/brk_computer/src/grouped/from_height_strict.rs +++ b/crates/brk_computer/src/grouped/from_height_strict.rs @@ -89,17 +89,26 @@ where T: ComputedType, { fn to_tree_node(&self) -> brk_traversable::TreeNode { - brk_traversable::TreeNode::List( + let height_extra_node = self.height_extra.to_tree_node(); + brk_traversable::TreeNode::Branch( [ - Some(self.height.to_tree_node()), - Some(self.height_extra.to_tree_node()), - Some(self.difficultyepoch.to_tree_node()), + Some(("height".to_string(), self.height.to_tree_node())), + if height_extra_node.is_empty() { + None + } else { + Some(("height_extra".to_string(), height_extra_node)) + }, + Some(( + "difficultyepoch".to_string(), + self.difficultyepoch.to_tree_node(), + )), ] .into_iter() .flatten() .collect(), ) - .collect_unique_leaves() + .merge_branches() + .unwrap() } fn iter_any_collectable(&self) -> impl Iterator { let mut regular_iter: Box> = diff --git a/crates/brk_computer/src/grouped/from_txindex.rs b/crates/brk_computer/src/grouped/from_txindex.rs index 36df68c4b..e1fd329ce 100644 --- a/crates/brk_computer/src/grouped/from_txindex.rs +++ b/crates/brk_computer/src/grouped/from_txindex.rs @@ -593,24 +593,33 @@ where T: ComputedType, { fn to_tree_node(&self) -> brk_traversable::TreeNode { - brk_traversable::TreeNode::List( + brk_traversable::TreeNode::Branch( [ - self.txindex.as_ref().map(|nested| nested.to_tree_node()), - Some(self.height.to_tree_node()), - Some(self.dateindex.to_tree_node()), - Some(self.weekindex.to_tree_node()), - Some(self.difficultyepoch.to_tree_node()), - Some(self.monthindex.to_tree_node()), - Some(self.quarterindex.to_tree_node()), - Some(self.semesterindex.to_tree_node()), - Some(self.yearindex.to_tree_node()), - Some(self.decadeindex.to_tree_node()), + self.txindex + .as_ref() + .map(|nested| ("txindex".to_string(), nested.to_tree_node())), + Some(("height".to_string(), self.height.to_tree_node())), + Some(("dateindex".to_string(), self.dateindex.to_tree_node())), + Some(("weekindex".to_string(), self.weekindex.to_tree_node())), + Some(( + "difficultyepoch".to_string(), + self.difficultyepoch.to_tree_node(), + )), + Some(("monthindex".to_string(), self.monthindex.to_tree_node())), + Some(("quarterindex".to_string(), self.quarterindex.to_tree_node())), + Some(( + "semesterindex".to_string(), + self.semesterindex.to_tree_node(), + )), + Some(("yearindex".to_string(), self.yearindex.to_tree_node())), + Some(("decadeindex".to_string(), self.decadeindex.to_tree_node())), ] .into_iter() .flatten() .collect(), ) - .collect_unique_leaves() + .merge_branches() + .unwrap() } fn iter_any_collectable(&self) -> impl Iterator { diff --git a/crates/brk_interface/examples/main.rs b/crates/brk_interface/examples/main.rs index d6c2ec4aa..8e604fa98 100644 --- a/crates/brk_interface/examples/main.rs +++ b/crates/brk_interface/examples/main.rs @@ -3,8 +3,9 @@ use std::{fs, path::Path}; use brk_computer::Computer; use brk_error::Result; use brk_indexer::Indexer; -use brk_interface::{Index, Interface, Params, ParamsOpt}; +use brk_interface::{Interface, Params, ParamsOpt}; use brk_parser::Parser; +use brk_structs::Index; use vecdb::Exit; pub fn main() -> Result<()> { diff --git a/crates/brk_interface/src/count.rs b/crates/brk_interface/src/count.rs new file mode 100644 index 000000000..c55e7081e --- /dev/null +++ b/crates/brk_interface/src/count.rs @@ -0,0 +1,13 @@ +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Debug, Serialize, JsonSchema)] +/// Metric count statistics - distinct metrics and total metric-index combinations +pub struct MetricCount { + #[schemars(example = 3141)] + /// Number of unique metrics available (e.g., realized_price, market_cap) + pub distinct_metrics: usize, + #[schemars(example = 21000)] + /// Total number of metric-index combinations across all timeframes + pub total_endpoints: usize, +} diff --git a/crates/brk_interface/src/lib.rs b/crates/brk_interface/src/lib.rs index 1bdcc6a63..dcbc6f5e4 100644 --- a/crates/brk_interface/src/lib.rs +++ b/crates/brk_interface/src/lib.rs @@ -6,7 +6,7 @@ use brk_computer::Computer; use brk_error::{Error, Result}; use brk_indexer::Indexer; use brk_parser::Parser; -use brk_structs::Height; +use brk_structs::{Height, Index, IndexInfo}; use brk_traversable::TreeNode; use nucleo_matcher::{ Config, Matcher, @@ -15,21 +15,20 @@ use nucleo_matcher::{ use quick_cache::sync::Cache; use vecdb::{AnyCollectableVec, AnyStoredVec}; +mod count; mod deser; mod format; -mod index; mod metrics; mod output; mod pagination; mod params; mod vecs; +pub use count::*; pub use format::Format; -pub use index::*; pub use output::{Output, Value}; -pub use pagination::{PaginatedIndexParam, PaginationParam}; +pub use pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; pub use params::{Params, ParamsDeprec, ParamsOpt}; -pub use vecs::PaginatedMetrics; use vecs::Vecs; use crate::vecs::{IndexToVec, MetricToVec}; @@ -229,6 +228,13 @@ impl<'a> Interface<'a> { &self.vecs.index_to_metric_to_vec } + pub fn metric_count(&self) -> MetricCount { + MetricCount { + distinct_metrics: self.distinct_metric_count(), + total_endpoints: self.total_metric_count(), + } + } + pub fn distinct_metric_count(&self) -> usize { self.vecs.distinct_metric_count } @@ -237,7 +243,7 @@ impl<'a> Interface<'a> { self.vecs.total_metric_count } - pub fn get_indexes(&self) -> &Indexes { + pub fn get_indexes(&self) -> &[IndexInfo] { &self.vecs.indexes } @@ -253,7 +259,7 @@ impl<'a> Interface<'a> { self.vecs.index_to_ids(paginated_index) } - pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec<&'static str>> { + pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec> { self.vecs.metric_to_indexes(metric) } diff --git a/crates/brk_interface/src/pagination.rs b/crates/brk_interface/src/pagination.rs index 7aa2ecb93..2412079f7 100644 --- a/crates/brk_interface/src/pagination.rs +++ b/crates/brk_interface/src/pagination.rs @@ -1,7 +1,8 @@ +use brk_structs::Index; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{Index, deser::de_unquote_usize}; +use crate::deser::de_unquote_usize; #[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct PaginationParam { @@ -28,3 +29,17 @@ pub struct PaginatedIndexParam { #[serde(flatten)] pub pagination: PaginationParam, } + +/// A paginated list of available metric names (1000 per page) +#[derive(Debug, Serialize, JsonSchema)] +pub struct PaginatedMetrics { + /// Current page number (0-indexed) + #[schemars(example = 0)] + pub current_page: usize, + /// Maximum valid page index (0-indexed) + #[schemars(example = 21000)] + pub max_page: usize, + /// List of metric names (max 1000 per page) + #[schemars(example = ["price_open", "price_close", "realized_price", "..."])] + pub metrics: &'static [&'static str], +} diff --git a/crates/brk_interface/src/params.rs b/crates/brk_interface/src/params.rs index 6bf4baa83..43e55c517 100644 --- a/crates/brk_interface/src/params.rs +++ b/crates/brk_interface/src/params.rs @@ -1,22 +1,23 @@ use std::ops::Deref; +use brk_structs::Index; use schemars::JsonSchema; use serde::Deserialize; use crate::{ - Format, Index, + Format, deser::{de_unquote_i64, de_unquote_usize}, metrics::MaybeMetrics, }; #[derive(Debug, Deserialize, JsonSchema)] pub struct Params { + /// Requested metrics #[serde(alias = "m")] - #[schemars(description = "Requested metrics")] pub metrics: MaybeMetrics, + /// Requested index #[serde(alias = "i")] - #[schemars(description = "Requested index")] pub index: Index, #[serde(flatten)] @@ -42,26 +43,20 @@ impl From<((Index, String), ParamsOpt)> for Params { #[derive(Default, Debug, Deserialize, JsonSchema)] pub struct ParamsOpt { - #[serde(default, alias = "f", deserialize_with = "de_unquote_i64")] /// Inclusive starting index, if negative will be from the end - #[schemars(description = "Inclusive starting index, if negative will be from the end")] + #[serde(default, alias = "f", deserialize_with = "de_unquote_i64")] from: Option, - #[serde(default, alias = "t", deserialize_with = "de_unquote_i64")] /// Exclusive ending index, if negative will be from the end, overrides 'count' - #[schemars( - description = "Exclusive ending index, if negative will be from the end, overrides 'count'" - )] + #[serde(default, alias = "t", deserialize_with = "de_unquote_i64")] to: Option, - #[serde(default, alias = "c", deserialize_with = "de_unquote_usize")] /// Number of values requested - #[schemars(description = "Number of values requested")] + #[serde(default, alias = "c", deserialize_with = "de_unquote_usize")] count: Option, - #[serde(default)] /// Format of the output - #[schemars(description = "Format of the output")] + #[serde(default)] format: Format, } diff --git a/crates/brk_interface/src/vecs.rs b/crates/brk_interface/src/vecs.rs index 582d99bf8..3eebd29fb 100644 --- a/crates/brk_interface/src/vecs.rs +++ b/crates/brk_interface/src/vecs.rs @@ -2,29 +2,23 @@ use std::collections::BTreeMap; use brk_computer::Computer; use brk_indexer::Indexer; +use brk_structs::{Index, IndexInfo}; use brk_traversable::{Traversable, TreeNode}; use derive_deref::{Deref, DerefMut}; -use schemars::JsonSchema; -use serde::Serialize; use vecdb::AnyCollectableVec; -use crate::{ - index::Indexes, - pagination::{PaginatedIndexParam, PaginationParam}, -}; - -use super::index::Index; +use crate::pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; #[derive(Default)] pub struct Vecs<'a> { pub metric_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>, pub index_to_metric_to_vec: BTreeMap>, pub metrics: Vec<&'a str>, - pub indexes: Indexes, + pub indexes: Vec, pub distinct_metric_count: usize, pub total_metric_count: usize, pub catalog: Option, - metric_to_indexes: BTreeMap<&'a str, Vec<&'static str>>, + metric_to_indexes: BTreeMap<&'a str, Vec>, index_to_metrics: BTreeMap>, } @@ -67,24 +61,19 @@ impl<'a> Vecs<'a> { .values() .map(|tree| tree.len()) .sum::(); - this.indexes = Indexes::new( - this.index_to_metric_to_vec - .keys() - .map(|i| (*i, i.possible_values())) - .collect::>(), - ); + this.indexes = this + .index_to_metric_to_vec + .keys() + .map(|i| IndexInfo { + index: *i, + aliases: i.possible_values(), + }) + .collect(); + this.metric_to_indexes = this .metric_to_index_to_vec .iter() - .map(|(id, index_to_vec)| { - ( - *id, - index_to_vec - .keys() - .map(|i| i.serialize_long()) - .collect::>(), - ) - }) + .map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::>())) .collect(); this.index_to_metrics = this .index_to_metric_to_vec @@ -142,12 +131,12 @@ impl<'a> Vecs<'a> { PaginatedMetrics { current_page: pagination.page.unwrap_or_default(), - total_pages: len / PaginationParam::PER_PAGE, + max_page: len.div_ceil(PaginationParam::PER_PAGE).saturating_sub(1), metrics: &self.metrics[start..end], } } - pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec<&'static str>> { + pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec> { self.metric_to_indexes .get(metric.replace("-", "_").as_str()) } @@ -171,17 +160,3 @@ pub struct IndexToVec<'a>(BTreeMap); #[derive(Default, Deref, DerefMut)] pub struct MetricToVec<'a>(BTreeMap<&'a str, &'a dyn AnyCollectableVec>); - -/// A paginated list of available metric names (1000 per page) -#[derive(Debug, Serialize, JsonSchema)] -pub struct PaginatedMetrics { - /// Current page number (0-indexed) - #[schemars(example = 0)] - current_page: usize, - /// Total number of pages available - #[schemars(example = 21000)] - total_pages: usize, - /// List of metric names (max 1000 per page) - #[schemars(example = ["price_open", "price_close", "realized_price", "..."])] - metrics: &'static [&'static str], -} diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index 2ba8c3a2f..6c4a8df3c 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -10,7 +10,7 @@ rust-version.workspace = true build = "build.rs" [dependencies] -aide = { workspace = true , features = ["axum-json", "axum-query"] } +aide = { workspace = true } axum = { workspace = true } bitcoin = { workspace = true } bitcoincore-rpc = { workspace = true } @@ -23,6 +23,7 @@ brk_logger = { workspace = true } brk_mcp = { workspace = true } brk_parser = { workspace = true } brk_structs = { workspace = true } +brk_traversable = { workspace = true } vecdb = { workspace = true } jiff = { workspace = true } log = { workspace = true } diff --git a/crates/brk_server/src/api/chain/addresses.rs b/crates/brk_server/src/api/chain/addresses.rs index 8b1c8ea86..6df0e194b 100644 --- a/crates/brk_server/src/api/chain/addresses.rs +++ b/crates/brk_server/src/api/chain/addresses.rs @@ -18,6 +18,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use vecdb::{AnyIterableVec, VecIterator}; +use crate::extended::TransformResponseExtended; + use super::AppState; #[derive(Debug, Serialize, JsonSchema)] @@ -88,13 +90,14 @@ struct AddressInfo { #[derive(Deserialize, JsonSchema)] struct AddressPath { /// Bitcoin address string + #[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")] address: String, } async fn get_address_info( Path(AddressPath { address }): Path, state: State, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json<&'static str>)> { let interface = state.interface; let indexer = interface.indexer(); let computer = interface.computer(); @@ -102,19 +105,28 @@ async fn get_address_info( let script = if let Ok(address) = BitcoinAddress::from_str(&address) { if !address.is_valid_for_network(Network::Bitcoin) { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::BAD_REQUEST, + Json("The provided address isn't the Bitcoin Network."), + )); } let address = address.assume_checked(); address.script_pubkey() } else if let Ok(pubkey) = PublicKey::from_str(&address) { ScriptBuf::new_p2pk(&pubkey) } else { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::BAD_REQUEST, + Json("The provided address is invalid."), + )); }; let type_ = OutputType::from(&script); let Ok(bytes) = AddressBytes::try_from((&script, type_)) else { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to convert the address to bytes"), + )); }; let hash = AddressBytesHash::from((&bytes, type_)); @@ -123,7 +135,10 @@ async fn get_address_info( .get(&hash) .map(|opt| opt.map(|cow| cow.into_owned())) else { - return Err(StatusCode::NOT_FOUND); + return Err(( + StatusCode::NOT_FOUND, + Json("Address not found in the blockchain (no transaction history)"), + )); }; let stateful = &computer.stateful; @@ -172,7 +187,12 @@ async fn get_address_info( .p2aaddressindex_to_anyaddressindex .iter() .unwrap_get_inner(type_index.into()), - _ => return Err(StatusCode::BAD_REQUEST), + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json("The provided address uses an unsupported type"), + )); + } }; let address_data = match any_address_index.to_enum() { @@ -207,12 +227,11 @@ fn get_address_info_docs(op: TransformOperation) -> TransformOperation { op.tag("Chain") .summary("Address information") .description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).") - .response_with::<400, (), _>(|res| - res.description("Invalid address format or unsupported address type") - ) - .response_with::<404, (), _>(|res| - res.description("Address not found in the blockchain (no transaction history)") - ) + .with_ok_response::(|res| res) + .with_not_modified() + .with_bad_request() + .with_not_found() + .with_server_error() } pub trait AddressesRoutes { diff --git a/crates/brk_server/src/api/chain/mod.rs b/crates/brk_server/src/api/chain/mod.rs index b47ee82e0..df4ac3adb 100644 --- a/crates/brk_server/src/api/chain/mod.rs +++ b/crates/brk_server/src/api/chain/mod.rs @@ -1,4 +1,5 @@ use aide::axum::ApiRouter; +use axum::{response::Redirect, routing::get}; use crate::api::chain::{addresses::AddressesRoutes, transactions::TransactionsRoutes}; @@ -13,6 +14,8 @@ pub trait ChainRoutes { impl ChainRoutes for ApiRouter { fn add_chain_routes(self) -> Self { - self.add_addresses_routes().add_transactions_routes() + self.route("/api/chain", get(Redirect::temporary("/api#tag/chain"))) + .add_addresses_routes() + .add_transactions_routes() } } diff --git a/crates/brk_server/src/api/chain/transactions.rs b/crates/brk_server/src/api/chain/transactions.rs index 40f7f6416..98708839b 100644 --- a/crates/brk_server/src/api/chain/transactions.rs +++ b/crates/brk_server/src/api/chain/transactions.rs @@ -21,6 +21,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use vecdb::VecIterator; +use crate::extended::TransformResponseExtended; + use super::AppState; #[derive(Serialize, JsonSchema)] @@ -41,15 +43,19 @@ struct TransactionInfo { #[derive(Deserialize, JsonSchema)] struct TxidPath { /// Bitcoin transaction id + #[schemars(example = &"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")] txid: String, } async fn get_transaction_info( Path(TxidPath { txid }): Path, state: State, -) -> Result { +) -> Result)> { let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::BAD_REQUEST, + Json("The provided TXID appears to be invalid."), + )); }; let txid = Txid::from(txid); @@ -62,7 +68,10 @@ async fn get_transaction_info( .get(&prefix) .map(|opt| opt.map(|cow| cow.into_owned())) else { - return Err(StatusCode::NOT_FOUND); + return Err(( + StatusCode::NOT_FOUND, + Json("Failed to found the TXID in the blockchain."), + )); }; let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index); @@ -84,32 +93,47 @@ async fn get_transaction_info( let blk_index_to_blk_path = parser.blk_index_to_blk_path(); let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else { - return Err(StatusCode::INTERNAL_SERVER_ERROR); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to read the transaction (get blk's path)"), + )); }; let mut xori = XORIndex::default(); xori.add_assign(position.offset() as usize); let Ok(mut file) = File::open(blk_path) else { - return Err(StatusCode::INTERNAL_SERVER_ERROR); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to read the transaction (open file)"), + )); }; if file .seek(SeekFrom::Start(position.offset() as u64)) .is_err() { - return Err(StatusCode::INTERNAL_SERVER_ERROR); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to read the transaction (file seek)"), + )); } let mut buffer = vec![0u8; *len as usize]; if file.read_exact(&mut buffer).is_err() { - return Err(StatusCode::INTERNAL_SERVER_ERROR); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to read the transaction (read exact)"), + )); } xori.bytes(&mut buffer, parser.xor_bytes()); let mut reader = Cursor::new(buffer); let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else { - return Err(StatusCode::INTERNAL_SERVER_ERROR); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed decode the transaction"), + )); }; let tx_info = TransactionInfo { txid, index, tx }; @@ -128,20 +152,11 @@ fn get_transaction_info_docs(op: TransformOperation) -> TransformOperation { .description( "Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.", ) - .response_with::<200, Json, _>(|res| res) - .response_with::<400, (), _>(|res| { - res.description( - "Invalid transaction ID format (must be a valid 64-character hex string)", - ) - }) - .response_with::<404, (), _>(|res| { - res.description("Transaction not found in the blockchain") - }) - .response_with::<500, (), _>(|res| { - res.description( - "Internal server error while reading transaction data from blockchain files", - ) - }) + .with_ok_response::(|res| res) + .with_not_modified() + .with_bad_request() + .with_not_found() + .with_server_error() } pub trait TransactionsRoutes { diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index d49971bd6..f74568710 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -2,19 +2,21 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ Json, extract::{Path, Query, State}, - http::{HeaderMap, Uri}, - response::{IntoResponse, Response}, + http::{HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Redirect, Response}, routing::get, }; use brk_interface::{ - Index, Indexes, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt, + MetricCount, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt, }; +use brk_structs::{Index, IndexInfo}; +use brk_traversable::TreeNode; use schemars::JsonSchema; -use serde::Serialize; +use serde::Deserialize; use crate::{ VERSION, - extended::{HeaderMapExtended, ResponseExtended}, + extended::{HeaderMapExtended, ResponseExtended, TransformResponseExtended}, }; use super::AppState; @@ -22,50 +24,51 @@ use super::AppState; mod data; pub trait ApiMetricsRoutes { - fn add_api_metrics_routes(self) -> Self; + fn add_metrics_routes(self) -> Self; +} + +#[derive(Deserialize, JsonSchema)] +struct MetricPath { + /// Metric name + #[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")] + metric: String, } const TO_SEPARATOR: &str = "_to_"; -#[derive(Debug, Serialize, JsonSchema)] -/// Metric count statistics - distinct metrics and total metric-index combinations -struct MetricCount { - #[schemars(example = 3141)] - /// Number of unique metrics available (e.g., realized_price, market_cap) - distinct_metrics: usize, - #[schemars(example = 21000)] - /// Total number of metric-index combinations across all timeframes - total_endpoints: usize, -} - impl ApiMetricsRoutes for ApiRouter { - fn add_api_metrics_routes(self) -> Self { - self.api_route( + fn add_metrics_routes(self) -> Self { + self + .route("/api/metrics", get(Redirect::temporary("/api#tag/metrics"))) + .api_route( "/api/metrics/count", get_with( - async |State(app_state): State| -> Json { - Json(MetricCount { - distinct_metrics: app_state.interface.distinct_metric_count(), - total_endpoints: app_state.interface.total_metric_count(), - }) + async |State(app_state): State| { + Json(app_state.interface.metric_count()) }, |op| { op.tag("Metrics") .summary("Metric count") .description("Current metric count") + .with_ok_response::, _>(|res| res) + .with_not_modified() }, ), ) .api_route( "/api/metrics/indexes", get_with( - async |State(app_state): State| -> Json<&Indexes> { + async |State(app_state): State| { Json(app_state.interface.get_indexes()) }, |op| { op.tag("Metrics") - .summary("Metric indexes") - .description("Available metric indexes and their accepted variants") + .summary("List available indexes") + .description( + "Returns all available indexes with their accepted query aliases. Use any alias when querying metrics." + ) + .with_ok_response::, _>(|res| res) + .with_not_modified() }, ), ) @@ -73,20 +76,21 @@ impl ApiMetricsRoutes for ApiRouter { "/api/metrics/list", get_with( async |State(app_state): State, - Query(pagination): Query| - -> Json { + Query(pagination): Query| { Json(app_state.interface.get_metrics(pagination)) }, |op| { op.tag("Metrics") .summary("Metrics list") .description("Paginated list of available metrics") + .with_ok_response::(|res| res) + .with_not_modified() }, ), ) - .route( + .api_route( "/api/metrics/catalog", - get( + get_with( async |headers: HeaderMap, State(app_state): State| -> Response { let etag = VERSION; @@ -97,8 +101,12 @@ impl ApiMetricsRoutes for ApiRouter { return Response::new_not_modified(); } - let mut response = - Json(app_state.interface.get_metrics_catalog()).into_response(); + let bytes = sonic_rs::to_vec(&app_state.interface.get_metrics_catalog()).unwrap(); + + let mut response = Response::builder() + .header("content-type", "application/json") + .body(bytes.into()) + .unwrap(); let headers = response.headers_mut(); headers.insert_cors(); @@ -106,6 +114,15 @@ impl ApiMetricsRoutes for ApiRouter { response }, + |op| { + op.tag("Metrics") + .summary("Metrics catalog") + .description( + "Returns the complete hierarchical catalog of available metrics organized as a tree structure. Metrics are grouped by categories and subcategories. Best viewed in an interactive JSON viewer (e.g., Firefox's built-in JSON viewer) for easy navigation of the nested structure." + ) + .with_ok_response::(|res| res) + .with_not_modified() + }, ), ) // TODO: @@ -119,12 +136,28 @@ impl ApiMetricsRoutes for ApiRouter { // }, // ), // ) - .route( + .api_route( "/api/metrics/{metric}", - get( - async |State(app_state): State, Path(metric): Path| -> Response { - // If not found do fuzzy search but here or in interface ? - Json(app_state.interface.metric_to_indexes(metric)).into_response() + get_with( + async | + State(app_state): State, + Path(MetricPath { metric }): Path + | { + match app_state.interface.metric_to_indexes(metric) { + Some(indexes) => Json(indexes).into_response(), + None => StatusCode::NOT_FOUND.into_response() + } + }, + |op| { + op.tag("Metrics") + .summary("Get supported indexes for a metric") + .description( + "Returns the list of indexes are supported by the specified metric. \ + For example, `realized_price` might be available on dateindex, weekindex, and monthindex." + ) + .with_ok_response::, _>(|res| res) + .with_not_modified() + .with_not_found() }, ), ) diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index 70ae0e2e3..4c0acac0c 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -2,21 +2,29 @@ use std::sync::Arc; use aide::{ axum::{ApiRouter, routing::get_with}, - openapi::{Info, OpenApi, Tag}, + openapi::OpenApi, +}; +use axum::{ + Extension, Json, + response::{Html, Redirect}, + routing::get, }; -use axum::{Extension, Json, response::Html, routing::get}; use schemars::JsonSchema; use serde::Serialize; use crate::{ VERSION, api::{chain::ChainRoutes, metrics::ApiMetricsRoutes}, + extended::TransformResponseExtended, }; use super::AppState; mod chain; mod metrics; +mod openapi; + +pub use openapi::*; pub trait ApiRoutes { fn add_api_routes(self) -> Self; @@ -33,7 +41,8 @@ struct Health { impl ApiRoutes for ApiRouter { fn add_api_routes(self) -> Self { self.add_chain_routes() - .add_api_metrics_routes() + .add_metrics_routes() + .route("/api/server", get(Redirect::temporary("/api#tag/server"))) .api_route( "/version", get_with( @@ -42,6 +51,7 @@ impl ApiRoutes for ApiRouter { op.tag("Server") .summary("API version") .description("Returns the current version of the API server") + .with_ok_response::(|res| res) }, ), ) @@ -59,6 +69,7 @@ impl ApiRoutes for ApiRouter { op.tag("Server") .summary("Health check") .description("Returns the health status of the API server") + .with_ok_response::(|res| res) }, ), ) @@ -73,48 +84,3 @@ impl ApiRoutes for ApiRouter { .route("/api", get(Html::from(include_str!("./scalar.html")))) } } - -pub fn create_openapi() -> OpenApi { - let tags = vec![ - Tag { - name: "Chain".to_string(), - description: Some( - "Explore Bitcoin blockchain data: addresses, transactions, blocks, balances, and UTXOs." - .to_string() - ), - ..Default::default() - }, - Tag { - name: "Metrics".to_string(), - description: Some( - "Access Bitcoin network metrics and time-series data. Query historical and real-time \ - statistics across various blockchain dimensions and aggregation levels." - .to_string() - ), - ..Default::default() - }, - Tag { - name: "Server".to_string(), - description: Some( - "Metadata and utility endpoints for API status, health checks, and system information." - .to_string() - ), - ..Default::default() - }, - ]; - - OpenApi { - info: Info { - title: "Bitcoin Research Kit API".to_string(), - description: Some( - "API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\ - ⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service." - .to_string(), - ), - version: format!("v{VERSION}"), - ..Info::default() - }, - tags, - ..OpenApi::default() - } -} diff --git a/crates/brk_server/src/api/openapi.rs b/crates/brk_server/src/api/openapi.rs new file mode 100644 index 000000000..820cb8d5c --- /dev/null +++ b/crates/brk_server/src/api/openapi.rs @@ -0,0 +1,60 @@ +use aide::openapi::{Info, OpenApi, Tag}; + +// +// https://docs.rs/schemars/latest/schemars/derive.JsonSchema.html +// +// Scalar: +// - Documentation: https://guides.scalar.com/scalar/scalar-api-references +// - Configuration: https://guides.scalar.com/scalar/scalar-api-references/configuration +// - Examples: +// - https://docs.machines.dev/ +// - https://tailscale.com/api +// - https://api.supabase.com/api/v1 +// + +use crate::VERSION; + +pub fn create_openapi() -> OpenApi { + let tags = vec![ + Tag { + name: "Chain".to_string(), + description: Some( + "Explore Bitcoin blockchain data: addresses, transactions, blocks, balances, and UTXOs." + .to_string() + ), + ..Default::default() + }, + Tag { + name: "Metrics".to_string(), + description: Some( + "Access Bitcoin network metrics and time-series data. Query historical and real-time \ + statistics across various blockchain dimensions and aggregation levels." + .to_string() + ), + ..Default::default() + }, + Tag { + name: "Server".to_string(), + description: Some( + "Metadata and utility endpoints for API status, health checks, and system information." + .to_string() + ), + ..Default::default() + }, + ]; + + OpenApi { + info: Info { + title: "Bitcoin Research Kit API".to_string(), + description: Some( + "API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\ + ⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service." + .to_string(), + ), + version: format!("v{VERSION}"), + ..Info::default() + }, + tags, + ..OpenApi::default() + } +} diff --git a/crates/brk_server/src/extended/mod.rs b/crates/brk_server/src/extended/mod.rs index 7055e7209..a66e7f101 100644 --- a/crates/brk_server/src/extended/mod.rs +++ b/crates/brk_server/src/extended/mod.rs @@ -1,5 +1,7 @@ mod header_map; mod response; +mod transform_operation; pub use header_map::*; pub use response::*; +pub use transform_operation::*; diff --git a/crates/brk_server/src/extended/transform_operation.rs b/crates/brk_server/src/extended/transform_operation.rs new file mode 100644 index 000000000..c437a7147 --- /dev/null +++ b/crates/brk_server/src/extended/transform_operation.rs @@ -0,0 +1,49 @@ +use aide::transform::{TransformOperation, TransformResponse}; +use axum::Json; +use schemars::JsonSchema; + +pub trait TransformResponseExtended<'t> { + /// 200 + fn with_ok_response(self, f: F) -> Self + where + R: JsonSchema, + F: FnOnce(TransformResponse<'_, R>) -> TransformResponse<'_, R>; + /// 400 + fn with_bad_request(self) -> Self; + /// 404 + fn with_not_found(self) -> Self; + /// 304 + fn with_not_modified(self) -> Self; + /// 500 + fn with_server_error(self) -> Self; +} + +impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> { + fn with_ok_response(self, f: F) -> Self + where + R: JsonSchema, + F: FnOnce(TransformResponse<'_, R>) -> TransformResponse<'_, R>, + { + self.response_with::<200, Json, _>(|res| f(res.description("Successful response"))) + } + + fn with_bad_request(self) -> Self { + self.response_with::<400, Json, _>(|res| { + res.description("Invalid request parameters") + }) + } + + fn with_not_found(self) -> Self { + self.response_with::<404, Json, _>(|res| res.description("Resource not found")) + } + + fn with_not_modified(self) -> Self { + self.response_with::<304, (), _>(|res| { + res.description("Not modified - content unchanged since last request") + }) + } + + fn with_server_error(self) -> Self { + self.response_with::<500, Json, _>(|res| res.description("Internal server error")) + } +} diff --git a/crates/brk_interface/src/index.rs b/crates/brk_structs/src/structs/index.rs similarity index 80% rename from crates/brk_interface/src/index.rs rename to crates/brk_structs/src/structs/index.rs index a739cc706..f341abf3d 100644 --- a/crates/brk_interface/src/index.rs +++ b/crates/brk_structs/src/structs/index.rs @@ -1,90 +1,81 @@ -use std::{ - collections::BTreeMap, - fmt::{self, Debug}, -}; +use std::fmt::{self, Debug}; use brk_error::Error; -use brk_structs::{ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use vecdb::PrintableIndex; + +use super::{ DateIndex, DecadeIndex, DifficultyEpoch, EmptyAddressIndex, EmptyOutputIndex, HalvingEpoch, Height, InputIndex, LoadedAddressIndex, MonthIndex, OpReturnIndex, OutputIndex, P2AAddressIndex, P2MSOutputIndex, P2PK33AddressIndex, P2PK65AddressIndex, P2PKHAddressIndex, - P2SHAddressIndex, P2TRAddressIndex, P2WPKHAddressIndex, P2WSHAddressIndex, PrintableIndex, - QuarterIndex, SemesterIndex, TxIndex, UnknownOutputIndex, WeekIndex, YearIndex, + P2SHAddressIndex, P2TRAddressIndex, P2WPKHAddressIndex, P2WSHAddressIndex, QuarterIndex, + SemesterIndex, TxIndex, UnknownOutputIndex, WeekIndex, YearIndex, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default, Serialize, JsonSchema)] -/// Indexes and their accepted variants -pub struct Indexes(BTreeMap); - -impl Indexes { - pub fn new(tree: BTreeMap) -> Self { - Self(tree) - } -} #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[schemars(example = Index::DateIndex)] +/// Aggregation dimension for querying Bitcoin blockchain data pub enum Index { - #[schemars(description = "Date/day index")] + /// Date/day index DateIndex, - #[schemars(description = "Decade index")] + /// Decade index DecadeIndex, - #[schemars(description = "Difficulty epoch index (equivalent to ~2 weeks)")] + /// Difficulty epoch index (equivalent to ~2 weeks) DifficultyEpoch, - #[schemars(description = "Empty output index")] + /// Empty output index EmptyOutputIndex, - #[schemars(description = "Halving epoch index (equivalent to ~4 years)")] + /// Halving epoch index (equivalent to ~4 years) HalvingEpoch, - #[schemars(description = "Height/block index")] + /// Height/block index Height, - #[schemars(description = "Transaction input index (based on total)")] + /// Transaction input index (based on total) InputIndex, - #[schemars(description = "Month index")] + /// Month index MonthIndex, - #[schemars(description = "Op return index")] + /// Op return index OpReturnIndex, - #[schemars(description = "Transaction output index (based on total)")] + /// Transaction output index (based on total) OutputIndex, - #[schemars(description = "Index of P2A address")] + /// Index of P2A address P2AAddressIndex, - #[schemars(description = "Index of P2MS output")] + /// Index of P2MS output P2MSOutputIndex, - #[schemars(description = "Index of P2PK (33 bytes) address")] + /// Index of P2PK (33 bytes) address P2PK33AddressIndex, - #[schemars(description = "Index of P2PK (65 bytes) address")] + /// Index of P2PK (65 bytes) address P2PK65AddressIndex, - #[schemars(description = "Index of P2PKH address")] + /// Index of P2PKH address P2PKHAddressIndex, - #[schemars(description = "Index of P2SH address")] + /// Index of P2SH address P2SHAddressIndex, - #[schemars(description = "Index of P2TR address")] + /// Index of P2TR address P2TRAddressIndex, - #[schemars(description = "Index of P2WPKH address")] + /// Index of P2WPKH address P2WPKHAddressIndex, - #[schemars(description = "Index of P2WSH address")] + /// Index of P2WSH address P2WSHAddressIndex, - #[schemars(description = "Quarter index")] + /// Quarter index QuarterIndex, - #[schemars(description = "Semester index")] + /// Semester index SemesterIndex, - #[schemars(description = "Transaction index")] + /// Transaction index TxIndex, - #[schemars(description = "Unknown output index")] + /// Unknown output index UnknownOutputIndex, - #[schemars(description = "Week index")] + /// Week index WeekIndex, - #[schemars(description = "Year index")] + /// Year index YearIndex, - #[schemars(description = "Loaded Address Index")] + /// Loaded Address Index LoadedAddressIndex, - #[schemars(description = "Empty Address Index")] + /// Empty Address Index EmptyAddressIndex, } impl Index { - pub fn all() -> [Self; 27] { + pub const fn all() -> [Self; 27] { [ Self::DateIndex, Self::DecadeIndex, diff --git a/crates/brk_structs/src/structs/indexinfo.rs b/crates/brk_structs/src/structs/indexinfo.rs new file mode 100644 index 000000000..e0a8369ea --- /dev/null +++ b/crates/brk_structs/src/structs/indexinfo.rs @@ -0,0 +1,15 @@ +use schemars::JsonSchema; +use serde::Serialize; + +use super::Index; + +#[derive(Serialize, JsonSchema)] +/// Information about an available index and its query aliases +pub struct IndexInfo { + /// The canonical index name + pub index: Index, + + /// All Accepted query aliases + #[schemars(example = vec!["d", "date", "dateindex"])] + pub aliases: &'static [&'static str], +} diff --git a/crates/brk_structs/src/structs/mod.rs b/crates/brk_structs/src/structs/mod.rs index 3653ffce4..9eb2f94f0 100644 --- a/crates/brk_structs/src/structs/mod.rs +++ b/crates/brk_structs/src/structs/mod.rs @@ -21,6 +21,8 @@ mod emptyoutputindex; mod feerate; mod halvingepoch; mod height; +mod index; +mod indexinfo; mod inputindex; mod loadedaddressdata; mod loadedaddressindex; @@ -55,6 +57,7 @@ mod stored_u32; mod stored_u64; mod stored_u8; mod timestamp; +mod treenode; mod txid; mod txidprefix; mod txindex; @@ -90,6 +93,8 @@ pub use emptyoutputindex::*; pub use feerate::*; pub use halvingepoch::*; pub use height::*; +pub use index::*; +pub use indexinfo::*; pub use inputindex::*; pub use loadedaddressdata::*; pub use loadedaddressindex::*; @@ -124,6 +129,7 @@ pub use stored_u16::*; pub use stored_u32::*; pub use stored_u64::*; pub use timestamp::*; +pub use treenode::*; pub use txid::*; pub use txidprefix::*; pub use txindex::*; diff --git a/crates/brk_structs/src/structs/treenode.rs b/crates/brk_structs/src/structs/treenode.rs new file mode 100644 index 000000000..aceb60301 --- /dev/null +++ b/crates/brk_structs/src/structs/treenode.rs @@ -0,0 +1,152 @@ +#[derive(Debug, Clone, Serialize, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +/// Hierarchical tree node for organizing metrics into categories +pub enum TreeNode { + /// Branch node containing subcategories + Branch(BTreeMap), + /// Leaf node containing the metric name + #[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")] + Leaf(String), +} + +impl TreeNode { + pub fn is_empty(&self) -> bool { + if let Self::Branch(tree) = self { + tree.is_empty() + } else { + false + } + } + + /// Merges all first-level branches into a single flattened structure. + /// Root-level leaves are placed under "default" key. + /// Returns None if conflicts are found (same key with incompatible values). + pub fn merge_branches(&self) -> Option { + let Self::Branch(tree) = self else { + return Some(self.clone()); + }; + + let mut merged: BTreeMap = BTreeMap::new(); + + for node in tree.values() { + match node { + Self::Leaf(value) => { + Self::merge_node(&mut merged, "base", &Self::Leaf(value.clone()))?; + } + Self::Branch(inner) => { + for (key, inner_node) in inner { + Self::merge_node(&mut merged, key, inner_node)?; + } + } + } + } + + let result = Self::Branch(merged); + + // Check if all leaves have the same value + if let Some(common_value) = result.all_leaves_same() { + Some(Self::Leaf(common_value)) + } else { + Some(result) + } + } + + /// Checks if all leaves in the tree have the same value. + /// Returns Some(value) if all leaves are identical, None otherwise. + fn all_leaves_same(&self) -> Option { + match self { + Self::Leaf(value) => Some(value.clone()), + Self::Branch(map) => { + let mut common_value: Option = None; + + for node in map.values() { + let node_value = node.all_leaves_same()?; + + match &common_value { + None => common_value = Some(node_value), + Some(existing) if existing != &node_value => return None, + _ => {} + } + } + + common_value + } + } + } + + /// Merges a node into the target map at the given key. + /// Returns None if there's a conflict. + fn merge_node( + target: &mut BTreeMap, + key: &str, + node: &TreeNode, + ) -> Option<()> { + match target.get_mut(key) { + None => { + target.insert(key.to_string(), node.clone()); + Some(()) + } + Some(existing) => match (existing, node) { + // Same leaf values: ok + (Self::Leaf(a), Self::Leaf(b)) if a == b => Some(()), + // Different leaf values: conflict + (Self::Leaf(_), Self::Leaf(_)) => None, + // Leaf vs branch: conflict + (Self::Leaf(_), Self::Branch(_)) | (Self::Branch(_), Self::Leaf(_)) => { + // dbg!((&existing, &node)); + None + } + // Both branches: merge recursively + (Self::Branch(existing_inner), Self::Branch(new_inner)) => { + for (k, v) in new_inner { + Self::merge_node(existing_inner, k, v)?; + } + Some(()) + } + }, + } + } + + /// List of prefixes to remove during simplification + const PREFIXES: &'static [&'static str] = &[ + "indexes_to_", + "chainindexes_to_", + "timeindexes_to_", + "txindex_to_", + "height_to_", + "dateindex_to_", + "weekindex_to_", + "difficultyepoch_to_", + "halvingepoch_to_", + // Add more prefixes here + ]; + + /// Recursively simplifies the tree by removing known prefixes from keys. + /// If multiple keys map to the same simplified name, checks for conflicts. + /// Returns None if there are conflicts (same simplified key, different values). + pub fn simplify(&self) -> Option { + match self { + Self::Leaf(value) => Some(Self::Leaf(value.clone())), + Self::Branch(map) => { + let mut simplified: BTreeMap = BTreeMap::new(); + + for (key, node) in map { + // Recursively simplify the child node first + let simplified_node = node.simplify()?; + + // Remove prefixes from the key + let simplified_key = Self::PREFIXES + .iter() + .find_map(|prefix| key.strip_prefix(prefix)) + .map(String::from) + .unwrap_or_else(|| key.clone()); + + // Try to merge into the result + Self::merge_node(&mut simplified, &simplified_key, &simplified_node)?; + } + + Some(Self::Branch(simplified)) + } + } + } +} diff --git a/crates/brk_traversable/Cargo.toml b/crates/brk_traversable/Cargo.toml index e7abca84a..e4a2d48a3 100644 --- a/crates/brk_traversable/Cargo.toml +++ b/crates/brk_traversable/Cargo.toml @@ -13,6 +13,8 @@ build = "build.rs" derive = ["brk_traversable_derive"] [dependencies] +brk_structs = { workspace = true } brk_traversable_derive = { workspace = true, optional = true } +schemars = { workspace = true } serde = { workspace = true } vecdb = { workspace = true } diff --git a/crates/brk_traversable/src/lib.rs b/crates/brk_traversable/src/lib.rs index 203d40ec7..1b52577d3 100644 --- a/crates/brk_traversable/src/lib.rs +++ b/crates/brk_traversable/src/lib.rs @@ -1,10 +1,8 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fmt::Debug, -}; +use std::{collections::BTreeMap, fmt::Debug}; #[cfg(feature = "derive")] pub use brk_traversable_derive::Traversable; +use schemars::JsonSchema; use serde::Serialize; use vecdb::{ AnyCollectableVec, AnyVec, CompressedVec, ComputedVec, EagerVec, LazyVecFrom1, LazyVecFrom2, @@ -16,42 +14,6 @@ pub trait Traversable { fn iter_any_collectable(&self) -> impl Iterator; } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum TreeNode { - Branch(BTreeMap), - List(Vec), - Leaf(String), -} - -impl TreeNode { - pub fn collect_unique_leaves(self) -> TreeNode { - let mut out = BTreeSet::new(); - - fn recurse(n: TreeNode, out: &mut BTreeSet) { - match n { - TreeNode::Leaf(s) => { - out.insert(s); - } - TreeNode::Branch(map) => { - map.into_values().for_each(|n| recurse(n, out)); - } - TreeNode::List(vec) => { - vec.into_iter().for_each(|n| recurse(n, out)); - } - } - } - - recurse(self, &mut out); - - match out.len() { - 0 => TreeNode::List(vec![]), - 1 => TreeNode::Leaf(out.into_iter().next().unwrap()), - _ => TreeNode::List(out.into_iter().map(TreeNode::Leaf).collect()), - } - } -} - impl Traversable for RawVec where I: StoredIndex, diff --git a/crates/brk_traversable_derive/src/lib.rs b/crates/brk_traversable_derive/src/lib.rs index 46f2a82df..19cb74b1a 100644 --- a/crates/brk_traversable_derive/src/lib.rs +++ b/crates/brk_traversable_derive/src/lib.rs @@ -150,11 +150,11 @@ fn generate_field_traversals(fields: &Fields) -> proc_macro2::TokenStream { .flatten() .collect(); - return if collected.len() == 1 { - collected.into_values().next().unwrap() - } else { - brk_traversable::TreeNode::Branch(collected) - }; + // return if collected.len() == 1 { + // collected.into_values().next().unwrap() + // } else { + brk_traversable::TreeNode::Branch(collected) + // }; } } _ => quote! {},