use paft::domain::Instrument;
use paft::market::responses::search::{SearchResponse, SearchResult};
use serde::Deserialize;
use serde_json::Value;
use url::Url;
use crate::core::client::CacheEndpoint;
use crate::core::diagnostics::{WireProjection, required_wire_value};
use crate::core::wire::WireValue;
use crate::core::yahoo_vocab::{parse_yahoo_exchange, parse_yahoo_quote_type};
use crate::core::{CallOptions, ProjectionContext};
use crate::{ProjectionIssue, YfClient, YfError, YfResponse};
#[allow(clippy::too_many_lines)]
fn parse_search_body(body: &str, ctx: &mut ProjectionContext) -> Result<SearchResponse, YfError> {
let env: V1SearchEnvelope = serde_json::from_str(body).map_err(YfError::json)?;
let quotes = env
.quotes
.ok_or_else(|| YfError::MissingData("search quotes missing".into()))?;
let mut results = Vec::new();
for (idx, q) in quotes.into_iter().enumerate() {
let raw_key = Some(search_quote_diag_key(&q, idx));
let q = match serde_json::from_value::<V1SearchQuote>(q) {
Ok(q) => q,
Err(err) => {
ctx.dropped_item(
"search_result",
raw_key.as_deref(),
ProjectionIssue::InvalidField {
field: "quote",
details: err.to_string(),
},
)?;
continue;
}
};
let key = q
.symbol
.as_ref()
.map(|symbol| symbol.trim().to_string())
.or(raw_key);
let Some(sym) =
required_wire_value(ctx, "search_result", key.as_deref(), "symbol", &q.symbol)?
.map(|sym| sym.trim().to_string())
else {
continue;
};
if sym.is_empty() {
ctx.dropped_item(
"search_result",
key.as_deref(),
ProjectionIssue::MissingRequiredField { field: "symbol" },
)?;
continue;
}
let shortname =
q.shortname
.optional_cloned(ctx, "quotes[].shortname", Some(sym.as_str()))?;
let longname = q
.longname
.optional_cloned(ctx, "quotes[].longname", Some(sym.as_str()))?;
let quote_type =
q.quote_type
.optional_cloned(ctx, "quotes[].quoteType", Some(sym.as_str()))?;
let exchange = q
.exchange
.optional_cloned(ctx, "quotes[].exchange", Some(sym.as_str()))?;
let exchange_opt = match exchange
.as_deref()
.map(str::trim)
.filter(|exchange| !exchange.is_empty())
{
Some(exchange) => match parse_yahoo_exchange(exchange) {
Ok(exchange) => Some(exchange),
Err(err) => {
ctx.omitted_present_field(
"quotes[].exchange",
Some(sym.as_str()),
ProjectionIssue::InvalidField {
field: "exchange",
details: err.to_string(),
},
)?;
None
}
},
None => None,
};
let kind = match quote_type
.as_deref()
.map(parse_yahoo_quote_type)
.transpose()
{
Ok(Some(kind)) => kind,
Ok(None) => {
ctx.dropped_item(
"search_result",
Some(sym.as_str()),
ProjectionIssue::MissingRequiredField { field: "quoteType" },
)?;
continue;
}
Err(err) => {
ctx.dropped_item(
"search_result",
Some(sym.as_str()),
ProjectionIssue::InvalidField {
field: "quoteType",
details: err.to_string(),
},
)?;
continue;
}
};
let instrument = match exchange_opt {
Some(exchange) => Instrument::from_symbol_and_exchange(&sym, exchange, kind),
None => Instrument::from_symbol(&sym, kind),
};
let Ok(instrument) = instrument else {
ctx.dropped_item(
"search_result",
Some(sym.as_str()),
ProjectionIssue::InvalidField {
field: "symbol",
details: "invalid instrument symbol".into(),
},
)?;
continue;
};
results.push(SearchResult {
instrument,
name: longname.or(shortname),
provider: (),
});
}
Ok(SearchResponse {
results,
provider: (),
})
}
fn search_quote_diag_key(value: &Value, idx: usize) -> String {
value
.get("symbol")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|symbol| !symbol.is_empty())
.map_or_else(|| format!("quotes[{idx}]"), ToString::to_string)
}
pub async fn search(client: &YfClient, query: &str) -> Result<SearchResponse, YfError> {
SearchBuilder::new(client, query).fetch().await
}
#[derive(Debug)]
pub struct SearchBuilder {
client: YfClient,
base: Url,
query: String,
quotes_count: Option<u32>,
lang: Option<String>,
region: Option<String>,
options: CallOptions,
}
impl SearchBuilder {
pub fn new(client: &YfClient, query: impl Into<String>) -> Self {
Self {
client: client.clone(),
base: Url::parse(DEFAULT_BASE_SEARCH_V1).unwrap(),
query: query.into(),
quotes_count: Some(10),
lang: None,
region: None,
options: CallOptions::default(),
}
}
crate::core::impl_call_option_setters!();
#[must_use]
pub fn search_base(mut self, base: Url) -> Self {
self.base = base;
self
}
#[must_use]
pub const fn quotes_count(mut self, n: u32) -> Self {
self.quotes_count = Some(n);
self
}
#[must_use]
pub fn lang(mut self, s: impl Into<String>) -> Self {
self.lang = Some(s.into());
self
}
#[must_use]
pub fn region(mut self, s: impl Into<String>) -> Self {
self.region = Some(s.into());
self
}
#[must_use]
pub fn lang_ref(&self) -> Option<&str> {
self.lang.as_deref()
}
#[must_use]
pub fn region_ref(&self) -> Option<&str> {
self.region.as_deref()
}
pub async fn fetch(&self) -> Result<SearchResponse, crate::core::YfError> {
Ok(self.fetch_with_diagnostics().await?.into_data())
}
pub async fn fetch_with_diagnostics(&self) -> Result<YfResponse<SearchResponse>, YfError> {
let query = self.validate_query()?;
let mut ctx = ProjectionContext::new("search", self.options.data_quality());
let mut url = self.base.clone();
Self::append_query_params(
&mut url,
query,
self.quotes_count,
self.lang.as_deref(),
self.region.as_deref(),
);
let (body, _) = crate::core::net::fetch_text_with_auth_retry(
&self.client,
url,
crate::core::net::AuthFetchConfig {
auth_mode: crate::core::net::AuthMode::OptionalCrumb,
cache_endpoint: CacheEndpoint::Search,
options: &self.options,
cache_body: None,
endpoint: "search_v1",
fixture_key: query,
ext: "json",
retry_on_invalid_crumb_body: true,
cache_validator: Some(validate_search_body),
},
|url| {
self.client
.http()
.get(url)
.header("accept", "application/json")
},
)
.await?;
let data = parse_search_body(&body, &mut ctx)?;
Ok(ctx.finish(data))
}
fn validate_query(&self) -> Result<&str, YfError> {
let query = self.query.trim();
if query.is_empty() {
return Err(YfError::InvalidParams(
"search query cannot be empty".into(),
));
}
Ok(query)
}
fn append_query_params(
url: &mut Url,
query: &str,
quotes_count: Option<u32>,
lang: Option<&str>,
region: Option<&str>,
) {
let mut qp = url.query_pairs_mut();
qp.append_pair("q", query);
if let Some(n) = quotes_count {
qp.append_pair("quotesCount", &n.to_string());
}
if let Some(l) = lang {
qp.append_pair("lang", l);
}
if let Some(r) = region {
qp.append_pair("region", r);
}
}
}
fn validate_search_body(body: &str) -> Result<(), YfError> {
let env: V1SearchEnvelope = serde_json::from_str(body).map_err(YfError::json)?;
if env.quotes.is_some() {
Ok(())
} else {
Err(YfError::MissingData("search quotes missing".into()))
}
}
const DEFAULT_BASE_SEARCH_V1: &str = "https://query2.finance.yahoo.com/v1/finance/search";
#[derive(Deserialize)]
struct V1SearchEnvelope {
#[allow(dead_code)]
explains: Option<serde_json::Value>,
#[allow(dead_code)]
count: Option<i64>,
quotes: Option<Vec<Value>>,
}
#[derive(Deserialize)]
struct V1SearchQuote {
#[serde(default)]
symbol: WireValue<String>,
#[serde(default)]
shortname: WireValue<String>,
#[serde(default)]
longname: WireValue<String>,
#[serde(rename = "quoteType")]
#[serde(default)]
quote_type: WireValue<String>,
#[serde(default)]
exchange: WireValue<String>,
#[allow(dead_code)]
#[serde(rename = "exchDisp")]
#[serde(default)]
exch_disp: WireValue<String>,
#[allow(dead_code)]
#[serde(rename = "typeDisp")]
#[serde(default)]
type_disp: WireValue<String>,
}