diff --git a/Cargo.lock b/Cargo.lock index 79127f24c..166c9bd2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,8 +720,7 @@ dependencies = [ "brk_structs", "brk_traversable", "derive_deref", - "quick_cache", - "rustc-hash", + "quickmatch", "schemars", "serde", "serde_json", @@ -2864,11 +2863,11 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3761,6 +3760,15 @@ dependencies = [ "parking_lot 0.12.5", ] +[[package]] +name = "quickmatch" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da298bd4f885924ca16faab70af76893797eb0164dfa4e86a14ffd983b8cb14" +dependencies = [ + "rustc-hash", +] + [[package]] name = "quote" version = "1.0.41" @@ -3943,9 +3951,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.3" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433" dependencies = [ "aho-corasick", "memchr", @@ -3955,9 +3963,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" dependencies = [ "aho-corasick", "memchr", @@ -3966,9 +3974,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" [[package]] name = "regress" diff --git a/Cargo.toml b/Cargo.toml index f9891ecc9..73a39aba7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,6 @@ jiff = "0.2.15" log = "0.4.28" minreq = { version = "2.14.1", features = ["https", "serde_json"] } parking_lot = "0.12.5" -quick_cache = "0.6.17" rayon = "1.11.0" schemars = "1.0.4" serde = "1.0.228" diff --git a/crates/brk_interface/Cargo.toml b/crates/brk_interface/Cargo.toml index 1e930c801..b04f1f955 100644 --- a/crates/brk_interface/Cargo.toml +++ b/crates/brk_interface/Cargo.toml @@ -18,10 +18,10 @@ brk_indexer = { workspace = true } brk_parser = { workspace = true } brk_structs = { workspace = true } brk_traversable = { workspace = true } -vecdb = { workspace = true } derive_deref = { workspace = true } -quick_cache = { workspace = true } +# quickmatch = { path = "../../../quickmatch" } +quickmatch = "0.1.8" schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -rustc-hash = "2.1.1" +vecdb = { workspace = true } diff --git a/crates/brk_interface/src/lib.rs b/crates/brk_interface/src/lib.rs index 490d78fac..470b60090 100644 --- a/crates/brk_interface/src/lib.rs +++ b/crates/brk_interface/src/lib.rs @@ -7,8 +7,8 @@ use brk_error::Result; use brk_indexer::Indexer; use brk_parser::Parser; use brk_structs::{ - AddressInfo, AddressPath, Format, Height, Index, IndexInfo, MetricCount, TransactionInfo, - TxidPath, + AddressInfo, AddressPath, Format, Height, Index, IndexInfo, MetricCount, MetricSearchQuery, + TransactionInfo, TxidPath, }; use brk_traversable::TreeNode; use vecdb::{AnyCollectableVec, AnyStoredVec}; @@ -18,7 +18,6 @@ mod deser; mod metrics; mod pagination; mod params; -mod searcher; mod vecs; pub use metrics::{Output, Value}; @@ -71,8 +70,8 @@ impl<'a> Interface<'a> { get_transaction_info(txid, self) } - pub fn search_metric(&self, metric: &str, limit: usize) -> Vec<&str> { - self.vecs.search(metric, limit) + pub fn match_metric(&self, query: MetricSearchQuery) -> Vec<&str> { + self.vecs.matches(query) } pub fn search_metric_with_index( diff --git a/crates/brk_interface/src/searcher.rs b/crates/brk_interface/src/searcher.rs deleted file mode 100644 index 98a6911d1..000000000 --- a/crates/brk_interface/src/searcher.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::{marker::PhantomData, ops::Neg, ptr}; - -use rustc_hash::{FxHashMap, FxHashSet}; - -const MAX_TRIGRAMS: usize = 9; - -pub struct NgramSearcher<'a> { - max_word_count: usize, - max_word_len: usize, - max_query_len: usize, - word_index: FxHashMap>, - trigram_index: FxHashMap<[char; 3], FxHashSet<*const str>>, - _phantom: PhantomData<&'a str>, -} - -unsafe impl<'a> Send for NgramSearcher<'a> {} -unsafe impl<'a> Sync for NgramSearcher<'a> {} - -const SEPARATORS: &[char] = &['_', '-', ' ']; - -impl<'a> NgramSearcher<'a> { - pub fn new(items: &[&'a str]) -> Self { - let mut word_index: FxHashMap> = FxHashMap::default(); - let mut trigram_index: FxHashMap<[char; 3], FxHashSet<*const str>> = FxHashMap::default(); - let mut max_word_len = 0; - let mut max_query_len = 0; - let mut max_words = 0; - - for &item in items { - max_query_len = max_query_len.max(item.len()); - let mut word_count = 0; - for word in item.split(SEPARATORS) { - word_count += 1; - if word.is_empty() { - continue; - } - - max_word_len = max_word_len.max(item.len()); - - word_index.entry(word.to_string()).or_default().insert(item); - - if word.len() >= 3 { - let chars = word.chars().collect::>(); - for window in chars.windows(3) { - trigram_index - .entry(unsafe { ptr::read(window.as_ptr() as *const [char; 3]) }) - .or_default() - .insert(item); - } - } - } - max_words = max_words.max(word_count); - } - - Self { - max_query_len: max_query_len + 6, - max_word_len: max_word_len + 4, - max_word_count: max_word_len + 2, - word_index, - trigram_index, - _phantom: PhantomData, - } - } - - pub fn search(&self, query: &str, limit: usize) -> Vec<&'a str> { - let query_lower = query.to_lowercase(); - let query_len = query_lower.len(); - - if query.is_empty() || query_len > self.max_query_len { - return vec![]; - } - - let words: FxHashSet<&str> = query_lower - .split(SEPARATORS) - .filter(|w| !w.is_empty() && w.len() <= self.max_word_len) - .collect(); - - if words.is_empty() || words.len() > self.max_word_count { - return vec![]; - } - - let min_len = query_len.saturating_sub(3); - - let mut pool: Option> = None; - let mut unknown_words = Vec::new(); - - let mut words_to_intersect = vec![]; - for word in words { - match self.word_index.get(word) { - Some(items) => words_to_intersect.push(items), - None => unknown_words.push(word), - } - } - - if !words_to_intersect.is_empty() { - words_to_intersect.sort_unstable_by_key(|set| (set.len() as i64).neg()); - - let mut intersect = words_to_intersect.pop().cloned().unwrap(); - - for other_set in words_to_intersect.iter().rev() { - intersect.retain(|ptr| other_set.contains(ptr)); - if intersect.is_empty() { - break; - } - } - - pool = Some(intersect); - } - let some_pool = pool.is_some(); - - if some_pool && unknown_words.is_empty() { - let mut results: Vec<_> = pool - .unwrap() - .into_iter() - .map(|item| unsafe { &*item as &str }) - .collect(); - // Partial sort - only sort what we need - if results.len() > limit { - results.select_nth_unstable_by_key(limit, |item| item.len()); - results.truncate(limit); - } - results.sort_unstable_by_key(|item| item.len()); - return results; - } - - // Score candidates - let mut scores: FxHashMap<*const str, usize> = FxHashMap::default(); - scores.reserve(256); - if let Some(pool) = &pool { - for &item in pool { - scores.insert(item, 1); - } - } - let mut trigram_count = 0; - 'outer: for word in unknown_words { - if word.len() < 3 || trigram_count >= MAX_TRIGRAMS { - continue; - } - - let mut chars = word.chars(); - let mut a = chars.next().unwrap(); - let mut b = chars.next().unwrap(); - - for c in chars { - if trigram_count >= MAX_TRIGRAMS { - break 'outer; - } - trigram_count += 1; - - let trigram = [a, b, c]; - - let Some(items) = self.trigram_index.get(&trigram) else { - continue; - }; - - if some_pool { - for &item in items { - if let Some(score) = scores.get_mut(&item) { - *score += 1; - } - } - } else { - for &item in items { - let len = unsafe { &*item }.len(); - if len >= min_len { - *scores.entry(item).or_default() += 1; - } - } - } - - // Slide window - a = b; - b = c; - } - } - - // Filter by minimum score - let min_score = trigram_count.div_ceil(2); - let mut results: Vec<_> = scores - .into_iter() - .filter(|(_, s)| *s >= min_score) - .map(|(item, score)| (unsafe { &*item as &str }, score)) - .collect(); - - if results.len() > limit { - results.select_nth_unstable_by(limit, |a, b| { - b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())) - }); - results.truncate(limit); - } - - results.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len()))); - - results - .into_iter() - .take(limit) - .map(|(item, _)| item) - .collect() - } -} diff --git a/crates/brk_interface/src/vecs.rs b/crates/brk_interface/src/vecs.rs index cd789304e..e46312f5a 100644 --- a/crates/brk_interface/src/vecs.rs +++ b/crates/brk_interface/src/vecs.rs @@ -2,15 +2,13 @@ use std::collections::BTreeMap; use brk_computer::Computer; use brk_indexer::Indexer; -use brk_structs::{Index, IndexInfo}; +use brk_structs::{Index, IndexInfo, MetricSearchQuery}; use brk_traversable::{Traversable, TreeNode}; use derive_deref::{Deref, DerefMut}; +use quickmatch::{QuickMatch, QuickMatchConfig}; use vecdb::AnyCollectableVec; -use crate::{ - pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}, - searcher::NgramSearcher, -}; +use crate::pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; #[derive(Default)] pub struct Vecs<'a> { @@ -22,7 +20,7 @@ pub struct Vecs<'a> { pub total_metric_count: usize, pub longest_metric_len: usize, catalog: Option, - searcher: Option>, + matcher: Option>, metric_to_indexes: BTreeMap<&'a str, Vec>, index_to_metrics: BTreeMap>, } @@ -106,7 +104,7 @@ impl<'a> Vecs<'a> { .simplify() .unwrap(), ); - this.searcher = Some(NgramSearcher::new(&this.metrics)); + this.matcher = Some(QuickMatch::new(&this.metrics)); this } @@ -174,12 +172,13 @@ impl<'a> Vecs<'a> { self.catalog.as_ref().unwrap() } - pub fn search(&self, metric: &str, limit: usize) -> Vec<&'_ str> { - self.searcher().search(metric, limit) + pub fn matches(&self, query: MetricSearchQuery) -> Vec<&'_ str> { + self.matcher() + .matches_with(&query.q, &QuickMatchConfig::new().with_limit(query.limit)) } - fn searcher(&self) -> &NgramSearcher<'_> { - self.searcher.as_ref().unwrap() + fn matcher(&self) -> &QuickMatch<'_> { + self.matcher.as_ref().unwrap() } } diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index e52a8de82..be40975c6 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -27,7 +27,7 @@ brk_traversable = { workspace = true } vecdb = { workspace = true } jiff = { workspace = true } log = { workspace = true } -quick_cache = { workspace = true } +quick_cache = "0.6.17" schemars = { workspace = true } serde = { workspace = true } sonic-rs = { workspace = true } diff --git a/crates/brk_server/src/api/chain/addresses.rs b/crates/brk_server/src/api/chain/addresses.rs index 08a48a0d0..85159941d 100644 --- a/crates/brk_server/src/api/chain/addresses.rs +++ b/crates/brk_server/src/api/chain/addresses.rs @@ -1,13 +1,15 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ - Json, extract::{Path, State}, - http::StatusCode, + http::HeaderMap, response::Response, }; use brk_structs::{AddressInfo, AddressPath}; -use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended}; +use crate::{ + VERSION, + extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, +}; use super::AppState; @@ -19,14 +21,19 @@ impl AddressesRoutes for ApiRouter { fn add_addresses_routes(self) -> Self { self.api_route( "/api/chain/address/{address}", - get_with(async |Path(address): Path, - State(app_state): State| - -> Result)> { - let address_info = app_state.interface.get_address_info(address).to_server_result()?; - - let bytes = sonic_rs::to_vec(&address_info).unwrap(); - - Ok(Response::new_json_from_bytes(bytes)) + get_with(async | + headers: HeaderMap, + Path(address): Path, + State(state): State + | { + let etag = format!("{VERSION}-{}", state.get_height()); + if headers.has_etag(&etag) { + return Response::new_not_modified(); + } + match state.get_address_info(address).with_status() { + Ok(value) => Response::new_json(&value, &etag), + Err((status, message)) => Response::new_json_with(status, &message, &etag) + } }, |op| op .tag("Chain") .summary("Address information") diff --git a/crates/brk_server/src/api/chain/transactions.rs b/crates/brk_server/src/api/chain/transactions.rs index 3406ff6a4..b1356a143 100644 --- a/crates/brk_server/src/api/chain/transactions.rs +++ b/crates/brk_server/src/api/chain/transactions.rs @@ -1,13 +1,15 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ - Json, extract::{Path, State}, - http::StatusCode, + http::HeaderMap, response::Response, }; use brk_structs::{TransactionInfo, TxidPath}; -use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended}; +use crate::{ + VERSION, + extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, +}; use super::AppState; @@ -20,14 +22,19 @@ impl TransactionsRoutes for ApiRouter { self.api_route( "/api/chain/tx/{txid}", get_with( - async |Path(txid): Path, - State(app_state): State| - -> Result)> { - let tx_info = app_state.interface.get_transaction_info(txid).to_server_result()?; - - let bytes = sonic_rs::to_vec(&tx_info).unwrap(); - - Ok(Response::new_json_from_bytes(bytes)) + async | + headers: HeaderMap, + Path(txid): Path, + State(state): State + | { + let etag = format!("{VERSION}-{}", state.get_height()); + if headers.has_etag(&etag) { + return Response::new_not_modified(); + } + match state.get_transaction_info(txid).with_status() { + Ok(value) => Response::new_json(&value, &etag), + Err((status, message)) => Response::new_json_with(status, &message, &etag) + } }, |op| op .tag("Chain") diff --git a/crates/brk_server/src/api/metrics/data.rs b/crates/brk_server/src/api/metrics/data.rs index 133f2008a..f22dbbc13 100644 --- a/crates/brk_server/src/api/metrics/data.rs +++ b/crates/brk_server/src/api/metrics/data.rs @@ -23,9 +23,9 @@ pub async fn handler( uri: Uri, headers: HeaderMap, query: Query, - State(app_state): State, + State(state): State, ) -> Response { - match req_to_response_res(uri, headers, query, app_state) { + match req_to_response_res(uri, headers, query, state) { Ok(response) => response, Err(error) => { let mut response = diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index 5c463b1b3..fc6245e45 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -1,13 +1,12 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ - Json, extract::{Path, Query, State}, http::{HeaderMap, StatusCode, Uri}, response::{IntoResponse, Redirect, Response}, routing::get, }; use brk_interface::{PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt}; -use brk_structs::{Index, IndexInfo, MetricCount, MetricPath}; +use brk_structs::{Index, IndexInfo, MetricCount, MetricPath, MetricSearchQuery}; use brk_traversable::TreeNode; use crate::{ @@ -23,8 +22,6 @@ pub trait ApiMetricsRoutes { fn add_metrics_routes(self) -> Self; } -const TO_SEPARATOR: &str = "_to_"; - impl ApiMetricsRoutes for ApiRouter { fn add_metrics_routes(self) -> Self { self @@ -32,149 +29,145 @@ impl ApiMetricsRoutes for ApiRouter { .api_route( "/api/metrics/count", get_with( - 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() + async | + headers: HeaderMap, + State(state): State + | { + let etag = VERSION; + if headers.has_etag(etag) { + return Response::new_not_modified(); + } + Response::new_json(state.metric_count(), etag) }, + |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(app_state.interface.get_indexes()) - }, - |op| { - op.tag("Metrics") - .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() + async | + headers: HeaderMap, + State(state): State + | { + let etag = VERSION; + if headers.has_etag(etag) { + return Response::new_not_modified(); + } + Response::new_json(state.get_indexes(), etag) }, + |op| op.tag("Metrics") + .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(), ), ) .api_route( "/api/metrics/list", get_with( - async |State(app_state): State, - 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() + async | + headers: HeaderMap, + State(state): State, + Query(pagination): Query + | { + let etag = VERSION; + if headers.has_etag(etag) { + return Response::new_not_modified(); + } + Response::new_json(state.get_metrics(pagination), etag) }, + |op| op.tag("Metrics") + .summary("Metrics list") + .description("Paginated list of available metrics") + .with_ok_response::(|res| res) + .with_not_modified(), ), ) .api_route( "/api/metrics/catalog", get_with( - async |headers: HeaderMap, State(app_state): State| -> Response { + async |headers: HeaderMap, State(state): State| -> Response { let etag = VERSION; - - if headers - .get_if_none_match() - .is_some_and(|prev_etag| etag == prev_etag) - { + if headers.has_etag(etag) { return Response::new_not_modified(); } - - let bytes = sonic_rs::to_vec(&app_state.interface.get_metrics_catalog()).unwrap(); - - let mut response = Response::new_json_from_bytes(bytes); - - let headers = response.headers_mut(); - headers.insert_cors(); - headers.insert_etag(etag); - - response + Response::new_json(state.get_metrics_catalog(), etag) }, - |op| { - op.tag("Metrics") + |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() - }, + .with_not_modified(), ), ) .api_route( - "/api/search/{metric}", + "/api/metrics/search", get_with( async | headers: HeaderMap, - State(app_state): State, - Path(MetricPath { metric }): Path + State(state): State, + Query(query): Query | { let etag = VERSION; - - if headers - .get_if_none_match() - .is_some_and(|prev_etag| etag == prev_etag) - { + if headers.has_etag(etag) { return Response::new_not_modified(); } - - let bytes = sonic_rs::to_vec(&app_state.interface.search_metric(&metric, usize::MAX)).unwrap(); - - let mut response = Response::new_json_from_bytes(bytes); - - let headers = response.headers_mut(); - headers.insert_cors(); - headers.insert_etag(etag); - - response - }, - |op| { - op.tag("Metrics") - .summary("Metric search") - .description( - "Search metrics based on a query" - ) - .with_ok_response::, _>(|res| res) - .with_not_modified() + Response::new_json(state.match_metric(query), etag) }, + |op| op.tag("Metrics") + .summary("Search metrics") + .description("Fuzzy search for metrics by name. Supports partial matches and typos.") + .with_ok_response::, _>(|res| res) + .with_not_modified(), ), ) .api_route( "/api/metrics/{metric}", get_with( async | - State(app_state): State, + headers: HeaderMap, + State(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() + let etag = VERSION; + if headers.has_etag(etag) { + return Response::new_not_modified(); } + if let Some(indexes) = state.metric_to_indexes(metric.clone()) { + return Response::new_json(indexes, etag) + } + let value = if let Some(first) = state.match_metric(MetricSearchQuery { + q: metric.clone(), + limit: 1, + }).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) }, - |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() - }, + |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(), ), ) + // WIP .route("/api/metrics/bulk", get(data::handler)) + // WIP .route( "/api/metrics/{metric}/{index}", get( @@ -221,8 +214,9 @@ impl ApiMetricsRoutes for ApiRouter { Query(params_opt): Query, state: State| -> Response { + let separator = "_to_"; let variant = variant.replace("-", "_"); - let mut split = variant.split(TO_SEPARATOR); + let mut split = variant.split(separator); let ser_index = split.next().unwrap(); let Ok(index) = Index::try_from(ser_index) else { @@ -230,7 +224,7 @@ impl ApiMetricsRoutes for ApiRouter { }; let params = Params::from(( - (index, split.collect::>().join(TO_SEPARATOR)), + (index, split.collect::>().join(separator)), params_opt, )); data::handler(uri, headers, Query(params), state).await diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index f01d74989..c2118cb71 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -52,8 +52,8 @@ impl ApiRoutes for ApiRouter { get_with( async || -> Json { Json(Health { - status: "healthy".to_string(), - service: "brk".to_string(), + status: "healthy", + service: "brk", timestamp: jiff::Timestamp::now().to_string(), }) }, @@ -73,21 +73,11 @@ impl ApiRoutes for ApiRouter { -> Response { let etag = VERSION; - if headers - .get_if_none_match() - .is_some_and(|prev_etag| etag == prev_etag) - { + if headers.has_etag(etag) { return Response::new_not_modified(); } - let mut response = - Response::new_json_from_bytes(sonic_rs::to_vec(&api).unwrap()); - - let headers = response.headers_mut(); - headers.insert_cors(); - headers.insert_etag(etag); - - response + Response::new_json(&api, etag) }, ), ) diff --git a/crates/brk_server/src/extended/header_map.rs b/crates/brk_server/src/extended/header_map.rs index 6de487d2c..2b658f600 100644 --- a/crates/brk_server/src/extended/header_map.rs +++ b/crates/brk_server/src/extended/header_map.rs @@ -21,7 +21,7 @@ pub enum ModifiedState { pub trait HeaderMapExtended { fn insert_cors(&mut self); - fn get_if_none_match(&self) -> Option<&str>; + fn has_etag(&self, etag: &str) -> bool; fn get_if_modified_since(&self) -> Option; fn check_if_modified_since(&self, path: &Path) -> Result<(ModifiedState, DateTime)>; @@ -123,8 +123,9 @@ impl HeaderMapExtended for HeaderMap { None } - fn get_if_none_match(&self) -> Option<&str> { - self.get(IF_NONE_MATCH).and_then(|v| v.to_str().ok()) + fn has_etag(&self, etag: &str) -> bool { + self.get(IF_NONE_MATCH) + .is_some_and(|prev_etag| etag == prev_etag) } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types diff --git a/crates/brk_server/src/extended/response.rs b/crates/brk_server/src/extended/response.rs index 26bbe1702..2e3f02e5f 100644 --- a/crates/brk_server/src/extended/response.rs +++ b/crates/brk_server/src/extended/response.rs @@ -11,7 +11,12 @@ where Self: Sized, { fn new_not_modified() -> Self; - fn new_json_from_bytes(bytes: Vec) -> Self; + fn new_json(value: T, etag: &str) -> Self + where + T: sonic_rs::Serialize; + fn new_json_with(status: StatusCode, value: T, etag: &str) -> Self + where + T: sonic_rs::Serialize; } impl ResponseExtended for Response { @@ -22,10 +27,25 @@ impl ResponseExtended for Response { response } - fn new_json_from_bytes(bytes: Vec) -> Self { - Response::builder() - .header("content-type", "application/json") - .body(bytes.into()) - .unwrap() + fn new_json(value: T, etag: &str) -> Self + where + T: sonic_rs::Serialize, + { + Self::new_json_with(StatusCode::default(), value, etag) + } + + fn new_json_with(status: StatusCode, value: T, etag: &str) -> Self + where + T: sonic_rs::Serialize, + { + let bytes = sonic_rs::to_vec(&value).unwrap(); + let mut response = Response::builder().body(bytes.into()).unwrap(); + *response.status_mut() = status; + let headers = response.headers_mut(); + headers.insert_cors(); + headers.insert_content_type_application_json(); + headers.insert_cache_control_must_revalidate(); + headers.insert_etag(etag); + response } } diff --git a/crates/brk_server/src/extended/result.rs b/crates/brk_server/src/extended/result.rs index 681bdb864..9cb606e6c 100644 --- a/crates/brk_server/src/extended/result.rs +++ b/crates/brk_server/src/extended/result.rs @@ -1,12 +1,12 @@ -use axum::{Json, http::StatusCode}; +use axum::http::StatusCode; use brk_error::{Error, Result}; pub trait ResultExtended { - fn to_server_result(self) -> Result)>; + fn with_status(self) -> Result; } impl ResultExtended for Result { - fn to_server_result(self) -> Result)> { + fn with_status(self) -> Result { self.map_err(|e| { ( match e { @@ -17,7 +17,7 @@ impl ResultExtended for Result { Error::UnknownAddress | Error::UnknownTxid => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }, - Json(e.to_string()), + e.to_string(), ) }) } diff --git a/crates/brk_server/src/files/file.rs b/crates/brk_server/src/files/file.rs index 40aee3426..721d85466 100644 --- a/crates/brk_server/src/files/file.rs +++ b/crates/brk_server/src/files/file.rs @@ -14,22 +14,22 @@ use crate::{AppState, HeaderMapExtended, ModifiedState, ResponseExtended}; pub async fn file_handler( headers: HeaderMap, - State(app_state): State, + State(state): State, path: extract::Path, ) -> Response { - any_handler(headers, app_state, Some(path)) + any_handler(headers, state, Some(path)) } -pub async fn index_handler(headers: HeaderMap, State(app_state): State) -> Response { - any_handler(headers, app_state, None) +pub async fn index_handler(headers: HeaderMap, State(state): State) -> Response { + any_handler(headers, state, None) } fn any_handler( headers: HeaderMap, - app_state: AppState, + state: AppState, path: Option>, ) -> Response { - let files_path = app_state.path.as_ref().unwrap(); + let files_path = state.path.as_ref().unwrap(); if let Some(path) = path.as_ref() { let path = path.0.replace("..", "").replace("\\", ""); @@ -52,14 +52,14 @@ fn any_handler( } } - path_to_response(&headers, &app_state, &path) + path_to_response(&headers, &state, &path) } else { - path_to_response(&headers, &app_state, &files_path.join("index.html")) + path_to_response(&headers, &state, &files_path.join("index.html")) } } -fn path_to_response(headers: &HeaderMap, app_state: &AppState, path: &Path) -> Response { - match path_to_response_(headers, app_state, path) { +fn path_to_response(headers: &HeaderMap, state: &AppState, path: &Path) -> Response { + match path_to_response_(headers, state, path) { Ok(response) => response, Err(error) => { let mut response = @@ -72,7 +72,7 @@ fn path_to_response(headers: &HeaderMap, app_state: &AppState, path: &Path) -> R } } -fn path_to_response_(headers: &HeaderMap, app_state: &AppState, path: &Path) -> Result { +fn path_to_response_(headers: &HeaderMap, state: &AppState, path: &Path) -> Result { let (modified, date) = headers.check_if_modified_since(path)?; if modified == ModifiedState::NotModifiedSince { return Ok(Response::new_not_modified()); @@ -86,7 +86,7 @@ fn path_to_response_(headers: &HeaderMap, app_state: &AppState, path: &Path) -> || serialized_path.ends_with("service-worker.js"); let guard_res = if !must_revalidate { - Some(app_state.cache.get_value_or_guard( + Some(state.cache.get_value_or_guard( &path.to_str().unwrap().to_owned(), Some(Duration::from_millis(50)), )) diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index b8b16cd12..edfd3f10f 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -3,7 +3,7 @@ #![doc = include_str!("../examples/main.rs")] #![doc = "```"] -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{ops::Deref, path::PathBuf, sync::Arc, time::Duration}; use aide::axum::ApiRouter; use api::ApiRoutes; @@ -42,6 +42,13 @@ pub struct AppState { cache: Arc>, } +impl Deref for AppState { + type Target = &'static Interface<'static>; + fn deref(&self) -> &Self::Target { + &self.interface + } +} + pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub struct Server(AppState); diff --git a/crates/brk_structs/src/health.rs b/crates/brk_structs/src/health.rs index b5d7fc1b0..3306601da 100644 --- a/crates/brk_structs/src/health.rs +++ b/crates/brk_structs/src/health.rs @@ -4,7 +4,7 @@ use serde::Serialize; #[derive(Debug, Serialize, JsonSchema)] /// Server health status pub struct Health { - pub status: String, - pub service: String, + pub status: &'static str, + pub service: &'static str, pub timestamp: String, } diff --git a/crates/brk_structs/src/lib.rs b/crates/brk_structs/src/lib.rs index e3d47273f..93bcf3537 100644 --- a/crates/brk_structs/src/lib.rs +++ b/crates/brk_structs/src/lib.rs @@ -36,6 +36,7 @@ mod loadedaddressdata; mod loadedaddressindex; mod metriccount; mod metricpath; +mod metricsearchquery; mod monthindex; mod ohlc; mod opreturnindex; @@ -116,6 +117,7 @@ pub use loadedaddressdata::*; pub use loadedaddressindex::*; pub use metriccount::*; pub use metricpath::*; +pub use metricsearchquery::*; pub use monthindex::*; pub use ohlc::*; pub use opreturnindex::*; diff --git a/crates/brk_structs/src/metricsearchquery.rs b/crates/brk_structs/src/metricsearchquery.rs new file mode 100644 index 000000000..2e3d00d3c --- /dev/null +++ b/crates/brk_structs/src/metricsearchquery.rs @@ -0,0 +1,26 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +/// Search query parameters for finding metrics by name +pub struct MetricSearchQuery { + /// Search query string. Supports fuzzy matching, partial matches, and typos. + #[schemars(example = &"price", example = &"low", example = &"sth", example = &"realized", example = &"pric")] + pub q: String, + + /// Maximum number of results to return. Defaults to 100 if not specified. + #[serde(default = "default_search_limit")] + #[schemars( + example = "1", + example = "10", + example = "100", + example = "1000", + example = "10000", + example = "100000" + )] + pub limit: usize, +} + +fn default_search_limit() -> usize { + 100 +}