Skip to main content

vantage_api_client/rest/
api.rs

1use ciborium::Value as CborValue;
2use indexmap::IndexMap;
3use vantage_core::error;
4use vantage_dataset::traits::Result;
5use vantage_expressions::Expression;
6use vantage_expressions::traits::expressive::ExpressiveEnum;
7use vantage_table::pagination::Pagination;
8use vantage_types::Record;
9
10/// How the API wraps its row array in the response body.
11///
12/// Most public APIs use one of these three shapes; the legacy vantage
13/// "wrapped under `data`" shape is `Wrapped { array_key: "data" }`.
14#[derive(Clone, Debug)]
15pub enum ResponseShape {
16    /// Body is a bare JSON array of records.
17    /// Example: `GET /users` → `[ {…}, {…} ]`. JSONPlaceholder, GitHub, etc.
18    BareArray,
19
20    /// Body is a JSON object with the array under a fixed key.
21    /// Example: `GET /users` → `{ "data": [ … ] }`.
22    Wrapped { array_key: String },
23
24    /// Body is a JSON object with the array under a key matching the
25    /// table name. Example (DummyJSON):
26    /// `GET /products` → `{ "products": [ … ], "total": …, "skip": …, "limit": … }`.
27    WrappedByTableName,
28}
29
30impl Default for ResponseShape {
31    /// Default matches the legacy 0.1.x shape: `{ "data": [...] }`.
32    fn default() -> Self {
33        ResponseShape::Wrapped {
34            array_key: "data".to_string(),
35        }
36    }
37}
38
39/// Names of the page/limit query parameters the API expects.
40///
41/// Defaults to `("_page", "_limit")` — the JSON Server convention used
42/// by JSONPlaceholder. DummyJSON uses `("skip", "limit")` (in items not
43/// pages). Customise via `RestApiBuilder::pagination_params`.
44#[derive(Clone, Debug)]
45pub struct PaginationParams {
46    pub page: String,
47    pub limit: String,
48    /// If true, the page parameter is sent as a *0-based item offset*
49    /// (`skip`) instead of a 1-based page index. DummyJSON-style.
50    pub skip_based: bool,
51}
52
53impl PaginationParams {
54    pub fn page_limit(page: impl Into<String>, limit: impl Into<String>) -> Self {
55        Self {
56            page: page.into(),
57            limit: limit.into(),
58            skip_based: false,
59        }
60    }
61
62    pub fn skip_limit(skip: impl Into<String>, limit: impl Into<String>) -> Self {
63        Self {
64            page: skip.into(),
65            limit: limit.into(),
66            skip_based: true,
67        }
68    }
69}
70
71impl Default for PaginationParams {
72    fn default() -> Self {
73        Self::page_limit("_page", "_limit")
74    }
75}
76
77/// REST API backend for Vantage — reads data from HTTP JSON endpoints.
78///
79/// Each table maps to an API endpoint: `{base_url}/{table_name}`.
80/// Response shape is configurable via [`RestApi::builder`]; see
81/// [`ResponseShape`] for the supported variants.
82///
83/// Currently read-only — write operations return errors.
84/// How a table's conditions are applied to a request.
85///
86/// URL `{placeholder}` path segments are always filled from matching
87/// eq-conditions regardless of strategy; this governs what happens to
88/// the *remaining* (non-path) eq-conditions.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum FilterStrategy {
91    /// Append remaining eq-conditions as `?field=value` query params
92    /// (JSON-Server semantics). The default.
93    #[default]
94    Query,
95    /// Apply remaining eq-conditions as in-memory row filters after the
96    /// fetch, never as query params. For APIs whose only server-side
97    /// filters are path segments and that reject (or ignore) unknown
98    /// query params — e.g. the Mercury control-API, whose CLI likewise
99    /// filters version/env client-side after fetching by product path.
100    Client,
101}
102
103#[derive(Clone, Debug)]
104pub struct RestApi {
105    base_url: String,
106    client: reqwest::Client,
107    pub(crate) auth_header: Option<String>,
108    response_shape: ResponseShape,
109    pagination: PaginationParams,
110    /// When true, no `_page`/`_limit` query params are appended and
111    /// list endpoints are assumed to return the full result set in
112    /// one shot. Caller-side requests for page > 1 short-circuit to
113    /// an empty result so a perpetual-grid stops paging after the
114    /// first chunk. Useful for FastAPI/Pydantic services that treat
115    /// unknown query params as strict filters.
116    no_pagination: bool,
117    /// How non-path eq-conditions are applied — query params vs.
118    /// in-memory post-fetch filtering. See [`FilterStrategy`].
119    filter_strategy: FilterStrategy,
120}
121
122impl RestApi {
123    /// Create a new REST API pointing at `base_url`. Uses the legacy
124    /// default response shape (`{ "data": [...] }`). For other shapes
125    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
126    pub fn new(base_url: impl Into<String>) -> Self {
127        RestApi::builder(base_url).build()
128    }
129
130    /// Start configuring a [`RestApi`] via the builder.
131    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
132        RestApiBuilder::new(base_url.into())
133    }
134
135    /// Set the Authorization header value (e.g. "Bearer `<token>`").
136    /// Provided for backwards compatibility — prefer
137    /// `RestApi::builder(...).auth(...)`.
138    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
139        self.auth_header = Some(auth.into());
140        self
141    }
142
143    /// Build the endpoint path for `table_name`, substituting any
144    /// `{placeholder}` segments from matching eq-conditions.
145    ///
146    /// Returns the absolute URL up to (but excluding) the query string,
147    /// alongside the indices of conditions consumed by the substitution
148    /// — those are dropped from the query string by `build_query_string`.
149    ///
150    /// Tables that don't use templates (no `{}` in the name) pass
151    /// through unchanged and consume no conditions.
152    fn endpoint_url(
153        &self,
154        table_name: &str,
155        conditions: &[&Expression<CborValue>],
156    ) -> Result<(String, Vec<usize>)> {
157        let mut consumed = Vec::new();
158        let mut path = String::with_capacity(table_name.len());
159        let mut rest = table_name;
160        while let Some(open) = rest.find('{') {
161            path.push_str(&rest[..open]);
162            let after = &rest[open + 1..];
163            let close = after.find('}').ok_or_else(|| {
164                error!(
165                    "Unclosed `{` in table name URI template",
166                    table_name = table_name
167                )
168            })?;
169            let placeholder = &after[..close];
170            let (idx, value) = conditions
171                .iter()
172                .enumerate()
173                .find_map(|(i, cond)| {
174                    if consumed.contains(&i) {
175                        return None;
176                    }
177                    let (field, value) = crate::condition_to_query_param(cond)?;
178                    (field == placeholder).then_some((i, value))
179                })
180                .ok_or_else(|| {
181                    error!(
182                        "No eq-condition provided for URI placeholder",
183                        placeholder = placeholder,
184                        table_name = table_name
185                    )
186                })?;
187            consumed.push(idx);
188            path.push_str(&urlencode(&value));
189            rest = &after[close + 1..];
190        }
191        path.push_str(rest);
192        Ok((format!("{}/{}", self.base_url, path), consumed))
193    }
194
195    /// Build the combined query-string from pagination + conditions.
196    /// `consumed` lists condition indices already baked into the URI
197    /// path; those don't appear in the query string. Conditions that
198    /// don't peel cleanly into eq pairs are skipped — same "best effort"
199    /// stance as before.
200    fn build_query_string(
201        &self,
202        pagination: Option<&Pagination>,
203        conditions: &[&Expression<CborValue>],
204        consumed: &[usize],
205    ) -> String {
206        let mut params: Vec<(String, String)> = Vec::new();
207
208        // Pagination first — matches the order users see in the URL bar.
209        // When `no_pagination` is set the API doesn't accept page/limit
210        // query params (and may treat them as strict filters that
211        // return empty), so we leave them off.
212        if !self.no_pagination
213            && let Some(p) = pagination
214        {
215            let page_value = if self.pagination.skip_based {
216                p.skip().to_string()
217            } else {
218                p.get_page().to_string()
219            };
220            params.push((self.pagination.page.clone(), page_value));
221            params.push((self.pagination.limit.clone(), p.limit().to_string()));
222        }
223
224        // Conditions: each `eq` becomes `?field=value`. Multiple
225        // conditions AND together (JSON Server semantics).
226        for (i, cond) in conditions.iter().enumerate() {
227            if consumed.contains(&i) {
228                continue;
229            }
230            if let Some((field, value)) = crate::condition_to_query_param(cond) {
231                params.push((field, value));
232            }
233        }
234
235        if params.is_empty() {
236            return String::new();
237        }
238        let mut s = String::from("?");
239        for (i, (k, v)) in params.iter().enumerate() {
240            if i > 0 {
241                s.push('&');
242            }
243            // Minimal URL encoding — we encode `&` and `=` and spaces
244            // because those break the query format. Anything else
245            // passes through; the JSON Server convention is permissive.
246            s.push_str(&urlencode(k));
247            s.push('=');
248            s.push_str(&urlencode(v));
249        }
250        s
251    }
252
253    /// Fetch data from the API endpoint and return parsed records.
254    ///
255    /// `id_field` selects which JSON field is treated as the record ID;
256    /// if `None`, row indices are used. `pagination` and `conditions`
257    /// are pushed into the URL query string — eq-conditions become
258    /// `?field=value`. Conditions that can't be peeled into a simple
259    /// eq are silently skipped (caller-side filtering still applies if
260    /// needed).
261    pub(crate) async fn fetch_records<'a>(
262        &self,
263        table_name: &str,
264        id_field: Option<&str>,
265        pagination: Option<&Pagination>,
266        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
267    ) -> Result<IndexMap<String, Record<CborValue>>> {
268        // Non-paginating endpoints return the whole list on page 1; a
269        // page-2 fetch would just re-deliver the same rows and the
270        // perpetual grid would never mark itself exhausted. Short-
271        // circuit page > 1 to empty so the grid sees the chunk shrink
272        // and stops asking for more.
273        if self.no_pagination
274            && let Some(p) = pagination
275            && p.get_page() > 1
276        {
277            return Ok(IndexMap::new());
278        }
279
280        // Conditions may carry `DeferredFn` values — typically from
281        // `related_in_condition` for `with_one`-style traversals where
282        // the FK lives in a parent record we haven't fetched yet.
283        // Resolve them once, up front, so the rest of the pipeline
284        // sees only sync, peelable scalars.
285        let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
286        let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
287        for cond in raw {
288            resolved.push(resolve_deferreds(cond.clone()).await?);
289        }
290        let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
291        let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
292
293        // Under `FilterStrategy::Client`, non-path eq-conditions are
294        // applied to the fetched rows in memory rather than sent as query
295        // params (the API rejects/ignores unknown params). Collect them,
296        // and keep them out of the query string by marking every
297        // condition as consumed for query-building purposes.
298        let (query_consumed, client_filters): (Vec<usize>, Vec<(String, String)>) =
299            if self.filter_strategy == FilterStrategy::Client {
300                let filters = conds
301                    .iter()
302                    .enumerate()
303                    .filter(|(i, _)| !consumed.contains(i))
304                    .filter_map(|(_, c)| crate::condition_to_query_param(c))
305                    .collect();
306                ((0..conds.len()).collect(), filters)
307            } else {
308                (consumed, Vec::new())
309            };
310
311        let url = format!(
312            "{}{}",
313            endpoint,
314            self.build_query_string(pagination, &conds, &query_consumed)
315        );
316
317        let mut request = self.client.get(&url);
318        if let Some(ref auth) = self.auth_header {
319            request = request.header("Authorization", auth);
320        }
321
322        let response = request
323            .send()
324            .await
325            .map_err(|e| error!("API request failed", url = url, detail = e))?;
326
327        if !response.status().is_success() {
328            return Err(error!(
329                "API returned error status",
330                url = url,
331                status = response.status().as_u16()
332            ));
333        }
334
335        let body: serde_json::Value = response
336            .json()
337            .await
338            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
339
340        let data = self.extract_array(&body, table_name)?;
341
342        let mut records = IndexMap::new();
343        for (row_idx, item) in data.iter().enumerate() {
344            let obj = item
345                .as_object()
346                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
347
348            // Extract ID from the configured id_field, or use row index
349            let id = id_field
350                .and_then(|field| obj.get(field))
351                .and_then(|v| match v {
352                    serde_json::Value::String(s) => Some(s.clone()),
353                    serde_json::Value::Number(n) => Some(n.to_string()),
354                    _ => None,
355                })
356                .unwrap_or_else(|| row_idx.to_string());
357
358            // The HTTP body parses as JSON for free; convert to CBOR
359            // at this single boundary so the rest of the pipeline
360            // (Table, Vista) sees the universal carrier.
361            let mut record: Record<CborValue> = Record::new();
362            for (k, v) in obj {
363                let cbor = CborValue::serialized(v).map_err(|e| {
364                    error!(
365                        "JSON → CBOR conversion failed",
366                        field = k.clone(),
367                        detail = e.to_string()
368                    )
369                })?;
370                record.insert(k.clone(), cbor);
371            }
372
373            records.insert(id, record);
374        }
375
376        // Client-side filtering (FilterStrategy::Client): drop rows that
377        // don't match the non-path eq-conditions. A condition whose field
378        // is absent from a row is treated as a pass (it was a path/request
379        // param, not a record field) — mirroring the AWS connector and the
380        // Mercury CLI's own post-fetch `_filter_deployments`.
381        if !client_filters.is_empty() {
382            records.retain(|_id, record| {
383                client_filters
384                    .iter()
385                    .all(|(field, want)| match record.get(field) {
386                        Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
387                        None => true,
388                    })
389            });
390        }
391
392        Ok(records)
393    }
394}
395
396fn urlencode(s: &str) -> String {
397    urlencoding::encode(s).into_owned()
398}
399
400/// Walk an `Expression`'s parameter tree and force any `Deferred`
401/// branches to their resolved form. Used at the `fetch_records`
402/// boundary so the URL builder only sees sync scalars.
403///
404/// Recursion lives on the heap (boxed) because the future's body
405/// contains another `async` call of the same shape — Rust can't size
406/// a directly-recursive `async fn` without indirection.
407fn resolve_deferreds(
408    mut expr: Expression<CborValue>,
409) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
410    Box::pin(async move {
411        for param in expr.parameters.iter_mut() {
412            match param {
413                ExpressiveEnum::Deferred(deferred) => {
414                    *param = deferred.call().await?;
415                }
416                ExpressiveEnum::Nested(inner) => {
417                    let resolved = resolve_deferreds(inner.clone()).await?;
418                    *inner = resolved;
419                }
420                ExpressiveEnum::Scalar(_) => {}
421            }
422        }
423        Ok(expr)
424    })
425}
426
427impl RestApi {
428    /// Pull the row array out of the response body, according to the
429    /// configured `ResponseShape`.
430    fn extract_array<'a>(
431        &self,
432        body: &'a serde_json::Value,
433        table_name: &str,
434    ) -> Result<&'a Vec<serde_json::Value>> {
435        match &self.response_shape {
436            ResponseShape::BareArray => body.as_array().ok_or_else(|| {
437                error!("Expected response body to be a JSON array (BareArray shape)")
438            }),
439            ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
440                error!(
441                    "Response missing array under wrapper key",
442                    array_key = array_key
443                )
444            }),
445            ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
446                error!(
447                    "Response missing array under table-name key",
448                    table_name = table_name
449                )
450            }),
451        }
452    }
453}
454
455/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
456/// override the pagination parameter names.
457///
458/// ```no_run
459/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
460///
461/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
462/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
463///     .response_shape(ResponseShape::BareArray)
464///     .build();
465///
466/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
467/// let api = RestApi::builder("https://dummyjson.com")
468///     .response_shape(ResponseShape::WrappedByTableName)
469///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
470///     .build();
471/// ```
472#[derive(Clone, Debug)]
473pub struct RestApiBuilder {
474    base_url: String,
475    auth_header: Option<String>,
476    response_shape: ResponseShape,
477    pagination: PaginationParams,
478    no_pagination: bool,
479    filter_strategy: FilterStrategy,
480}
481
482impl RestApiBuilder {
483    fn new(base_url: String) -> Self {
484        Self {
485            base_url,
486            auth_header: None,
487            response_shape: ResponseShape::default(),
488            pagination: PaginationParams::default(),
489            no_pagination: false,
490            filter_strategy: FilterStrategy::default(),
491        }
492    }
493
494    /// Set the Authorization header value (e.g. "Bearer `<token>`").
495    pub fn auth(mut self, auth: impl Into<String>) -> Self {
496        self.auth_header = Some(auth.into());
497        self
498    }
499
500    /// Choose how the API wraps its row array. Defaults to
501    /// `Wrapped { array_key: "data" }` for backwards compat.
502    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
503        self.response_shape = shape;
504        self
505    }
506
507    /// Override the page/limit query parameter names. Default is
508    /// `("_page", "_limit")` (JSON Server convention).
509    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
510        self.pagination = pagination;
511        self
512    }
513
514    /// Disable pagination entirely — no `_page`/`_limit` query
515    /// params are appended, and a request for page > 1 is short-
516    /// circuited to an empty result. Use this for APIs that don't
517    /// paginate (return the full list every call) or that treat
518    /// unknown query params as strict filters.
519    pub fn no_pagination(mut self) -> Self {
520        self.no_pagination = true;
521        self
522    }
523
524    /// Choose how non-path eq-conditions are applied. Default is
525    /// [`FilterStrategy::Query`]; use [`FilterStrategy::Client`] for
526    /// APIs that only filter via path segments and reject/ignore unknown
527    /// query params (the conditions are then applied in memory).
528    pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
529        self.filter_strategy = strategy;
530        self
531    }
532
533    pub fn build(self) -> RestApi {
534        RestApi {
535            base_url: self.base_url,
536            client: reqwest::Client::new(),
537            auth_header: self.auth_header,
538            response_shape: self.response_shape,
539            pagination: self.pagination,
540            no_pagination: self.no_pagination,
541            filter_strategy: self.filter_strategy,
542        }
543    }
544}