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    /// Response-envelope key carrying the grand total of matching rows
121    /// (e.g. `count`). When set, the shell reports an exact count and
122    /// advertises `can_fetch_window` for lazy/scroll loading; when `None`
123    /// it falls back to counting fetched rows.
124    total_key: Option<String>,
125    /// Emit `tracing` events for window/count requests.
126    debug: bool,
127}
128
129impl RestApi {
130    /// Create a new REST API pointing at `base_url`. Uses the legacy
131    /// default response shape (`{ "data": [...] }`). For other shapes
132    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
133    pub fn new(base_url: impl Into<String>) -> Self {
134        RestApi::builder(base_url).build()
135    }
136
137    /// Start configuring a [`RestApi`] via the builder.
138    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
139        RestApiBuilder::new(base_url.into())
140    }
141
142    /// Set the Authorization header value (e.g. "Bearer `<token>`").
143    /// Provided for backwards compatibility — prefer
144    /// `RestApi::builder(...).auth(...)`.
145    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
146        self.auth_header = Some(auth.into());
147        self
148    }
149
150    /// The configured response-envelope total key, if any. When set, the
151    /// REST shell can report an exact count and serve `fetch_window`.
152    pub fn total_key(&self) -> Option<&str> {
153        self.total_key.as_deref()
154    }
155
156    /// Build the endpoint path for `table_name`, substituting any
157    /// `{placeholder}` segments from matching eq-conditions.
158    ///
159    /// Returns the absolute URL up to (but excluding) the query string,
160    /// alongside the indices of conditions consumed by the substitution
161    /// — those are dropped from the query string by `build_query_string`.
162    ///
163    /// Tables that don't use templates (no `{}` in the name) pass
164    /// through unchanged and consume no conditions.
165    fn endpoint_url(
166        &self,
167        table_name: &str,
168        conditions: &[&Expression<CborValue>],
169    ) -> Result<(String, Vec<usize>)> {
170        let mut consumed = Vec::new();
171        let mut path = String::with_capacity(table_name.len());
172        let mut rest = table_name;
173        while let Some(open) = rest.find('{') {
174            path.push_str(&rest[..open]);
175            let after = &rest[open + 1..];
176            let close = after.find('}').ok_or_else(|| {
177                error!(
178                    "Unclosed `{` in table name URI template",
179                    table_name = table_name
180                )
181            })?;
182            let placeholder = &after[..close];
183            let (idx, value) = conditions
184                .iter()
185                .enumerate()
186                .find_map(|(i, cond)| {
187                    if consumed.contains(&i) {
188                        return None;
189                    }
190                    let (field, value) = crate::condition_to_query_param(cond)?;
191                    (field == placeholder).then_some((i, value))
192                })
193                .ok_or_else(|| {
194                    error!(
195                        "No eq-condition provided for URI placeholder",
196                        placeholder = placeholder,
197                        table_name = table_name
198                    )
199                })?;
200            consumed.push(idx);
201            path.push_str(&urlencode(&value));
202            rest = &after[close + 1..];
203        }
204        path.push_str(rest);
205        Ok((format!("{}/{}", self.base_url, path), consumed))
206    }
207
208    /// Decide which conditions go in the query string and which are applied to
209    /// the rows after they arrive.
210    ///
211    /// Under [`FilterStrategy::Client`] non-path eq-conditions are *not* sent —
212    /// the API rejects or ignores unknown params — so they come back as
213    /// client-side filters and every condition is marked consumed to keep it out
214    /// of the URL. Otherwise nothing is filtered locally and only the path
215    /// placeholders are consumed.
216    ///
217    /// Shared by the real fetch and by [`preview_request`](Self::preview_request)
218    /// so a previewed URL cannot claim a filter the fetch would have applied in
219    /// memory, or vice versa.
220    fn split_filters(
221        &self,
222        conds: &[&Expression<CborValue>],
223        consumed: Vec<usize>,
224    ) -> (Vec<usize>, Vec<(String, String)>) {
225        if self.filter_strategy == FilterStrategy::Client {
226            let filters = conds
227                .iter()
228                .enumerate()
229                .filter(|(i, _)| !consumed.contains(i))
230                .filter_map(|(_, c)| crate::condition_to_query_param(c))
231                .collect();
232            ((0..conds.len()).collect(), filters)
233        } else {
234            (consumed, Vec::new())
235        }
236    }
237
238    /// Build the combined query-string from pagination + conditions.
239    /// `consumed` lists condition indices already baked into the URI
240    /// path; those don't appear in the query string. Conditions that
241    /// don't peel cleanly into eq pairs are skipped — same "best effort"
242    /// stance as before.
243    fn build_query_string(
244        &self,
245        window: Option<(i64, i64)>,
246        conditions: &[&Expression<CborValue>],
247        consumed: &[usize],
248    ) -> String {
249        let mut params: Vec<(String, String)> = Vec::new();
250
251        // Pagination first — matches the order users see in the URL bar.
252        // When `no_pagination` is set the API doesn't accept page/limit
253        // query params (and may treat them as strict filters that
254        // return empty), so we leave them off.
255        //
256        // `window` is a half-open `[offset, offset+limit)` band. Skip-based
257        // APIs take the offset verbatim; page-based APIs are addressed by
258        // 1-based page, derived from the offset (the loader may hand
259        // non-page-aligned windows, so it rounds down to the containing page).
260        if !self.no_pagination
261            && let Some((offset, limit)) = window
262        {
263            let offset = offset.max(0);
264            let limit = limit.max(1);
265            let page_value = if self.pagination.skip_based {
266                offset.to_string()
267            } else {
268                (offset / limit + 1).to_string()
269            };
270            params.push((self.pagination.page.clone(), page_value));
271            params.push((self.pagination.limit.clone(), limit.to_string()));
272        }
273
274        // Conditions: each `eq` becomes `?field=value`. Multiple
275        // conditions AND together (JSON Server semantics).
276        for (i, cond) in conditions.iter().enumerate() {
277            if consumed.contains(&i) {
278                continue;
279            }
280            if let Some((field, value)) = crate::condition_to_query_param(cond) {
281                params.push((field, value));
282            }
283        }
284
285        if params.is_empty() {
286            return String::new();
287        }
288        let mut s = String::from("?");
289        for (i, (k, v)) in params.iter().enumerate() {
290            if i > 0 {
291                s.push('&');
292            }
293            // Minimal URL encoding — we encode `&` and `=` and spaces
294            // because those break the query format. Anything else
295            // passes through; the JSON Server convention is permissive.
296            s.push_str(&urlencode(k));
297            s.push('=');
298            s.push_str(&urlencode(v));
299        }
300        s
301    }
302
303    /// Render the request a read would issue, without issuing it.
304    ///
305    /// Shares [`endpoint_url`](Self::endpoint_url) and
306    /// [`build_query_string`](Self::build_query_string) with the real fetch
307    /// path, so a previewed URL cannot drift from the one that gets sent.
308    ///
309    /// One difference is deliberate: `fetch_raw_body` first *awaits* any
310    /// deferred condition (a foreign key whose value arrives with the parent
311    /// row), and awaiting is what a preview must not do. Those are counted
312    /// under `deferred_conditions` and left out of the URL instead.
313    pub(crate) fn preview_request<'a>(
314        &self,
315        table_name: &str,
316        window: Option<(i64, i64)>,
317        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
318    ) -> serde_json::Value {
319        let conds: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
320
321        // Conditions the query-param lowering cannot peel into an eq pair —
322        // deferred foreign keys, and anything else not shaped `field = value`.
323        let unresolved = conds
324            .iter()
325            .filter(|c| crate::condition_to_query_param(c).is_none())
326            .count();
327
328        let (endpoint, consumed) = match self.endpoint_url(table_name, &conds) {
329            Ok(pair) => pair,
330            // A URI template placeholder went unfilled. If some condition is
331            // still unresolved, the real fetch would have awaited it *before*
332            // building the path, so this is a preview limitation and the
333            // template is the honest answer. With nothing outstanding, the
334            // fetch would fail here too — report that.
335            Err(_) if unresolved > 0 => {
336                return serde_json::json!({
337                    "driver": "rest-api",
338                    "method": "GET",
339                    "url": format!("{}/{}", self.base_url, table_name),
340                    "unresolved_conditions": unresolved,
341                    "note": "path placeholders are filled from conditions resolved \
342                             at fetch time; the template is shown unfilled",
343                });
344            }
345            Err(e) => {
346                return serde_json::json!({
347                    "driver": "rest-api",
348                    "base_url": self.base_url,
349                    "error": e.to_string(),
350                });
351            }
352        };
353
354        let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
355        let query = self.build_query_string(window, &conds, &query_consumed);
356
357        serde_json::json!({
358            "driver": "rest-api",
359            "method": "GET",
360            "url": join_query(&endpoint, &query),
361            "auth_header": self.auth_header.as_ref().map(|_| "<set>"),
362            // Under `FilterStrategy::Client` these never reach the server: the
363            // rows come back unfiltered and are narrowed in memory.
364            "client_side_filters": client_filters
365                .into_iter()
366                .map(|(k, v)| format!("{k}={v}"))
367                .collect::<Vec<_>>(),
368            "unresolved_conditions": unresolved,
369        })
370    }
371
372    /// Fetch data from the API endpoint and return parsed records.
373    ///
374    /// `id_field` selects which JSON field is treated as the record ID;
375    /// if `None`, row indices are used. The page-based `pagination` is
376    /// lowered to a `[offset, offset+limit)` window; `conditions` are
377    /// pushed into the URL query string — eq-conditions become
378    /// `?field=value`. Conditions that can't be peeled into a simple
379    /// eq are silently skipped (caller-side filtering still applies if
380    /// needed).
381    pub(crate) async fn fetch_records<'a>(
382        &self,
383        table_name: &str,
384        id_field: Option<&str>,
385        pagination: Option<&Pagination>,
386        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
387    ) -> Result<IndexMap<String, Record<CborValue>>> {
388        let window = pagination.map(|p| (p.skip(), p.limit()));
389        self.fetch_windowed(table_name, id_field, window, conditions)
390            .await
391            .map(|(records, _total)| records)
392    }
393
394    /// Fetch a single half-open row window `[offset, offset+limit)` — the
395    /// primitive a paged, lazily-loaded grid drives on scroll (offset is
396    /// an absolute row index, not a page number).
397    /// Fetch one half-open row window, plus the envelope's `total_key` when
398    /// the response carries one.
399    ///
400    /// The total comes out of the **same response as the rows**. Every paged
401    /// endpoint reports it on every reply, so a caller wanting both a window
402    /// and a grand total takes them together here rather than pairing a
403    /// window fetch with [`Self::fetch_total`] — that pairing costs a second
404    /// round trip for a number already in hand.
405    pub(crate) async fn fetch_window_records_counted<'a>(
406        &self,
407        table_name: &str,
408        id_field: Option<&str>,
409        offset: i64,
410        limit: i64,
411        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
412    ) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
413        self.fetch_windowed(table_name, id_field, Some((offset, limit)), conditions)
414            .await
415    }
416
417    /// Read the grand total of matching rows from the response envelope's
418    /// configured `total_key` (e.g. `count`). Returns `None` when no
419    /// `total_key` is set — the caller then falls back to counting fetched
420    /// rows. Issues a cheap `limit=1` request so the body carries the count
421    /// without paying for the rows.
422    pub(crate) async fn fetch_total<'a>(
423        &self,
424        table_name: &str,
425        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
426    ) -> Result<Option<i64>> {
427        let Some(total_key) = self.total_key.clone() else {
428            return Ok(None);
429        };
430        let (body, _client_filters) = self
431            .fetch_raw_body(table_name, Some((0, 1)), conditions)
432            .await?;
433        let total = body
434            .get(total_key.as_str())
435            .and_then(|v| v.as_i64())
436            .ok_or_else(|| {
437                error!(
438                    "total_key missing or not an integer in API response",
439                    total_key = total_key.as_str()
440                )
441            })?;
442        if self.debug {
443            tracing::debug!(target: "vantage_api_client::rest", total, "REST count");
444        }
445        Ok(Some(total))
446    }
447
448    /// Resolve conditions, build the windowed request URL, GET it (with the
449    /// auth header if configured), and return the parsed JSON body together
450    /// with any client-side filters that still need applying (under
451    /// [`FilterStrategy::Client`]).
452    async fn fetch_raw_body<'a>(
453        &self,
454        table_name: &str,
455        window: Option<(i64, i64)>,
456        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
457    ) -> Result<(serde_json::Value, Vec<(String, String)>)> {
458        // Conditions may carry `DeferredFn` values — typically from
459        // `related_in_condition` for `with_one`-style traversals where the FK
460        // lives in a parent record we haven't fetched yet. Resolve them once,
461        // up front, so the rest of the pipeline sees only sync scalars.
462        let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
463        let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
464        for cond in raw {
465            resolved.push(resolve_deferreds(cond.clone()).await?);
466        }
467        let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
468        let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
469
470        let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
471        let query = self.build_query_string(window, &conds, &query_consumed);
472        let url = join_query(&endpoint, &query);
473
474        // The `(0, 1)` window is the count probe (reads only the envelope's
475        // total); log it at debug so it doesn't drown the real data fetches.
476        if self.debug {
477            if window == Some((0, 1)) {
478                tracing::debug!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET (count probe)");
479            } else {
480                tracing::info!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET");
481            }
482        }
483
484        let mut request = self.client.get(&url);
485        if let Some(ref auth) = self.auth_header {
486            request = request.header("Authorization", auth);
487        }
488
489        // Time every round trip, unconditionally — a remote API is the one part
490        // of a read the process cannot bound, and a slow page is far more often
491        // one slow GET than anything local. Reported regardless of `debug` so
492        // the cost is attributable from a default log; a request over a second
493        // is worth an operator's attention, hence `info` at that point.
494        let started = std::time::Instant::now();
495        let response = request.send().await.map_err(|e| {
496            tracing::warn!(
497                target: "vantage_api_client::rest",
498                table = table_name,
499                url = %url,
500                ms = started.elapsed().as_millis() as u64,
501                "REST GET failed",
502            );
503            error!("API request failed", url = url, detail = e)
504        })?;
505
506        if !response.status().is_success() {
507            return Err(error!(
508                "API returned error status",
509                url = url,
510                status = response.status().as_u16()
511            ));
512        }
513
514        let body: serde_json::Value = response
515            .json()
516            .await
517            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
518
519        let ms = started.elapsed().as_millis() as u64;
520        let probe = window == Some((0, 1));
521        if ms >= 1000 {
522            tracing::info!(
523                target: "vantage_api_client::rest",
524                table = table_name,
525                url = %url,
526                ms,
527                count_probe = probe,
528                "slow REST GET",
529            );
530        } else {
531            tracing::debug!(
532                target: "vantage_api_client::rest",
533                table = table_name,
534                url = %url,
535                ms,
536                count_probe = probe,
537                "REST GET done",
538            );
539        }
540
541        Ok((body, client_filters))
542    }
543
544    /// Also reports the envelope total when `total_key` is configured and the
545    /// body carries it. Unlike [`Self::fetch_total`] this never errors on a
546    /// missing total: the rows are the point here, and a caller that needs a
547    /// definitive count can still ask for one.
548    async fn fetch_windowed<'a>(
549        &self,
550        table_name: &str,
551        id_field: Option<&str>,
552        window: Option<(i64, i64)>,
553        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
554    ) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
555        // Non-paginating endpoints return the whole list on the first
556        // window; a later window would just re-deliver the same rows and the
557        // perpetual grid would never mark itself exhausted. Short-circuit any
558        // window past the start to empty so the grid sees the chunk shrink
559        // and stops asking for more.
560        if self.no_pagination && window.is_some_and(|(offset, _)| offset > 0) {
561            return Ok((IndexMap::new(), None));
562        }
563
564        let (body, client_filters) = self.fetch_raw_body(table_name, window, conditions).await?;
565        let total = self
566            .total_key
567            .as_deref()
568            .and_then(|key| body.get(key))
569            .and_then(|v| v.as_i64());
570        let data = self.extract_array(&body, table_name)?;
571
572        let mut records = IndexMap::new();
573        for (row_idx, item) in data.iter().enumerate() {
574            let obj = item
575                .as_object()
576                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
577
578            // Extract ID from the configured id_field, or use row index
579            let id = id_field
580                .and_then(|field| obj.get(field))
581                .and_then(|v| match v {
582                    serde_json::Value::String(s) => Some(s.clone()),
583                    serde_json::Value::Number(n) => Some(n.to_string()),
584                    _ => None,
585                })
586                .unwrap_or_else(|| row_idx.to_string());
587
588            // The HTTP body parses as JSON for free; convert to CBOR
589            // at this single boundary so the rest of the pipeline
590            // (Table, Vista) sees the universal carrier. json_to_cbor
591            // is total — JSON is a strict subset of CBOR.
592            let mut record: Record<CborValue> = Record::new();
593            for (k, v) in obj {
594                record.insert(k.clone(), vantage_types::json_to_cbor(v.clone()));
595            }
596
597            records.insert(id, record);
598        }
599
600        // Client-side filtering (FilterStrategy::Client): drop rows that
601        // don't match the non-path eq-conditions. A condition whose field
602        // is absent from a row is treated as a pass (it was a path/request
603        // param, not a record field) — mirroring the AWS connector and the
604        // Mercury CLI's own post-fetch `_filter_deployments`.
605        if !client_filters.is_empty() {
606            records.retain(|_id, record| {
607                client_filters
608                    .iter()
609                    .all(|(field, want)| match record.get(field) {
610                        Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
611                        None => true,
612                    })
613            });
614            // The envelope counted what the SERVER matched, before these rows
615            // were dropped here — reporting it now would size a grid to rows
616            // it will never be given. No total is better than a wrong one.
617            return Ok((records, None));
618        }
619
620        Ok((records, total))
621    }
622}
623
624fn urlencode(s: &str) -> String {
625    urlencoding::encode(s).into_owned()
626}
627
628/// Append a `build_query_string` result (always opening with `?`, or empty)
629/// to an endpoint URL. The table path may itself carry a query string (e.g.
630/// `launches/?mode=detailed`), in which case the appended params must join
631/// with `&` — otherwise the URL gets two `?` and the API rejects it.
632fn join_query(endpoint: &str, query: &str) -> String {
633    match query.strip_prefix('?') {
634        Some(rest) if endpoint.contains('?') => format!("{endpoint}&{rest}"),
635        _ => format!("{endpoint}{query}"),
636    }
637}
638
639/// Walk an `Expression`'s parameter tree and force any `Deferred`
640/// branches to their resolved form. Used at the `fetch_records`
641/// boundary so the URL builder only sees sync scalars.
642///
643/// Recursion lives on the heap (boxed) because the future's body
644/// contains another `async` call of the same shape — Rust can't size
645/// a directly-recursive `async fn` without indirection.
646fn resolve_deferreds(
647    mut expr: Expression<CborValue>,
648) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
649    Box::pin(async move {
650        for param in expr.parameters.iter_mut() {
651            match param {
652                ExpressiveEnum::Deferred(deferred) => {
653                    *param = deferred.call().await?;
654                }
655                ExpressiveEnum::Nested(inner) => {
656                    let resolved = resolve_deferreds(inner.clone()).await?;
657                    *inner = resolved;
658                }
659                ExpressiveEnum::Scalar(_) => {}
660            }
661        }
662        Ok(expr)
663    })
664}
665
666impl RestApi {
667    /// Pull the row array out of the response body, according to the
668    /// configured `ResponseShape`.
669    fn extract_array<'a>(
670        &self,
671        body: &'a serde_json::Value,
672        table_name: &str,
673    ) -> Result<&'a Vec<serde_json::Value>> {
674        match &self.response_shape {
675            ResponseShape::BareArray => body.as_array().ok_or_else(|| {
676                error!("Expected response body to be a JSON array (BareArray shape)")
677            }),
678            ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
679                error!(
680                    "Response missing array under wrapper key",
681                    array_key = array_key
682                )
683            }),
684            ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
685                error!(
686                    "Response missing array under table-name key",
687                    table_name = table_name
688                )
689            }),
690        }
691    }
692}
693
694/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
695/// override the pagination parameter names.
696///
697/// ```no_run
698/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
699///
700/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
701/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
702///     .response_shape(ResponseShape::BareArray)
703///     .build();
704///
705/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
706/// let api = RestApi::builder("https://dummyjson.com")
707///     .response_shape(ResponseShape::WrappedByTableName)
708///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
709///     .build();
710/// ```
711#[derive(Clone, Debug)]
712pub struct RestApiBuilder {
713    base_url: String,
714    auth_header: Option<String>,
715    response_shape: ResponseShape,
716    pagination: PaginationParams,
717    no_pagination: bool,
718    filter_strategy: FilterStrategy,
719    total_key: Option<String>,
720    debug: bool,
721}
722
723impl RestApiBuilder {
724    fn new(base_url: String) -> Self {
725        Self {
726            base_url,
727            auth_header: None,
728            response_shape: ResponseShape::default(),
729            pagination: PaginationParams::default(),
730            no_pagination: false,
731            filter_strategy: FilterStrategy::default(),
732            total_key: None,
733            debug: false,
734        }
735    }
736
737    /// Set the Authorization header value (e.g. "Bearer `<token>`").
738    pub fn auth(mut self, auth: impl Into<String>) -> Self {
739        self.auth_header = Some(auth.into());
740        self
741    }
742
743    /// Choose how the API wraps its row array. Defaults to
744    /// `Wrapped { array_key: "data" }` for backwards compat.
745    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
746        self.response_shape = shape;
747        self
748    }
749
750    /// Override the page/limit query parameter names. Default is
751    /// `("_page", "_limit")` (JSON Server convention).
752    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
753        self.pagination = pagination;
754        self
755    }
756
757    /// Disable pagination entirely — no `_page`/`_limit` query
758    /// params are appended, and a request for page > 1 is short-
759    /// circuited to an empty result. Use this for APIs that don't
760    /// paginate (return the full list every call) or that treat
761    /// unknown query params as strict filters.
762    pub fn no_pagination(mut self) -> Self {
763        self.no_pagination = true;
764        self
765    }
766
767    /// Choose how non-path eq-conditions are applied. Default is
768    /// [`FilterStrategy::Query`]; use [`FilterStrategy::Client`] for
769    /// APIs that only filter via path segments and reject/ignore unknown
770    /// query params (the conditions are then applied in memory).
771    pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
772        self.filter_strategy = strategy;
773        self
774    }
775
776    /// Name the response-envelope key carrying the grand total of matching
777    /// rows (e.g. `count`). Setting it lets the shell report an exact count
778    /// and advertise `can_fetch_window` for lazy/scroll loading.
779    pub fn total_key(mut self, key: impl Into<String>) -> Self {
780        self.total_key = Some(key.into());
781        self
782    }
783
784    /// Emit `tracing` events for window/count requests.
785    pub fn debug(mut self, debug: bool) -> Self {
786        self.debug = debug;
787        self
788    }
789
790    pub fn build(self) -> RestApi {
791        RestApi {
792            base_url: self.base_url,
793            client: reqwest::Client::new(),
794            auth_header: self.auth_header,
795            response_shape: self.response_shape,
796            pagination: self.pagination,
797            no_pagination: self.no_pagination,
798            filter_strategy: self.filter_strategy,
799            total_key: self.total_key,
800            debug: self.debug,
801        }
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    /// `build_query_string` with no conditions, exercising only the
810    /// window → pagination-param mapping.
811    fn qs(api: &RestApi, window: Option<(i64, i64)>) -> String {
812        api.build_query_string(window, &[], &[])
813    }
814
815    #[test]
816    fn skip_based_window_uses_offset_verbatim() {
817        let api = RestApi::builder("http://x")
818            .pagination_params(PaginationParams::skip_limit("skip", "limit"))
819            .build();
820        assert_eq!(qs(&api, Some((20, 10))), "?skip=20&limit=10");
821    }
822
823    #[test]
824    fn page_based_window_derives_one_based_page() {
825        let api = RestApi::builder("http://x").build(); // default _page/_limit
826        // offset 20 / limit 10 → page 3 (1-based).
827        assert_eq!(qs(&api, Some((20, 10))), "?_page=3&_limit=10");
828    }
829
830    #[test]
831    fn no_window_emits_no_pagination_params() {
832        let api = RestApi::builder("http://x").build();
833        assert_eq!(qs(&api, None), "");
834    }
835
836    #[test]
837    fn no_pagination_suppresses_window_params() {
838        let api = RestApi::builder("http://x").no_pagination().build();
839        assert_eq!(qs(&api, Some((20, 10))), "");
840    }
841
842    #[test]
843    fn query_string_joins_plain_endpoint_with_question_mark() {
844        assert_eq!(
845            join_query("http://x/launches/", "?_page=1&_limit=10"),
846            "http://x/launches/?_page=1&_limit=10"
847        );
848    }
849
850    #[test]
851    fn query_string_joins_templated_endpoint_with_ampersand() {
852        // Endpoint already carries `?mode=detailed`; pagination must append
853        // with `&`, not a second `?`.
854        assert_eq!(
855            join_query("http://x/launches/?mode=detailed", "?offset=0&limit=1"),
856            "http://x/launches/?mode=detailed&offset=0&limit=1"
857        );
858    }
859
860    #[test]
861    fn empty_query_string_leaves_endpoint_untouched() {
862        assert_eq!(
863            join_query("http://x/launches/?mode=detailed", ""),
864            "http://x/launches/?mode=detailed"
865        );
866    }
867
868    /// Live regression for the double-`?` bug: a real fetch against the
869    /// Launch Library 2 dev API using a table path that already carries a
870    /// query string (`launches/?mode=detailed`). Before the `join_query`
871    /// fix the request URL was `…/launches/?mode=detailed?offset=0&limit=1`
872    /// and the server answered 500. Network-gated, so `#[ignore]`d:
873    /// `cargo test -p vantage-api-client -- --ignored query_string`.
874    #[tokio::test]
875    #[ignore = "hits the live Launch Library 2 dev API"]
876    async fn live_templated_table_path_fetches_rows() {
877        let api = RestApi::builder("https://lldev.thespacedevs.com/2.3.0")
878            .pagination_params(PaginationParams::skip_limit("offset", "limit"))
879            .response_shape(ResponseShape::Wrapped {
880                array_key: "results".into(),
881            })
882            .total_key("count")
883            .build();
884
885        let total = api
886            .fetch_total("launches/?mode=detailed", [])
887            .await
888            .expect("fetch_total");
889        assert!(total.is_some_and(|n| n > 0), "expected a positive count");
890
891        let (rows, window_total) = api
892            .fetch_window_records_counted("launches/?mode=detailed", Some("id"), 0, 3, [])
893            .await
894            .expect("fetch_window_records_counted");
895        assert_eq!(rows.len(), 3, "expected the requested 3-row window");
896        // The whole point of the counted window: the same response that
897        // carried the rows also carried the count, so `fetch_total`'s extra
898        // round trip buys nothing a caller couldn't already have.
899        assert_eq!(
900            window_total, total,
901            "the window's envelope total should match the dedicated count",
902        );
903    }
904}