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#[derive(Clone, Debug)]
85pub struct RestApi {
86    base_url: String,
87    client: reqwest::Client,
88    pub(crate) auth_header: Option<String>,
89    response_shape: ResponseShape,
90    pagination: PaginationParams,
91    /// When true, no `_page`/`_limit` query params are appended and
92    /// list endpoints are assumed to return the full result set in
93    /// one shot. Caller-side requests for page > 1 short-circuit to
94    /// an empty result so a perpetual-grid stops paging after the
95    /// first chunk. Useful for FastAPI/Pydantic services that treat
96    /// unknown query params as strict filters.
97    no_pagination: bool,
98}
99
100impl RestApi {
101    /// Create a new REST API pointing at `base_url`. Uses the legacy
102    /// default response shape (`{ "data": [...] }`). For other shapes
103    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
104    pub fn new(base_url: impl Into<String>) -> Self {
105        RestApi::builder(base_url).build()
106    }
107
108    /// Start configuring a [`RestApi`] via the builder.
109    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
110        RestApiBuilder::new(base_url.into())
111    }
112
113    /// Set the Authorization header value (e.g. "Bearer `<token>`").
114    /// Provided for backwards compatibility — prefer
115    /// `RestApi::builder(...).auth(...)`.
116    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
117        self.auth_header = Some(auth.into());
118        self
119    }
120
121    /// Build the endpoint path for `table_name`, substituting any
122    /// `{placeholder}` segments from matching eq-conditions.
123    ///
124    /// Returns the absolute URL up to (but excluding) the query string,
125    /// alongside the indices of conditions consumed by the substitution
126    /// — those are dropped from the query string by `build_query_string`.
127    ///
128    /// Tables that don't use templates (no `{}` in the name) pass
129    /// through unchanged and consume no conditions.
130    fn endpoint_url(
131        &self,
132        table_name: &str,
133        conditions: &[&Expression<CborValue>],
134    ) -> Result<(String, Vec<usize>)> {
135        let mut consumed = Vec::new();
136        let mut path = String::with_capacity(table_name.len());
137        let mut rest = table_name;
138        while let Some(open) = rest.find('{') {
139            path.push_str(&rest[..open]);
140            let after = &rest[open + 1..];
141            let close = after.find('}').ok_or_else(|| {
142                error!(
143                    "Unclosed `{` in table name URI template",
144                    table_name = table_name
145                )
146            })?;
147            let placeholder = &after[..close];
148            let (idx, value) = conditions
149                .iter()
150                .enumerate()
151                .find_map(|(i, cond)| {
152                    if consumed.contains(&i) {
153                        return None;
154                    }
155                    let (field, value) = crate::condition_to_query_param(cond)?;
156                    (field == placeholder).then_some((i, value))
157                })
158                .ok_or_else(|| {
159                    error!(
160                        "No eq-condition provided for URI placeholder",
161                        placeholder = placeholder,
162                        table_name = table_name
163                    )
164                })?;
165            consumed.push(idx);
166            path.push_str(&urlencode(&value));
167            rest = &after[close + 1..];
168        }
169        path.push_str(rest);
170        Ok((format!("{}/{}", self.base_url, path), consumed))
171    }
172
173    /// Build the combined query-string from pagination + conditions.
174    /// `consumed` lists condition indices already baked into the URI
175    /// path; those don't appear in the query string. Conditions that
176    /// don't peel cleanly into eq pairs are skipped — same "best effort"
177    /// stance as before.
178    fn build_query_string(
179        &self,
180        pagination: Option<&Pagination>,
181        conditions: &[&Expression<CborValue>],
182        consumed: &[usize],
183    ) -> String {
184        let mut params: Vec<(String, String)> = Vec::new();
185
186        // Pagination first — matches the order users see in the URL bar.
187        // When `no_pagination` is set the API doesn't accept page/limit
188        // query params (and may treat them as strict filters that
189        // return empty), so we leave them off.
190        if !self.no_pagination
191            && let Some(p) = pagination
192        {
193            let page_value = if self.pagination.skip_based {
194                p.skip().to_string()
195            } else {
196                p.get_page().to_string()
197            };
198            params.push((self.pagination.page.clone(), page_value));
199            params.push((self.pagination.limit.clone(), p.limit().to_string()));
200        }
201
202        // Conditions: each `eq` becomes `?field=value`. Multiple
203        // conditions AND together (JSON Server semantics).
204        for (i, cond) in conditions.iter().enumerate() {
205            if consumed.contains(&i) {
206                continue;
207            }
208            if let Some((field, value)) = crate::condition_to_query_param(cond) {
209                params.push((field, value));
210            }
211        }
212
213        if params.is_empty() {
214            return String::new();
215        }
216        let mut s = String::from("?");
217        for (i, (k, v)) in params.iter().enumerate() {
218            if i > 0 {
219                s.push('&');
220            }
221            // Minimal URL encoding — we encode `&` and `=` and spaces
222            // because those break the query format. Anything else
223            // passes through; the JSON Server convention is permissive.
224            s.push_str(&urlencode(k));
225            s.push('=');
226            s.push_str(&urlencode(v));
227        }
228        s
229    }
230
231    /// Fetch data from the API endpoint and return parsed records.
232    ///
233    /// `id_field` selects which JSON field is treated as the record ID;
234    /// if `None`, row indices are used. `pagination` and `conditions`
235    /// are pushed into the URL query string — eq-conditions become
236    /// `?field=value`. Conditions that can't be peeled into a simple
237    /// eq are silently skipped (caller-side filtering still applies if
238    /// needed).
239    pub(crate) async fn fetch_records<'a>(
240        &self,
241        table_name: &str,
242        id_field: Option<&str>,
243        pagination: Option<&Pagination>,
244        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
245    ) -> Result<IndexMap<String, Record<CborValue>>> {
246        // Non-paginating endpoints return the whole list on page 1; a
247        // page-2 fetch would just re-deliver the same rows and the
248        // perpetual grid would never mark itself exhausted. Short-
249        // circuit page > 1 to empty so the grid sees the chunk shrink
250        // and stops asking for more.
251        if self.no_pagination
252            && let Some(p) = pagination
253            && p.get_page() > 1
254        {
255            return Ok(IndexMap::new());
256        }
257
258        // Conditions may carry `DeferredFn` values — typically from
259        // `related_in_condition` for `with_one`-style traversals where
260        // the FK lives in a parent record we haven't fetched yet.
261        // Resolve them once, up front, so the rest of the pipeline
262        // sees only sync, peelable scalars.
263        let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
264        let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
265        for cond in raw {
266            resolved.push(resolve_deferreds(cond.clone()).await?);
267        }
268        let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
269        let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
270        let url = format!(
271            "{}{}",
272            endpoint,
273            self.build_query_string(pagination, &conds, &consumed)
274        );
275
276        let mut request = self.client.get(&url);
277        if let Some(ref auth) = self.auth_header {
278            request = request.header("Authorization", auth);
279        }
280
281        let response = request
282            .send()
283            .await
284            .map_err(|e| error!("API request failed", url = url, detail = e))?;
285
286        if !response.status().is_success() {
287            return Err(error!(
288                "API returned error status",
289                url = url,
290                status = response.status().as_u16()
291            ));
292        }
293
294        let body: serde_json::Value = response
295            .json()
296            .await
297            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
298
299        let data = self.extract_array(&body, table_name)?;
300
301        let mut records = IndexMap::new();
302        for (row_idx, item) in data.iter().enumerate() {
303            let obj = item
304                .as_object()
305                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
306
307            // Extract ID from the configured id_field, or use row index
308            let id = id_field
309                .and_then(|field| obj.get(field))
310                .and_then(|v| match v {
311                    serde_json::Value::String(s) => Some(s.clone()),
312                    serde_json::Value::Number(n) => Some(n.to_string()),
313                    _ => None,
314                })
315                .unwrap_or_else(|| row_idx.to_string());
316
317            // The HTTP body parses as JSON for free; convert to CBOR
318            // at this single boundary so the rest of the pipeline
319            // (Table, Vista, AnyTable) sees the universal carrier.
320            let mut record: Record<CborValue> = Record::new();
321            for (k, v) in obj {
322                let cbor = CborValue::serialized(v).map_err(|e| {
323                    error!(
324                        "JSON → CBOR conversion failed",
325                        field = k.clone(),
326                        detail = e.to_string()
327                    )
328                })?;
329                record.insert(k.clone(), cbor);
330            }
331
332            records.insert(id, record);
333        }
334
335        Ok(records)
336    }
337}
338
339fn urlencode(s: &str) -> String {
340    urlencoding::encode(s).into_owned()
341}
342
343/// Walk an `Expression`'s parameter tree and force any `Deferred`
344/// branches to their resolved form. Used at the `fetch_records`
345/// boundary so the URL builder only sees sync scalars.
346///
347/// Recursion lives on the heap (boxed) because the future's body
348/// contains another `async` call of the same shape — Rust can't size
349/// a directly-recursive `async fn` without indirection.
350fn resolve_deferreds(
351    mut expr: Expression<CborValue>,
352) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
353    Box::pin(async move {
354        for param in expr.parameters.iter_mut() {
355            match param {
356                ExpressiveEnum::Deferred(deferred) => {
357                    *param = deferred.call().await?;
358                }
359                ExpressiveEnum::Nested(inner) => {
360                    let resolved = resolve_deferreds(inner.clone()).await?;
361                    *inner = resolved;
362                }
363                ExpressiveEnum::Scalar(_) => {}
364            }
365        }
366        Ok(expr)
367    })
368}
369
370impl RestApi {
371    /// Pull the row array out of the response body, according to the
372    /// configured `ResponseShape`.
373    fn extract_array<'a>(
374        &self,
375        body: &'a serde_json::Value,
376        table_name: &str,
377    ) -> Result<&'a Vec<serde_json::Value>> {
378        match &self.response_shape {
379            ResponseShape::BareArray => body.as_array().ok_or_else(|| {
380                error!("Expected response body to be a JSON array (BareArray shape)")
381            }),
382            ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
383                error!(
384                    "Response missing array under wrapper key",
385                    array_key = array_key
386                )
387            }),
388            ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
389                error!(
390                    "Response missing array under table-name key",
391                    table_name = table_name
392                )
393            }),
394        }
395    }
396}
397
398/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
399/// override the pagination parameter names.
400///
401/// ```no_run
402/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
403///
404/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
405/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
406///     .response_shape(ResponseShape::BareArray)
407///     .build();
408///
409/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
410/// let api = RestApi::builder("https://dummyjson.com")
411///     .response_shape(ResponseShape::WrappedByTableName)
412///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
413///     .build();
414/// ```
415#[derive(Clone, Debug)]
416pub struct RestApiBuilder {
417    base_url: String,
418    auth_header: Option<String>,
419    response_shape: ResponseShape,
420    pagination: PaginationParams,
421    no_pagination: bool,
422}
423
424impl RestApiBuilder {
425    fn new(base_url: String) -> Self {
426        Self {
427            base_url,
428            auth_header: None,
429            response_shape: ResponseShape::default(),
430            pagination: PaginationParams::default(),
431            no_pagination: false,
432        }
433    }
434
435    /// Set the Authorization header value (e.g. "Bearer `<token>`").
436    pub fn auth(mut self, auth: impl Into<String>) -> Self {
437        self.auth_header = Some(auth.into());
438        self
439    }
440
441    /// Choose how the API wraps its row array. Defaults to
442    /// `Wrapped { array_key: "data" }` for backwards compat.
443    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
444        self.response_shape = shape;
445        self
446    }
447
448    /// Override the page/limit query parameter names. Default is
449    /// `("_page", "_limit")` (JSON Server convention).
450    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
451        self.pagination = pagination;
452        self
453    }
454
455    /// Disable pagination entirely — no `_page`/`_limit` query
456    /// params are appended, and a request for page > 1 is short-
457    /// circuited to an empty result. Use this for APIs that don't
458    /// paginate (return the full list every call) or that treat
459    /// unknown query params as strict filters.
460    pub fn no_pagination(mut self) -> Self {
461        self.no_pagination = true;
462        self
463    }
464
465    pub fn build(self) -> RestApi {
466        RestApi {
467            base_url: self.base_url,
468            client: reqwest::Client::new(),
469            auth_header: self.auth_header,
470            response_shape: self.response_shape,
471            pagination: self.pagination,
472            no_pagination: self.no_pagination,
473        }
474    }
475}