use ciborium::Value as CborValue;
use indexmap::IndexMap;
use vantage_core::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;
#[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, Debug)]
pub struct RestApi {
base_url: String,
client: reqwest::Client,
pub(crate) auth_header: Option<String>,
response_shape: ResponseShape,
pagination: PaginationParams,
no_pagination: 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 = Some(auth.into());
self
}
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 build_query_string(
&self,
pagination: Option<&Pagination>,
conditions: &[&Expression<CborValue>],
consumed: &[usize],
) -> String {
let mut params: Vec<(String, String)> = Vec::new();
if !self.no_pagination
&& let Some(p) = pagination
{
let page_value = if self.pagination.skip_based {
p.skip().to_string()
} else {
p.get_page().to_string()
};
params.push((self.pagination.page.clone(), page_value));
params.push((self.pagination.limit.clone(), p.limit().to_string()));
}
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) 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>>> {
if self.no_pagination
&& let Some(p) = pagination
&& p.get_page() > 1
{
return Ok(IndexMap::new());
}
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 url = format!(
"{}{}",
endpoint,
self.build_query_string(pagination, &conds, &consumed)
);
let mut request = self.client.get(&url);
if let Some(ref auth) = self.auth_header {
request = request.header("Authorization", auth);
}
let response = request
.send()
.await
.map_err(|e| error!("API request failed", url = url, detail = e))?;
if !response.status().is_success() {
return Err(error!(
"API returned error status",
url = url,
status = response.status().as_u16()
));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
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 {
let cbor = CborValue::serialized(v).map_err(|e| {
error!(
"JSON → CBOR conversion failed",
field = k.clone(),
detail = e.to_string()
)
})?;
record.insert(k.clone(), cbor);
}
records.insert(id, record);
}
Ok(records)
}
}
fn urlencode(s: &str) -> String {
urlencoding::encode(s).into_owned()
}
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: Option<String>,
response_shape: ResponseShape,
pagination: PaginationParams,
no_pagination: bool,
}
impl RestApiBuilder {
fn new(base_url: String) -> Self {
Self {
base_url,
auth_header: None,
response_shape: ResponseShape::default(),
pagination: PaginationParams::default(),
no_pagination: false,
}
}
pub fn auth(mut self, auth: impl Into<String>) -> Self {
self.auth_header = Some(auth.into());
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 build(self) -> RestApi {
RestApi {
base_url: self.base_url,
client: reqwest::Client::new(),
auth_header: self.auth_header,
response_shape: self.response_shape,
pagination: self.pagination,
no_pagination: self.no_pagination,
}
}
}