use std::sync::Arc;
use ciborium::Value as CborValue;
use indexmap::IndexMap;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
use vantage_api_pool::resilient::{ResilientClient, TransportEvent, TransportObserver};
use vantage_core::{Priority, error};
use vantage_dataset::traits::Result;
use vantage_expressions::Expression;
use vantage_expressions::traits::expressive::ExpressiveEnum;
use vantage_table::pagination::Pagination;
use vantage_types::Record;
use crate::transport::AuthHeader;
#[derive(Clone, Debug)]
pub enum ResponseShape {
BareArray,
Wrapped { array_key: String },
WrappedByTableName,
}
impl Default for ResponseShape {
fn default() -> Self {
ResponseShape::Wrapped {
array_key: "data".to_string(),
}
}
}
#[derive(Clone, Debug)]
pub struct PaginationParams {
pub page: String,
pub limit: String,
pub skip_based: bool,
}
impl PaginationParams {
pub fn page_limit(page: impl Into<String>, limit: impl Into<String>) -> Self {
Self {
page: page.into(),
limit: limit.into(),
skip_based: false,
}
}
pub fn skip_limit(skip: impl Into<String>, limit: impl Into<String>) -> Self {
Self {
page: skip.into(),
limit: limit.into(),
skip_based: true,
}
}
}
impl Default for PaginationParams {
fn default() -> Self {
Self::page_limit("_page", "_limit")
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FilterStrategy {
#[default]
Query,
Client,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OrderingParams {
pub param: String,
pub desc_prefix: String,
}
impl OrderingParams {
pub fn new(param: impl Into<String>, desc_prefix: impl Into<String>) -> Self {
Self {
param: param.into(),
desc_prefix: desc_prefix.into(),
}
}
pub fn value(&self, field: &str, dir: vantage_vista::SortDirection) -> String {
match dir {
vantage_vista::SortDirection::Ascending => field.to_string(),
vantage_vista::SortDirection::Descending => format!("{}{field}", self.desc_prefix),
}
}
}
pub(crate) type Order<'a> = Option<(&'a str, vantage_vista::SortDirection)>;
#[derive(Clone, Debug)]
pub struct RestApi {
base_url: String,
client: ResilientClient,
pub(crate) auth_header: AuthHeader,
response_shape: ResponseShape,
pagination: PaginationParams,
no_pagination: bool,
filter_strategy: FilterStrategy,
total_key: Option<String>,
ordering: Option<OrderingParams>,
debug: bool,
}
impl RestApi {
pub fn new(base_url: impl Into<String>) -> Self {
RestApi::builder(base_url).build()
}
pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
RestApiBuilder::new(base_url.into())
}
pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
self.auth_header = AuthHeader::new(auth);
self
}
pub fn total_key(&self) -> Option<&str> {
self.total_key.as_deref()
}
pub fn ordering(&self) -> Option<&OrderingParams> {
self.ordering.as_ref()
}
pub fn breaker_state(&self) -> Option<crate::BreakerState> {
self.client.breaker_state()
}
pub fn client(&self) -> &ResilientClient {
&self.client
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn http_request(
&self,
method: reqwest::Method,
path: &str,
headers: &[(&str, &str)],
body: Option<&serde_json::Value>,
) -> vantage_core::Result<reqwest::Response> {
let url = join_base_path(&self.base_url, path);
let mut header_map = HeaderMap::new();
if let Some(auth) = self.auth_header.value() {
header_map.insert(
AUTHORIZATION,
HeaderValue::from_str(auth)
.expect("configured auth header must be a valid header value"),
);
}
for (name, value) in headers {
header_map.insert(
HeaderName::from_bytes(name.as_bytes()).expect("header name must be valid"),
HeaderValue::from_str(value).expect("header value must be valid"),
);
}
let policy = crate::transport::policy_for(Priority::current());
let response = self
.client
.execute_with(&policy, |http| {
let req = http
.request(method.clone(), &url)
.headers(header_map.clone());
match body {
Some(body) => req.json(body),
None => req,
}
})
.await
.map_err(|e| crate::transport::client_error(e, "API request failed", &url))?;
if method != reqwest::Method::GET {
self.client.report(TransportEvent::WritePushed);
}
Ok(response)
}
fn endpoint_url(
&self,
table_name: &str,
conditions: &[&Expression<CborValue>],
) -> Result<(String, Vec<usize>)> {
let mut consumed = Vec::new();
let mut path = String::with_capacity(table_name.len());
let mut rest = table_name;
while let Some(open) = rest.find('{') {
path.push_str(&rest[..open]);
let after = &rest[open + 1..];
let close = after.find('}').ok_or_else(|| {
error!(
"Unclosed `{` in table name URI template",
table_name = table_name
)
})?;
let placeholder = &after[..close];
let (idx, value) = conditions
.iter()
.enumerate()
.find_map(|(i, cond)| {
if consumed.contains(&i) {
return None;
}
let (field, value) = crate::condition_to_query_param(cond)?;
(field == placeholder).then_some((i, value))
})
.ok_or_else(|| {
error!(
"No eq-condition provided for URI placeholder",
placeholder = placeholder,
table_name = table_name
)
})?;
consumed.push(idx);
path.push_str(&urlencode(&value));
rest = &after[close + 1..];
}
path.push_str(rest);
Ok((format!("{}/{}", self.base_url, path), consumed))
}
fn split_filters(
&self,
conds: &[&Expression<CborValue>],
consumed: Vec<usize>,
) -> (Vec<usize>, Vec<(String, String)>) {
if self.filter_strategy == FilterStrategy::Client {
let filters = conds
.iter()
.enumerate()
.filter(|(i, _)| !consumed.contains(i))
.filter_map(|(_, c)| crate::condition_to_query_param(c))
.collect();
((0..conds.len()).collect(), filters)
} else {
(consumed, Vec::new())
}
}
fn build_query_string(
&self,
window: Option<(i64, i64)>,
conditions: &[&Expression<CborValue>],
consumed: &[usize],
order: Order<'_>,
) -> String {
let mut params: Vec<(String, String)> = Vec::new();
if !self.no_pagination
&& let Some((offset, limit)) = window
{
let offset = offset.max(0);
let limit = limit.max(1);
let page_value = if self.pagination.skip_based {
offset.to_string()
} else {
(offset / limit + 1).to_string()
};
params.push((self.pagination.page.clone(), page_value));
params.push((self.pagination.limit.clone(), limit.to_string()));
}
if let (Some(spec), Some((field, dir))) = (&self.ordering, order) {
params.push((spec.param.clone(), spec.value(field, dir)));
}
for (i, cond) in conditions.iter().enumerate() {
if consumed.contains(&i) {
continue;
}
if let Some((field, value)) = crate::condition_to_query_param(cond) {
params.push((field, value));
}
}
if params.is_empty() {
return String::new();
}
let mut s = String::from("?");
for (i, (k, v)) in params.iter().enumerate() {
if i > 0 {
s.push('&');
}
s.push_str(&urlencode(k));
s.push('=');
s.push_str(&urlencode(v));
}
s
}
pub(crate) fn preview_request<'a>(
&self,
table_name: &str,
window: Option<(i64, i64)>,
order: Order<'_>,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> serde_json::Value {
let conds: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
let unresolved = conds
.iter()
.filter(|c| crate::condition_to_query_param(c).is_none())
.count();
let (endpoint, consumed) = match self.endpoint_url(table_name, &conds) {
Ok(pair) => pair,
Err(_) if unresolved > 0 => {
return serde_json::json!({
"driver": "rest-api",
"method": "GET",
"url": format!("{}/{}", self.base_url, table_name),
"unresolved_conditions": unresolved,
"note": "path placeholders are filled from conditions resolved \
at fetch time; the template is shown unfilled",
});
}
Err(e) => {
return serde_json::json!({
"driver": "rest-api",
"base_url": self.base_url,
"error": e.to_string(),
});
}
};
let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
let query = self.build_query_string(window, &conds, &query_consumed, order);
serde_json::json!({
"driver": "rest-api",
"method": "GET",
"url": join_query(&endpoint, &query),
"auth_header": self.auth_header.masked(),
"client_side_filters": client_filters
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>(),
"unresolved_conditions": unresolved,
})
}
pub(crate) async fn fetch_records<'a>(
&self,
table_name: &str,
id_field: Option<&str>,
pagination: Option<&Pagination>,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> Result<IndexMap<String, Record<CborValue>>> {
let window = pagination.map(|p| (p.skip(), p.limit()));
self.fetch_windowed(table_name, id_field, window, None, conditions)
.await
.map(|(records, _total)| records)
}
pub(crate) async fn fetch_window_records_counted<'a>(
&self,
table_name: &str,
id_field: Option<&str>,
offset: i64,
limit: i64,
order: Order<'_>,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
self.fetch_windowed(
table_name,
id_field,
Some((offset, limit)),
order,
conditions,
)
.await
}
pub(crate) async fn fetch_total<'a>(
&self,
table_name: &str,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> Result<Option<i64>> {
let Some(total_key) = self.total_key.clone() else {
return Ok(None);
};
let (body, _client_filters) = self
.fetch_raw_body(table_name, Some((0, 1)), None, conditions)
.await?;
let total = body
.get(total_key.as_str())
.and_then(|v| v.as_i64())
.ok_or_else(|| {
error!(
"total_key missing or not an integer in API response",
total_key = total_key.as_str()
)
})?;
if self.debug {
tracing::debug!(target: "vantage_api_client::rest", total, "REST count");
}
Ok(Some(total))
}
async fn fetch_raw_body<'a>(
&self,
table_name: &str,
window: Option<(i64, i64)>,
order: Order<'_>,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> Result<(serde_json::Value, Vec<(String, String)>)> {
let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
for cond in raw {
resolved.push(resolve_deferreds(cond.clone()).await?);
}
let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
let query = self.build_query_string(window, &conds, &query_consumed, order);
let url = join_query(&endpoint, &query);
if self.debug {
if window == Some((0, 1)) {
tracing::debug!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET (count probe)");
} else {
tracing::info!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET");
}
}
let started = std::time::Instant::now();
let policy = crate::transport::policy_for(Priority::current());
let response = self
.client
.execute_with(&policy, |http| {
let req = http.get(&url);
match self.auth_header.value() {
Some(auth) => req.header(AUTHORIZATION, auth),
None => req,
}
})
.await
.map_err(|e| {
let ms = started.elapsed().as_millis() as u64;
if e.is_final() {
tracing::warn!(
target: "vantage_api_client::rest",
table = table_name,
url = %url,
ms,
attempts = e.attempts,
"REST GET failed",
);
} else {
tracing::debug!(
target: "vantage_api_client::rest",
table = table_name,
url = %url,
ms,
attempts = e.attempts,
kind = e.kind_name(),
"REST GET gave up on a transient failure",
);
}
crate::transport::client_error(e, "API request failed", &url)
})?;
let body: serde_json::Value = response
.json()
.await
.map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
let ms = started.elapsed().as_millis() as u64;
let probe = window == Some((0, 1));
if ms >= 1000 {
tracing::info!(
target: "vantage_api_client::rest",
table = table_name,
url = %url,
ms,
count_probe = probe,
"slow REST GET",
);
} else {
tracing::debug!(
target: "vantage_api_client::rest",
table = table_name,
url = %url,
ms,
count_probe = probe,
"REST GET done",
);
}
Ok((body, client_filters))
}
async fn fetch_windowed<'a>(
&self,
table_name: &str,
id_field: Option<&str>,
window: Option<(i64, i64)>,
order: Order<'_>,
conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
if self.no_pagination && window.is_some_and(|(offset, _)| offset > 0) {
return Ok((IndexMap::new(), None));
}
let (body, client_filters) = self
.fetch_raw_body(table_name, window, order, conditions)
.await?;
let total = self
.total_key
.as_deref()
.and_then(|key| body.get(key))
.and_then(|v| v.as_i64());
let data = self.extract_array(&body, table_name)?;
let mut records = IndexMap::new();
for (row_idx, item) in data.iter().enumerate() {
let obj = item
.as_object()
.ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
let id = id_field
.and_then(|field| obj.get(field))
.and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_else(|| row_idx.to_string());
let mut record: Record<CborValue> = Record::new();
for (k, v) in obj {
record.insert(k.clone(), vantage_types::json_to_cbor(v.clone()));
}
records.insert(id, record);
}
if !client_filters.is_empty() {
records.retain(|_id, record| {
client_filters
.iter()
.all(|(field, want)| match record.get(field) {
Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
None => true,
})
});
}
self.client
.report(TransportEvent::RowsPulled { n: records.len() });
let total = if client_filters.is_empty() {
total
} else {
None
};
Ok((records, total))
}
}
fn urlencode(s: &str) -> String {
urlencoding::encode(s).into_owned()
}
fn join_base_path(base: &str, path: &str) -> String {
format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
fn join_query(endpoint: &str, query: &str) -> String {
match query.strip_prefix('?') {
Some(rest) if endpoint.contains('?') => format!("{endpoint}&{rest}"),
_ => format!("{endpoint}{query}"),
}
}
fn resolve_deferreds(
mut expr: Expression<CborValue>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
Box::pin(async move {
for param in expr.parameters.iter_mut() {
match param {
ExpressiveEnum::Deferred(deferred) => {
*param = deferred.call().await?;
}
ExpressiveEnum::Nested(inner) => {
let resolved = resolve_deferreds(inner.clone()).await?;
*inner = resolved;
}
ExpressiveEnum::Scalar(_) => {}
}
}
Ok(expr)
})
}
impl RestApi {
fn extract_array<'a>(
&self,
body: &'a serde_json::Value,
table_name: &str,
) -> Result<&'a Vec<serde_json::Value>> {
match &self.response_shape {
ResponseShape::BareArray => body.as_array().ok_or_else(|| {
error!("Expected response body to be a JSON array (BareArray shape)")
}),
ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
error!(
"Response missing array under wrapper key",
array_key = array_key
)
}),
ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
error!(
"Response missing array under table-name key",
table_name = table_name
)
}),
}
}
}
#[derive(Clone, Debug)]
pub struct RestApiBuilder {
base_url: String,
auth_header: AuthHeader,
response_shape: ResponseShape,
pagination: PaginationParams,
no_pagination: bool,
filter_strategy: FilterStrategy,
total_key: Option<String>,
ordering: Option<OrderingParams>,
debug: bool,
transport: crate::transport::ClientConfig,
}
impl RestApiBuilder {
fn new(base_url: String) -> Self {
Self {
base_url,
auth_header: AuthHeader::default(),
response_shape: ResponseShape::default(),
pagination: PaginationParams::default(),
no_pagination: false,
filter_strategy: FilterStrategy::default(),
total_key: None,
ordering: None,
debug: false,
transport: crate::transport::ClientConfig::default(),
}
}
pub fn max_parallel(mut self, n: usize) -> Self {
self.transport.max_parallel = n.max(1);
self
}
pub fn rate_limit(mut self, per_second: f64) -> Self {
self.transport.rate_limit = Some(per_second);
self
}
pub fn observer(
mut self,
key: impl Into<Arc<str>>,
observer: Arc<dyn TransportObserver>,
) -> Self {
self.transport.observer = Some((key.into(), observer));
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.transport.http = Some(client);
self
}
pub fn auth(mut self, auth: impl Into<String>) -> Self {
self.auth_header = AuthHeader::new(auth);
self
}
pub fn response_shape(mut self, shape: ResponseShape) -> Self {
self.response_shape = shape;
self
}
pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
self.pagination = pagination;
self
}
pub fn no_pagination(mut self) -> Self {
self.no_pagination = true;
self
}
pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
self.filter_strategy = strategy;
self
}
pub fn total_key(mut self, key: impl Into<String>) -> Self {
self.total_key = Some(key.into());
self
}
pub fn ordering(mut self, ordering: OrderingParams) -> Self {
self.ordering = Some(ordering);
self
}
pub fn debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn build(self) -> RestApi {
RestApi {
base_url: self.base_url,
client: crate::transport::build_client(self.transport),
auth_header: self.auth_header,
response_shape: self.response_shape,
pagination: self.pagination,
no_pagination: self.no_pagination,
filter_strategy: self.filter_strategy,
total_key: self.total_key,
ordering: self.ordering,
debug: self.debug,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn qs(api: &RestApi, window: Option<(i64, i64)>) -> String {
api.build_query_string(window, &[], &[], None)
}
#[test]
fn debug_masks_auth_header() {
let api = RestApi::builder("http://x")
.auth("Bearer secret-token")
.build();
let text = format!("{api:?}");
assert!(!text.contains("secret-token"), "{text}");
assert!(text.contains("<set>"), "{text}");
}
#[test]
fn skip_based_window_uses_offset_verbatim() {
let api = RestApi::builder("http://x")
.pagination_params(PaginationParams::skip_limit("skip", "limit"))
.build();
assert_eq!(qs(&api, Some((20, 10))), "?skip=20&limit=10");
}
#[test]
fn page_based_window_derives_one_based_page() {
let api = RestApi::builder("http://x").build(); assert_eq!(qs(&api, Some((20, 10))), "?_page=3&_limit=10");
}
#[test]
fn no_window_emits_no_pagination_params() {
let api = RestApi::builder("http://x").build();
assert_eq!(qs(&api, None), "");
}
#[test]
fn no_pagination_suppresses_window_params() {
let api = RestApi::builder("http://x").no_pagination().build();
assert_eq!(qs(&api, Some((20, 10))), "");
}
#[test]
fn query_string_joins_plain_endpoint_with_question_mark() {
assert_eq!(
join_query("http://x/launches/", "?_page=1&_limit=10"),
"http://x/launches/?_page=1&_limit=10"
);
}
#[test]
fn query_string_joins_templated_endpoint_with_ampersand() {
assert_eq!(
join_query("http://x/launches/?mode=detailed", "?offset=0&limit=1"),
"http://x/launches/?mode=detailed&offset=0&limit=1"
);
}
#[test]
fn empty_query_string_leaves_endpoint_untouched() {
assert_eq!(
join_query("http://x/launches/?mode=detailed", ""),
"http://x/launches/?mode=detailed"
);
}
#[tokio::test]
#[ignore = "hits the live Launch Library 2 dev API"]
async fn live_templated_table_path_fetches_rows() {
let api = RestApi::builder("https://lldev.thespacedevs.com/2.3.0")
.pagination_params(PaginationParams::skip_limit("offset", "limit"))
.response_shape(ResponseShape::Wrapped {
array_key: "results".into(),
})
.total_key("count")
.build();
let total = api
.fetch_total("launches/?mode=detailed", [])
.await
.expect("fetch_total");
assert!(total.is_some_and(|n| n > 0), "expected a positive count");
let (rows, window_total) = api
.fetch_window_records_counted("launches/?mode=detailed", Some("id"), 0, 3, None, [])
.await
.expect("fetch_window_records_counted");
assert_eq!(rows.len(), 3, "expected the requested 3-row window");
assert_eq!(
window_total, total,
"the window's envelope total should match the dedicated count",
);
}
}