#[cfg(feature = "test-mode")]
use std::env;
use serde::de::DeserializeOwned;
use url::Url;
use crate::core::{
CallOptions, YfClient, YfError,
client::{CacheEndpoint, CacheMode, RetryConfig},
redaction::RedactedUrl,
};
pub type CacheBodyValidator = fn(&str) -> Result<(), YfError>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthMode {
OptionalCrumb,
RequiredCrumb,
}
#[derive(Clone, Copy, Debug)]
pub struct AuthFetchConfig<'a> {
pub auth_mode: AuthMode,
pub cache_endpoint: CacheEndpoint,
pub options: &'a CallOptions,
pub cache_body: Option<&'a str>,
pub endpoint: &'a str,
pub fixture_key: &'a str,
pub ext: &'a str,
pub retry_on_invalid_crumb_body: bool,
pub cache_validator: Option<CacheBodyValidator>,
}
#[derive(Clone, Copy, Debug)]
pub struct CacheFetchConfig<'a> {
pub cache_endpoint: CacheEndpoint,
pub options: &'a CallOptions,
pub endpoint: &'a str,
pub fixture_key: &'a str,
pub ext: &'a str,
pub cache_validator: Option<CacheBodyValidator>,
}
enum AuthAttempt {
Success { body: String, url: Url },
Status { status: u16, url: Url },
InvalidCrumb,
}
enum CachedAuthAttempt {
Success { body: String, url: Url },
InvalidCrumb,
}
#[allow(unused_variables)]
pub async fn get_text(
resp: reqwest::Response,
endpoint: &str,
symbol: &str,
ext: &str,
) -> Result<String, reqwest::Error> {
let text = resp.text().await?;
#[cfg(feature = "test-mode")]
{
if env::var("YF_RECORD").ok().as_deref() == Some("1")
&& let Err(e) = crate::core::fixtures::record_fixture(endpoint, symbol, ext, &text)
{
crate::core::logging::trace_warn!(
symbol,
error = %e,
"failed to write fixture"
);
}
}
Ok(text)
}
#[must_use]
pub fn status_error_code(status: u16, url: &Url) -> YfError {
let url = RedactedUrl::new(url).to_string();
match status {
404 => YfError::NotFound { url },
429 => YfError::RateLimited { url },
500..=599 => YfError::ServerError { status, url },
_ => YfError::Status { status, url },
}
}
#[must_use]
pub fn status_error(status: reqwest::StatusCode, url: &Url) -> YfError {
status_error_code(status.as_u16(), url)
}
pub async fn get_success_text(
resp: reqwest::Response,
url: &Url,
endpoint: &str,
symbol: &str,
ext: &str,
) -> Result<String, YfError> {
if !resp.status().is_success() {
return Err(status_error(resp.status(), url));
}
get_text(resp, endpoint, symbol, ext)
.await
.map_err(YfError::from)
}
pub async fn fetch_text(
client: &YfClient,
req: reqwest::RequestBuilder,
url: &Url,
retry_override: Option<&RetryConfig>,
endpoint: &str,
symbol: &str,
ext: &str,
) -> Result<String, YfError> {
let resp = client.send_with_retry(req, retry_override).await?;
get_success_text(resp, url, endpoint, symbol, ext).await
}
pub async fn fetch_text_cached(
client: &YfClient,
url: &Url,
config: CacheFetchConfig<'_>,
) -> Result<String, YfError> {
let cache_key = client_cache_key(url);
if config.options.cache_mode().reads(config.cache_endpoint)
&& let Some(text) = client.cache_get_key(&cache_key)
&& cached_body_is_valid(client, &cache_key, text.as_ref(), config.cache_validator)
{
return Ok(text.to_string());
}
let text = fetch_text(
client,
client.http().get(url.clone()),
url,
config.options.retry_override(),
config.endpoint,
config.fixture_key,
config.ext,
)
.await?;
if cache_write_enabled(client, config.options, config.cache_endpoint) {
validate_cache_body(config.cache_validator, &text)?;
client.cache_put_key(config.cache_endpoint, cache_key, &text, None);
}
Ok(text)
}
pub async fn fetch_json_post_cached<T>(
client: &YfClient,
url: &Url,
body_json: &str,
config: CacheFetchConfig<'_>,
) -> Result<T, YfError>
where
T: DeserializeOwned,
{
let cache_key = YfClient::post_cache_key(url, body_json);
if config.options.cache_mode().reads(config.cache_endpoint)
&& let Some(body) = client.cache_get_key(&cache_key)
&& cached_body_is_valid(client, &cache_key, body.as_ref(), config.cache_validator)
{
match serde_json::from_str(body.as_ref()) {
Ok(data) => return Ok(data),
Err(_) => client.cache_remove_key(&cache_key),
}
}
let req = client
.http()
.post(url.clone())
.header("content-type", "application/json")
.body(body_json.to_string());
let body = fetch_text(
client,
req,
url,
config.options.retry_override(),
config.endpoint,
config.fixture_key,
config.ext,
)
.await?;
validate_cache_body(config.cache_validator, &body)?;
let data = serde_json::from_str(&body).map_err(YfError::json)?;
if cache_write_enabled(client, config.options, config.cache_endpoint) {
client.cache_put_key(config.cache_endpoint, cache_key, &body, None);
}
Ok(data)
}
pub async fn fetch_text_with_auth_retry<F>(
client: &YfClient,
base_url: Url,
config: AuthFetchConfig<'_>,
build_request: F,
) -> Result<(String, Url), YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
let cache_key = config.cache_body.map_or_else(
|| client_cache_key(&base_url),
|body| YfClient::post_cache_key(&base_url, body),
);
match config.auth_mode {
AuthMode::OptionalCrumb => {
fetch_optional_crumb_auth(client, base_url, &cache_key, config, &build_request).await
}
AuthMode::RequiredCrumb => {
fetch_required_crumb_auth(client, base_url, &cache_key, config, &build_request).await
}
}
}
async fn fetch_optional_crumb_auth<F>(
client: &YfClient,
base_url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
) -> Result<(String, Url), YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
if let Some(result) =
read_cached_or_refresh(client, base_url.clone(), cache_key, config, build_request).await
{
return result;
}
if let Some(crumb) = client.crumb() {
let crumb_url = url_with_crumb(base_url.clone(), &crumb);
let attempt =
fetch_text_auth_attempt(client, crumb_url, cache_key, config, build_request, true)
.await?;
return finish_crumb_attempt(client, attempt, base_url, cache_key, config, build_request)
.await;
}
match fetch_text_auth_attempt(
client,
base_url.clone(),
cache_key,
config,
build_request,
true,
)
.await?
{
AuthAttempt::Success { body, url } => Ok((body, url)),
AuthAttempt::Status { status, url } if !is_auth_status(status) => {
Err(status_error_code(status, &url))
}
AuthAttempt::InvalidCrumb => {
retry_with_fresh_crumb(client, base_url, cache_key, config, build_request).await
}
AuthAttempt::Status { .. } => {
let crumb = ensure_crumb(client, "Crumb is not set after ensuring credentials").await?;
let crumb_url = url_with_crumb(base_url.clone(), &crumb);
let attempt =
fetch_text_auth_attempt(client, crumb_url, cache_key, config, build_request, true)
.await?;
finish_crumb_attempt(client, attempt, base_url, cache_key, config, build_request).await
}
}
}
async fn fetch_required_crumb_auth<F>(
client: &YfClient,
base_url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
) -> Result<(String, Url), YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
if let Some(result) =
read_cached_or_refresh(client, base_url.clone(), cache_key, config, build_request).await
{
return result;
}
let crumb = ensure_crumb(client, "Crumb is not set").await?;
let crumb_url = url_with_crumb(base_url.clone(), &crumb);
let attempt =
fetch_text_auth_attempt(client, crumb_url, cache_key, config, build_request, true).await?;
finish_crumb_attempt(client, attempt, base_url, cache_key, config, build_request).await
}
async fn read_cached_or_refresh<F>(
client: &YfClient,
base_url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
) -> Option<Result<(String, Url), YfError>>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
let attempt = read_cached_auth_attempt(client, base_url.clone(), cache_key, config, true)?;
Some(match attempt {
CachedAuthAttempt::Success { body, url } => Ok((body, url)),
CachedAuthAttempt::InvalidCrumb => {
retry_with_fresh_crumb(client, base_url, cache_key, config, build_request).await
}
})
}
async fn finish_crumb_attempt<F>(
client: &YfClient,
attempt: AuthAttempt,
base_url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
) -> Result<(String, Url), YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
match attempt {
AuthAttempt::Success { body, url } => Ok((body, url)),
AuthAttempt::Status { status, url } if !is_auth_status(status) => {
Err(status_error_code(status, &url))
}
AuthAttempt::Status { .. } | AuthAttempt::InvalidCrumb => {
retry_with_fresh_crumb(client, base_url, cache_key, config, build_request).await
}
}
}
async fn fetch_text_auth_attempt<F>(
client: &YfClient,
url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
detect_invalid_crumb_body: bool,
) -> Result<AuthAttempt, YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
let req = client.with_auth_cookie(build_request(url.clone()));
let resp = client
.send_with_retry(req, config.options.retry_override())
.await?;
if !resp.status().is_success() {
return Ok(AuthAttempt::Status {
status: resp.status().as_u16(),
url,
});
}
let body =
get_success_text(resp, &url, config.endpoint, config.fixture_key, config.ext).await?;
if should_retry_invalid_crumb_body(config, detect_invalid_crumb_body, &body) {
return Ok(AuthAttempt::InvalidCrumb);
}
if cache_write_enabled(client, config.options, config.cache_endpoint) {
validate_cache_body(config.cache_validator, &body)?;
client.cache_put_key(config.cache_endpoint, cache_key.to_string(), &body, None);
}
Ok(AuthAttempt::Success { body, url })
}
fn read_cached_auth_attempt(
client: &YfClient,
url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
detect_invalid_crumb_body: bool,
) -> Option<CachedAuthAttempt> {
if !config.options.cache_mode().reads(config.cache_endpoint) {
return None;
}
let body = client.cache_get_key(cache_key)?;
if should_retry_invalid_crumb_body(config, detect_invalid_crumb_body, body.as_ref()) {
client.cache_remove_key(cache_key);
return Some(CachedAuthAttempt::InvalidCrumb);
}
if !cached_body_is_valid(client, cache_key, body.as_ref(), config.cache_validator) {
return None;
}
Some(CachedAuthAttempt::Success {
body: body.to_string(),
url,
})
}
async fn retry_with_fresh_crumb<F>(
client: &YfClient,
base_url: Url,
cache_key: &str,
config: AuthFetchConfig<'_>,
build_request: &F,
) -> Result<(String, Url), YfError>
where
F: Fn(Url) -> reqwest::RequestBuilder + Send + Sync,
{
client.cache_remove_key(cache_key);
client.clear_crumb();
let crumb = ensure_crumb(client, "Crumb is not set after refreshing credentials").await?;
let crumb_url = url_with_crumb(base_url, &crumb);
let retry_options = config
.options
.clone()
.with_cache_mode(retry_cache_mode(config));
let retry_config = AuthFetchConfig {
options: &retry_options,
..config
};
match fetch_text_auth_attempt(
client,
crumb_url,
cache_key,
retry_config,
build_request,
true,
)
.await?
{
AuthAttempt::Success { body, url } => Ok((body, url)),
AuthAttempt::Status { status, url } => Err(status_error_code(status, &url)),
AuthAttempt::InvalidCrumb => Err(YfError::Auth(
"Yahoo returned an invalid crumb response after refreshing credentials".into(),
)),
}
}
const fn retry_cache_mode(config: AuthFetchConfig<'_>) -> CacheMode {
if config.options.cache_mode().writes(config.cache_endpoint) {
CacheMode::Refresh
} else {
CacheMode::Bypass
}
}
async fn ensure_crumb(client: &YfClient, missing_message: &str) -> Result<String, YfError> {
client.ensure_credentials().await?;
client
.crumb()
.ok_or_else(|| YfError::Auth(missing_message.into()))
}
fn url_with_crumb(mut url: Url, crumb: &str) -> Url {
url.query_pairs_mut().append_pair("crumb", crumb);
url
}
fn client_cache_key(url: &Url) -> String {
url.as_str().to_string()
}
fn validate_cache_body(validator: Option<CacheBodyValidator>, body: &str) -> Result<(), YfError> {
validator.map_or(Ok(()), |validate| validate(body))
}
const fn cache_write_enabled(
client: &YfClient,
options: &CallOptions,
endpoint: CacheEndpoint,
) -> bool {
client.cache_enabled() && options.cache_mode().writes(endpoint)
}
fn cached_body_is_valid(
client: &YfClient,
cache_key: &str,
body: &str,
validator: Option<CacheBodyValidator>,
) -> bool {
if validate_cache_body(validator, body).is_ok() {
true
} else {
client.cache_remove_key(cache_key);
false
}
}
fn should_retry_invalid_crumb_body(
config: AuthFetchConfig<'_>,
detect_invalid_crumb_body: bool,
body: &str,
) -> bool {
config.retry_on_invalid_crumb_body
&& detect_invalid_crumb_body
&& has_invalid_crumb_marker(body)
}
fn has_invalid_crumb_marker(body: &str) -> bool {
const INVALID_CRUMB_BODY_MAX_LEN: usize = 200;
const INVALID_CREDENTIAL_MARKERS: &[&[u8]] =
&[b"invalid crumb", b"invalid credentials", b"unauthorized"];
body.len() <= INVALID_CRUMB_BODY_MAX_LEN
&& INVALID_CREDENTIAL_MARKERS.iter().any(|marker| {
body.as_bytes()
.windows(marker.len())
.any(|window| window.eq_ignore_ascii_case(marker))
})
}
const fn is_auth_status(status: u16) -> bool {
status == 401 || status == 403
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use httpmock::{Method::GET, Mock, MockServer};
use super::*;
const INVALID_CRUMB_BODY: &str =
r#"{"quoteResponse":{"result":null,"error":{"description":"Invalid Crumb"}}}"#;
const OK_BODY: &str = r#"{"quoteResponse":{"result":[],"error":null}}"#;
static QUOTE_AUTH_OPTIONS: CallOptions = CallOptions::new().with_cache_mode(CacheMode::Use);
#[must_use]
fn server_url(server: &MockServer, path_and_query: &str) -> Url {
Url::parse(&format!("{}{}", server.base_url(), path_and_query)).expect("valid mock URL")
}
#[must_use]
fn cached_client(server: &MockServer) -> YfClient {
YfClient::builder()
.cookie_url(server_url(server, "/consent"))
.crumb_url(server_url(server, "/v1/test/getcrumb"))
.cache_ttl(Duration::from_mins(1))
.build()
.expect("client builds")
}
#[must_use]
fn quote_auth_config() -> AuthFetchConfig<'static> {
AuthFetchConfig {
auth_mode: AuthMode::OptionalCrumb,
cache_endpoint: CacheEndpoint::Quote,
options: "E_AUTH_OPTIONS,
cache_body: None,
endpoint: "quote_v7",
fixture_key: "AAPL",
ext: "json",
retry_on_invalid_crumb_body: true,
cache_validator: None,
}
}
#[must_use]
fn required_quote_summary_auth_config() -> AuthFetchConfig<'static> {
AuthFetchConfig {
auth_mode: AuthMode::RequiredCrumb,
cache_endpoint: CacheEndpoint::QuoteSummary,
options: "E_AUTH_OPTIONS,
cache_body: None,
endpoint: "quote_summary",
fixture_key: "AAPL",
ext: "json",
retry_on_invalid_crumb_body: true,
cache_validator: None,
}
}
#[must_use]
fn mock_credentials(server: &'_ MockServer) -> (Mock<'_>, Mock<'_>) {
let cookie = server.mock(|when, then| {
when.method(GET).path("/consent");
then.status(200).header("set-cookie", "A=B; Path=/");
});
let crumb = server.mock(|when, then| {
when.method(GET).path("/v1/test/getcrumb");
then.status(200).body("fresh-crumb");
});
(cookie, crumb)
}
#[test]
fn invalid_crumb_body_detection_is_case_insensitive_and_size_bounded() {
assert!(should_retry_invalid_crumb_body(
quote_auth_config(),
true,
INVALID_CRUMB_BODY
));
assert!(should_retry_invalid_crumb_body(
quote_auth_config(),
true,
"Invalid Crumb"
));
assert!(should_retry_invalid_crumb_body(
quote_auth_config(),
true,
"invalid crumb"
));
assert!(should_retry_invalid_crumb_body(
quote_auth_config(),
true,
"Unauthorized"
));
assert!(should_retry_invalid_crumb_body(
quote_auth_config(),
true,
r#"{"error":"Invalid Credentials"}"#
));
assert!(!should_retry_invalid_crumb_body(
quote_auth_config(),
false,
INVALID_CRUMB_BODY
));
let large_success_body = format!("{}Invalid Crumb", "x".repeat(201));
assert!(!should_retry_invalid_crumb_body(
quote_auth_config(),
true,
&large_success_body
));
}
#[tokio::test]
async fn required_crumb_cache_hit_does_not_fetch_credentials() {
let server = MockServer::start();
let (cookie, crumb) = mock_credentials(&server);
let client = cached_client(&server);
let base_url = server_url(
&server,
"/v10/finance/quoteSummary/AAPL?modules=summaryDetail",
);
let cache_key = base_url.as_str().to_string();
client.cache_put_key(CacheEndpoint::QuoteSummary, cache_key, OK_BODY, None);
let (body, used_url) = fetch_text_with_auth_retry(
&client,
base_url.clone(),
required_quote_summary_auth_config(),
|url| client.http().get(url),
)
.await
.expect("cached quoteSummary response succeeds");
assert_eq!(body, OK_BODY);
assert_eq!(used_url, base_url);
assert_eq!(cookie.calls(), 0);
assert_eq!(crumb.calls(), 0);
}
#[tokio::test]
#[allow(clippy::used_underscore_items)]
async fn optional_crumb_uses_cached_crumb_before_bare_request() {
let server = MockServer::start();
let bare = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.is_true(|req| !req.query_params().iter().any(|(key, _)| key == "crumb"));
then.status(401).body("unauthorized");
});
let (cookie, crumb) = mock_credentials(&server);
let api = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.query_param("crumb", "cached-crumb");
then.status(200)
.header("content-type", "application/json")
.body(OK_BODY);
});
let client = YfClient::builder()
.cookie_url(server_url(&server, "/consent"))
.crumb_url(server_url(&server, "/v1/test/getcrumb"))
.cache_ttl(Duration::from_mins(1))
._preauth("cookie", "cached-crumb")
.build()
.expect("client builds");
let base_url = server_url(&server, "/v7/finance/quote?symbols=AAPL");
let (body, used_url) =
fetch_text_with_auth_retry(&client, base_url, quote_auth_config(), |url| {
client.http().get(url)
})
.await
.expect("cached crumb succeeds");
assert_eq!(body, OK_BODY);
assert!(
used_url
.query_pairs()
.any(|(key, value)| key == "crumb" && value == "cached-crumb")
);
assert_eq!(bare.calls(), 0);
assert_eq!(cookie.calls(), 0);
assert_eq!(crumb.calls(), 0);
api.assert();
}
#[tokio::test]
#[allow(clippy::used_underscore_items)]
async fn optional_crumb_refreshes_stale_cached_crumb_without_bare_retry() {
let server = MockServer::start();
let bare = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.is_true(|req| !req.query_params().iter().any(|(key, _)| key == "crumb"));
then.status(200).body(OK_BODY);
});
let stale = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.query_param("crumb", "stale-crumb");
then.status(401).body("unauthorized");
});
let (cookie, crumb) = mock_credentials(&server);
let api = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.query_param("crumb", "fresh-crumb");
then.status(200)
.header("content-type", "application/json")
.body(OK_BODY);
});
let client = YfClient::builder()
.cookie_url(server_url(&server, "/consent"))
.crumb_url(server_url(&server, "/v1/test/getcrumb"))
.cache_ttl(Duration::from_mins(1))
._preauth("cookie", "stale-crumb")
.build()
.expect("client builds");
let base_url = server_url(&server, "/v7/finance/quote?symbols=AAPL");
let (body, used_url) =
fetch_text_with_auth_retry(&client, base_url, quote_auth_config(), |url| {
client.http().get(url)
})
.await
.expect("fresh crumb retry succeeds");
assert_eq!(body, OK_BODY);
assert!(
used_url
.query_pairs()
.any(|(key, value)| key == "crumb" && value == "fresh-crumb")
);
assert_eq!(bare.calls(), 0);
stale.assert();
cookie.assert();
crumb.assert();
api.assert();
}
#[tokio::test]
async fn cached_invalid_crumb_body_is_evicted_before_fresh_crumb_retry() {
let server = MockServer::start();
let (_cookie, _crumb) = mock_credentials(&server);
let api = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.query_param("crumb", "fresh-crumb");
then.status(200)
.header("content-type", "application/json")
.body(OK_BODY);
});
let client = cached_client(&server);
let base_url = server_url(&server, "/v7/finance/quote?symbols=AAPL");
let cache_key = base_url.as_str().to_string();
client.cache_put_key(
CacheEndpoint::Quote,
cache_key.clone(),
INVALID_CRUMB_BODY,
None,
);
let (body, used_url) =
fetch_text_with_auth_retry(&client, base_url, quote_auth_config(), |url| {
client.http().get(url)
})
.await
.expect("fresh crumb retry succeeds");
assert_eq!(body, OK_BODY);
assert!(
used_url
.query_pairs()
.any(|(key, value)| { key == "crumb" && value == "fresh-crumb" })
);
assert_eq!(client.cache_get_key(&cache_key).as_deref(), Some(OK_BODY));
api.assert();
}
#[tokio::test]
async fn invalid_crumb_body_after_refresh_returns_auth_error() {
let server = MockServer::start();
let (_cookie, _crumb) = mock_credentials(&server);
let api = server.mock(|when, then| {
when.method(GET)
.path("/v7/finance/quote")
.query_param("symbols", "AAPL")
.query_param("crumb", "fresh-crumb");
then.status(200)
.header("content-type", "application/json")
.body(INVALID_CRUMB_BODY);
});
let client = cached_client(&server);
let base_url = server_url(&server, "/v7/finance/quote?symbols=AAPL");
let cache_key = base_url.as_str().to_string();
let build_request = |url| client.http().get(url);
let err = retry_with_fresh_crumb(
&client,
base_url,
&cache_key,
quote_auth_config(),
&build_request,
)
.await
.expect_err("invalid crumb body is an auth error");
match err {
YfError::Auth(message) => assert!(
message.contains("invalid crumb response after refreshing credentials"),
"unexpected auth error: {message}",
),
other => panic!("expected auth error, got {other:?}"),
}
assert!(client.cache_get_key(&cache_key).is_none());
api.assert();
}
}