Skip to main content

vantage_api_client/rest/
api.rs

1use std::sync::Arc;
2
3use ciborium::Value as CborValue;
4use indexmap::IndexMap;
5use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
6use vantage_api_pool::resilient::{ResilientClient, TransportEvent, TransportObserver};
7use vantage_core::{Priority, error};
8use vantage_dataset::traits::Result;
9use vantage_expressions::Expression;
10use vantage_expressions::traits::expressive::ExpressiveEnum;
11use vantage_table::pagination::Pagination;
12use vantage_types::Record;
13
14use crate::transport::AuthHeader;
15
16/// How the API wraps its row array in the response body.
17///
18/// Most public APIs use one of these three shapes; the legacy vantage
19/// "wrapped under `data`" shape is `Wrapped { array_key: "data" }`.
20#[derive(Clone, Debug)]
21pub enum ResponseShape {
22    /// Body is a bare JSON array of records.
23    /// Example: `GET /users` → `[ {…}, {…} ]`. JSONPlaceholder, GitHub, etc.
24    BareArray,
25
26    /// Body is a JSON object with the array under a fixed key.
27    /// Example: `GET /users` → `{ "data": [ … ] }`.
28    Wrapped { array_key: String },
29
30    /// Body is a JSON object with the array under a key matching the
31    /// table name. Example (DummyJSON):
32    /// `GET /products` → `{ "products": [ … ], "total": …, "skip": …, "limit": … }`.
33    WrappedByTableName,
34}
35
36impl Default for ResponseShape {
37    /// Default matches the legacy 0.1.x shape: `{ "data": [...] }`.
38    fn default() -> Self {
39        ResponseShape::Wrapped {
40            array_key: "data".to_string(),
41        }
42    }
43}
44
45/// Names of the page/limit query parameters the API expects.
46///
47/// Defaults to `("_page", "_limit")` — the JSON Server convention used
48/// by JSONPlaceholder. DummyJSON uses `("skip", "limit")` (in items not
49/// pages). Customise via `RestApiBuilder::pagination_params`.
50#[derive(Clone, Debug)]
51pub struct PaginationParams {
52    pub page: String,
53    pub limit: String,
54    /// If true, the page parameter is sent as a *0-based item offset*
55    /// (`skip`) instead of a 1-based page index. DummyJSON-style.
56    pub skip_based: bool,
57}
58
59impl PaginationParams {
60    pub fn page_limit(page: impl Into<String>, limit: impl Into<String>) -> Self {
61        Self {
62            page: page.into(),
63            limit: limit.into(),
64            skip_based: false,
65        }
66    }
67
68    pub fn skip_limit(skip: impl Into<String>, limit: impl Into<String>) -> Self {
69        Self {
70            page: skip.into(),
71            limit: limit.into(),
72            skip_based: true,
73        }
74    }
75}
76
77impl Default for PaginationParams {
78    fn default() -> Self {
79        Self::page_limit("_page", "_limit")
80    }
81}
82
83/// REST API backend for Vantage — reads data from HTTP JSON endpoints.
84///
85/// Each table maps to an API endpoint: `{base_url}/{table_name}`.
86/// Response shape is configurable via [`RestApi::builder`]; see
87/// [`ResponseShape`] for the supported variants.
88///
89/// Currently read-only — write operations return errors.
90/// How a table's conditions are applied to a request.
91///
92/// URL `{placeholder}` path segments are always filled from matching
93/// eq-conditions regardless of strategy; this governs what happens to
94/// the *remaining* (non-path) eq-conditions.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub enum FilterStrategy {
97    /// Append remaining eq-conditions as `?field=value` query params
98    /// (JSON-Server semantics). The default.
99    #[default]
100    Query,
101    /// Apply remaining eq-conditions as in-memory row filters after the
102    /// fetch, never as query params. For APIs whose only server-side
103    /// filters are path segments and that reject (or ignore) unknown
104    /// query params — e.g. the Mercury control-API, whose CLI likewise
105    /// filters version/env client-side after fetching by product path.
106    Client,
107}
108
109/// How the API takes a sort: one query param naming the column, with a
110/// prefix that flips it to descending (`?ordering=-net`, the Django REST
111/// Framework and Launch Library convention). Configuring it makes the REST
112/// vista orderable, so a paged grid asks the server for its sort instead of
113/// re-sorting the rows it happens to hold.
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct OrderingParams {
116    pub param: String,
117    pub desc_prefix: String,
118}
119
120impl OrderingParams {
121    pub fn new(param: impl Into<String>, desc_prefix: impl Into<String>) -> Self {
122        Self {
123            param: param.into(),
124            desc_prefix: desc_prefix.into(),
125        }
126    }
127
128    /// The value for `param`: the column, prefixed when descending.
129    pub fn value(&self, field: &str, dir: vantage_vista::SortDirection) -> String {
130        match dir {
131            vantage_vista::SortDirection::Ascending => field.to_string(),
132            vantage_vista::SortDirection::Descending => format!("{}{field}", self.desc_prefix),
133        }
134    }
135}
136
137/// A sort pushed down to the API: column and direction.
138pub(crate) type Order<'a> = Option<(&'a str, vantage_vista::SortDirection)>;
139
140/// One response's rows, see [`RestApi::fetch_page`].
141struct Page {
142    rows: IndexMap<String, Record<CborValue>>,
143    total: Option<i64>,
144    server_rows: usize,
145    lossy: bool,
146}
147
148#[derive(Clone, Debug)]
149pub struct RestApi {
150    base_url: String,
151    client: ResilientClient,
152    pub(crate) auth_header: AuthHeader,
153    response_shape: ResponseShape,
154    pagination: PaginationParams,
155    /// Paging params were configured explicitly, so windows can be fetched
156    /// even with no `total_key` — the end of the set shows as a short page.
157    paged: bool,
158    /// When true, no `_page`/`_limit` query params are appended and
159    /// list endpoints are assumed to return the full result set in
160    /// one shot. Caller-side requests for page > 1 short-circuit to
161    /// an empty result so a perpetual-grid stops paging after the
162    /// first chunk. Useful for FastAPI/Pydantic services that treat
163    /// unknown query params as strict filters.
164    no_pagination: bool,
165    /// How non-path eq-conditions are applied — query params vs.
166    /// in-memory post-fetch filtering. See [`FilterStrategy`].
167    filter_strategy: FilterStrategy,
168    /// Response-envelope key carrying the grand total of matching rows
169    /// (e.g. `count`). When set, the shell reports an exact count and
170    /// advertises `can_fetch_window` for lazy/scroll loading; when `None`
171    /// it falls back to counting fetched rows.
172    total_key: Option<String>,
173    /// The sort query param, when the API has one. Unset means the API
174    /// cannot sort and consumers order fetched rows themselves.
175    ordering: Option<OrderingParams>,
176    /// Emit `tracing` events for window/count requests.
177    debug: bool,
178}
179
180impl RestApi {
181    /// Create a new REST API pointing at `base_url`. Uses the legacy
182    /// default response shape (`{ "data": [...] }`). For other shapes
183    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
184    pub fn new(base_url: impl Into<String>) -> Self {
185        RestApi::builder(base_url).build()
186    }
187
188    /// Start configuring a [`RestApi`] via the builder.
189    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
190        RestApiBuilder::new(base_url.into())
191    }
192
193    /// Set the Authorization header value (e.g. "Bearer `<token>`").
194    /// Provided for backwards compatibility — prefer
195    /// `RestApi::builder(...).auth(...)`.
196    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
197        self.auth_header = AuthHeader::new(auth);
198        self
199    }
200
201    /// The configured response-envelope total key, if any. When set, the
202    /// REST shell can report an exact count and serve `fetch_window`.
203    pub fn total_key(&self) -> Option<&str> {
204        self.total_key.as_deref()
205    }
206
207    /// Whether the shell can serve absolute-offset windows: the API pages
208    /// (explicit `pagination_params`, or a `total_key`) and pagination isn't
209    /// switched off. Without a total the loader learns the end of the set
210    /// from a short page.
211    pub fn serves_windows(&self) -> bool {
212        !self.no_pagination && (self.paged || self.total_key.is_some())
213    }
214
215    /// The sort query param, when configured.
216    pub fn ordering(&self) -> Option<&OrderingParams> {
217        self.ordering.as_ref()
218    }
219
220    /// What the circuit breaker is doing right now. `None` when the
221    /// underlying client has no breaker configured.
222    pub fn breaker_state(&self) -> Option<crate::BreakerState> {
223        self.client.breaker_state()
224    }
225
226    /// The resilient client backing this API — the pool, breaker and
227    /// observer a caller outside the read path (e.g. an outbox replaying a
228    /// queued write) should share rather than build its own.
229    pub fn client(&self) -> &ResilientClient {
230        &self.client
231    }
232
233    /// The configured base URL requests are joined against.
234    pub fn base_url(&self) -> &str {
235        &self.base_url
236    }
237
238    /// Issue an arbitrary HTTP request against `base_url`/`path`, through
239    /// the same resilient client and auth header as reads.
240    ///
241    /// `headers` are applied after the configured `Authorization` header, so
242    /// a caller-supplied header of the same name wins. `body`, when given,
243    /// is sent as the JSON request body. The call policy follows
244    /// [`vantage_core::Priority::current`], same as a table read. On success,
245    /// a non-`GET` method reports [`TransportEvent::WritePushed`] to the
246    /// observer.
247    pub async fn http_request(
248        &self,
249        method: reqwest::Method,
250        path: &str,
251        headers: &[(&str, &str)],
252        body: Option<&serde_json::Value>,
253    ) -> vantage_core::Result<reqwest::Response> {
254        let url = join_base_path(&self.base_url, path);
255
256        // `HeaderMap::insert` replaces a same-named entry rather than
257        // appending — unlike `RequestBuilder::header` — so a caller header
258        // actually overrides the configured auth instead of riding alongside
259        // it on the wire.
260        let mut header_map = HeaderMap::new();
261        if let Some(auth) = self.auth_header.value() {
262            header_map.insert(
263                AUTHORIZATION,
264                HeaderValue::from_str(auth)
265                    .expect("configured auth header must be a valid header value"),
266            );
267        }
268        for (name, value) in headers {
269            header_map.insert(
270                HeaderName::from_bytes(name.as_bytes()).expect("header name must be valid"),
271                HeaderValue::from_str(value).expect("header value must be valid"),
272            );
273        }
274
275        let policy = crate::transport::policy_for(Priority::current());
276        let response = self
277            .client
278            .execute_with(&policy, |http| {
279                let req = http
280                    .request(method.clone(), &url)
281                    .headers(header_map.clone());
282                match body {
283                    Some(body) => req.json(body),
284                    None => req,
285                }
286            })
287            .await
288            .map_err(|e| crate::transport::client_error(e, "API request failed", &url))?;
289
290        if method != reqwest::Method::GET {
291            self.client.report(TransportEvent::WritePushed);
292        }
293
294        Ok(response)
295    }
296
297    /// Build the endpoint path for `table_name`, substituting any
298    /// `{placeholder}` segments from matching eq-conditions.
299    ///
300    /// Returns the absolute URL up to (but excluding) the query string,
301    /// alongside the indices of conditions consumed by the substitution
302    /// — those are dropped from the query string by `build_query_string`.
303    ///
304    /// Tables that don't use templates (no `{}` in the name) pass
305    /// through unchanged and consume no conditions.
306    fn endpoint_url(
307        &self,
308        table_name: &str,
309        conditions: &[&Expression<CborValue>],
310    ) -> Result<(String, Vec<usize>)> {
311        let mut consumed = Vec::new();
312        let mut path = String::with_capacity(table_name.len());
313        let mut rest = table_name;
314        while let Some(open) = rest.find('{') {
315            path.push_str(&rest[..open]);
316            let after = &rest[open + 1..];
317            let close = after.find('}').ok_or_else(|| {
318                error!(
319                    "Unclosed `{` in table name URI template",
320                    table_name = table_name
321                )
322            })?;
323            let placeholder = &after[..close];
324            let (idx, value) = conditions
325                .iter()
326                .enumerate()
327                .find_map(|(i, cond)| {
328                    if consumed.contains(&i) {
329                        return None;
330                    }
331                    let (field, value) = crate::condition_to_query_param(cond)?;
332                    (field == placeholder).then_some((i, value))
333                })
334                .ok_or_else(|| {
335                    error!(
336                        "No eq-condition provided for URI placeholder",
337                        placeholder = placeholder,
338                        table_name = table_name
339                    )
340                })?;
341            consumed.push(idx);
342            path.push_str(&urlencode(&value));
343            rest = &after[close + 1..];
344        }
345        path.push_str(rest);
346        Ok((format!("{}/{}", self.base_url, path), consumed))
347    }
348
349    /// Decide which conditions go in the query string and which are applied to
350    /// the rows after they arrive.
351    ///
352    /// Under [`FilterStrategy::Client`] non-path eq-conditions are *not* sent —
353    /// the API rejects or ignores unknown params — so they come back as
354    /// client-side filters and every condition is marked consumed to keep it out
355    /// of the URL. Otherwise nothing is filtered locally and only the path
356    /// placeholders are consumed.
357    ///
358    /// Shared by the real fetch and by [`preview_request`](Self::preview_request)
359    /// so a previewed URL cannot claim a filter the fetch would have applied in
360    /// memory, or vice versa.
361    fn split_filters(
362        &self,
363        conds: &[&Expression<CborValue>],
364        consumed: Vec<usize>,
365    ) -> (Vec<usize>, Vec<(String, String)>) {
366        if self.filter_strategy == FilterStrategy::Client {
367            let filters = conds
368                .iter()
369                .enumerate()
370                .filter(|(i, _)| !consumed.contains(i))
371                .filter_map(|(_, c)| crate::condition_to_query_param(c))
372                .collect();
373            ((0..conds.len()).collect(), filters)
374        } else {
375            (consumed, Vec::new())
376        }
377    }
378
379    /// Build the combined query-string from pagination + conditions.
380    /// `consumed` lists condition indices already baked into the URI
381    /// path; those don't appear in the query string. Conditions that
382    /// don't peel cleanly into eq pairs are skipped — same "best effort"
383    /// stance as before.
384    fn build_query_string(
385        &self,
386        window: Option<(i64, i64)>,
387        conditions: &[&Expression<CborValue>],
388        consumed: &[usize],
389        order: Order<'_>,
390    ) -> String {
391        let mut params: Vec<(String, String)> = Vec::new();
392
393        // Pagination first — matches the order users see in the URL bar.
394        // When `no_pagination` is set the API doesn't accept page/limit
395        // query params (and may treat them as strict filters that
396        // return empty), so we leave them off.
397        //
398        // `window` is a half-open `[offset, offset+limit)` band. Skip-based
399        // APIs take the offset verbatim; page-based APIs are addressed by
400        // 1-based page, derived from the offset (the loader may hand
401        // non-page-aligned windows, so it rounds down to the containing page).
402        if !self.no_pagination
403            && let Some((offset, limit)) = window
404        {
405            let offset = offset.max(0);
406            let limit = limit.max(1);
407            let page_value = if self.pagination.skip_based {
408                offset.to_string()
409            } else {
410                (offset / limit + 1).to_string()
411            };
412            params.push((self.pagination.page.clone(), page_value));
413            params.push((self.pagination.limit.clone(), limit.to_string()));
414        }
415
416        // A sort reaches the query only when the API declared how it takes
417        // one; the vista never offers `add_order` otherwise.
418        if let (Some(spec), Some((field, dir))) = (&self.ordering, order) {
419            params.push((spec.param.clone(), spec.value(field, dir)));
420        }
421
422        // Conditions: each `eq` becomes `?field=value`. Multiple
423        // conditions AND together (JSON Server semantics).
424        for (i, cond) in conditions.iter().enumerate() {
425            if consumed.contains(&i) {
426                continue;
427            }
428            if let Some((field, value)) = crate::condition_to_query_param(cond) {
429                params.push((field, value));
430            }
431        }
432
433        if params.is_empty() {
434            return String::new();
435        }
436        let mut s = String::from("?");
437        for (i, (k, v)) in params.iter().enumerate() {
438            if i > 0 {
439                s.push('&');
440            }
441            // Minimal URL encoding — we encode `&` and `=` and spaces
442            // because those break the query format. Anything else
443            // passes through; the JSON Server convention is permissive.
444            s.push_str(&urlencode(k));
445            s.push('=');
446            s.push_str(&urlencode(v));
447        }
448        s
449    }
450
451    /// Render the request a read would issue, without issuing it.
452    ///
453    /// Shares [`endpoint_url`](Self::endpoint_url) and
454    /// [`build_query_string`](Self::build_query_string) with the real fetch
455    /// path, so a previewed URL cannot drift from the one that gets sent.
456    ///
457    /// One difference is deliberate: `fetch_raw_body` first *awaits* any
458    /// deferred condition (a foreign key whose value arrives with the parent
459    /// row), and awaiting is what a preview must not do. Those are counted
460    /// under `deferred_conditions` and left out of the URL instead.
461    pub(crate) fn preview_request<'a>(
462        &self,
463        table_name: &str,
464        window: Option<(i64, i64)>,
465        order: Order<'_>,
466        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
467    ) -> serde_json::Value {
468        let conds: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
469
470        // Conditions the query-param lowering cannot peel into an eq pair —
471        // deferred foreign keys, and anything else not shaped `field = value`.
472        let unresolved = conds
473            .iter()
474            .filter(|c| crate::condition_to_query_param(c).is_none())
475            .count();
476
477        let (endpoint, consumed) = match self.endpoint_url(table_name, &conds) {
478            Ok(pair) => pair,
479            // A URI template placeholder went unfilled. If some condition is
480            // still unresolved, the real fetch would have awaited it *before*
481            // building the path, so this is a preview limitation and the
482            // template is the honest answer. With nothing outstanding, the
483            // fetch would fail here too — report that.
484            Err(_) if unresolved > 0 => {
485                return serde_json::json!({
486                    "driver": "rest-api",
487                    "method": "GET",
488                    "url": format!("{}/{}", self.base_url, table_name),
489                    "unresolved_conditions": unresolved,
490                    "note": "path placeholders are filled from conditions resolved \
491                             at fetch time; the template is shown unfilled",
492                });
493            }
494            Err(e) => {
495                return serde_json::json!({
496                    "driver": "rest-api",
497                    "base_url": self.base_url,
498                    "error": e.to_string(),
499                });
500            }
501        };
502
503        let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
504        let query = self.build_query_string(window, &conds, &query_consumed, order);
505
506        serde_json::json!({
507            "driver": "rest-api",
508            "method": "GET",
509            "url": join_query(&endpoint, &query),
510            "auth_header": self.auth_header.masked(),
511            // Under `FilterStrategy::Client` these never reach the server: the
512            // rows come back unfiltered and are narrowed in memory.
513            "client_side_filters": client_filters
514                .into_iter()
515                .map(|(k, v)| format!("{k}={v}"))
516                .collect::<Vec<_>>(),
517            "unresolved_conditions": unresolved,
518        })
519    }
520
521    /// Fetch data from the API endpoint and return parsed records.
522    ///
523    /// `id_field` selects which JSON field is treated as the record ID;
524    /// if `None`, row indices are used. The page-based `pagination` is
525    /// lowered to a `[offset, offset+limit)` window; `conditions` are
526    /// pushed into the URL query string — eq-conditions become
527    /// `?field=value`. Conditions that can't be peeled into a simple
528    /// eq are silently skipped (caller-side filtering still applies if
529    /// needed).
530    pub(crate) async fn fetch_records<'a>(
531        &self,
532        table_name: &str,
533        id_field: Option<&str>,
534        pagination: Option<&Pagination>,
535        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
536    ) -> Result<IndexMap<String, Record<CborValue>>> {
537        let window = pagination.map(|p| (p.skip(), p.limit()));
538        self.fetch_windowed(table_name, id_field, window, None, conditions)
539            .await
540            .map(|(records, _total)| records)
541    }
542
543    /// Fetch a single half-open row window `[offset, offset+limit)` — the
544    /// primitive a paged, lazily-loaded grid drives on scroll (offset is
545    /// an absolute row index, not a page number).
546    /// Fetch one half-open row window, plus the envelope's `total_key` when
547    /// the response carries one.
548    ///
549    /// The total comes out of the **same response as the rows**. Every paged
550    /// endpoint reports it on every reply, so a caller wanting both a window
551    /// and a grand total takes them together here rather than pairing a
552    /// window fetch with [`Self::fetch_total`] — that pairing costs a second
553    /// round trip for a number already in hand.
554    pub(crate) async fn fetch_window_records_counted<'a>(
555        &self,
556        table_name: &str,
557        id_field: Option<&str>,
558        offset: i64,
559        limit: i64,
560        order: Order<'_>,
561        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
562    ) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
563        self.fetch_windowed(
564            table_name,
565            id_field,
566            Some((offset, limit)),
567            order,
568            conditions,
569        )
570        .await
571    }
572
573    /// Read the grand total of matching rows from the response envelope's
574    /// configured `total_key` (e.g. `count`). Returns `None` when no
575    /// `total_key` is set — the caller then falls back to counting fetched
576    /// rows. Issues a cheap `limit=1` request so the body carries the count
577    /// without paying for the rows.
578    pub(crate) async fn fetch_total<'a>(
579        &self,
580        table_name: &str,
581        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
582    ) -> Result<Option<i64>> {
583        let Some(total_key) = self.total_key.clone() else {
584            return Ok(None);
585        };
586        let (body, _client_filters) = self
587            .fetch_raw_body(table_name, Some((0, 1)), None, conditions)
588            .await?;
589        let total = body
590            .get(total_key.as_str())
591            .and_then(|v| v.as_i64())
592            .ok_or_else(|| {
593                error!(
594                    "total_key missing or not an integer in API response",
595                    total_key = total_key.as_str()
596                )
597            })?;
598        if self.debug {
599            tracing::debug!(target: "vantage_api_client::rest", total, "REST count");
600        }
601        Ok(Some(total))
602    }
603
604    /// Resolve conditions, build the windowed request URL, GET it (with the
605    /// auth header if configured), and return the parsed JSON body together
606    /// with any client-side filters that still need applying (under
607    /// [`FilterStrategy::Client`]).
608    async fn fetch_raw_body<'a>(
609        &self,
610        table_name: &str,
611        window: Option<(i64, i64)>,
612        order: Order<'_>,
613        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
614    ) -> Result<(serde_json::Value, Vec<(String, String)>)> {
615        // Conditions may carry `DeferredFn` values — typically from
616        // `related_in_condition` for `with_one`-style traversals where the FK
617        // lives in a parent record we haven't fetched yet. Resolve them once,
618        // up front, so the rest of the pipeline sees only sync scalars.
619        let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
620        let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
621        for cond in raw {
622            resolved.push(resolve_deferreds(cond.clone()).await?);
623        }
624        let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
625        let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
626
627        let (query_consumed, client_filters) = self.split_filters(&conds, consumed);
628        let query = self.build_query_string(window, &conds, &query_consumed, order);
629        let url = join_query(&endpoint, &query);
630
631        // The `(0, 1)` window is the count probe (reads only the envelope's
632        // total); log it at debug so it doesn't drown the real data fetches.
633        if self.debug {
634            if window == Some((0, 1)) {
635                tracing::debug!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET (count probe)");
636            } else {
637                tracing::info!(target: "vantage_api_client::rest", table = table_name, url = %url, "REST GET");
638            }
639        }
640
641        // Time every round trip, unconditionally — a remote API is the one part
642        // of a read the process cannot bound, and a slow page is far more often
643        // one slow GET than anything local. Reported regardless of `debug` so
644        // the cost is attributable from a default log; a request over a second
645        // is worth an operator's attention, hence `info` at that point.
646        let started = std::time::Instant::now();
647        let policy = crate::transport::policy_for(Priority::current());
648        let response = self
649            .client
650            .execute_with(&policy, |http| {
651                let req = http.get(&url);
652                match self.auth_header.value() {
653                    Some(auth) => req.header(AUTHORIZATION, auth),
654                    None => req,
655                }
656            })
657            .await
658            .map_err(|e| {
659                let ms = started.elapsed().as_millis() as u64;
660                // A transient failure (5xx, network, open breaker) is visible
661                // in the datasource's health and will be tried again by a
662                // later call; only an answer no retry can change is worth a
663                // warning.
664                if e.is_final() {
665                    tracing::warn!(
666                        target: "vantage_api_client::rest",
667                        table = table_name,
668                        url = %url,
669                        ms,
670                        attempts = e.attempts,
671                        "REST GET failed",
672                    );
673                } else {
674                    tracing::debug!(
675                        target: "vantage_api_client::rest",
676                        table = table_name,
677                        url = %url,
678                        ms,
679                        attempts = e.attempts,
680                        kind = e.kind_name(),
681                        "REST GET gave up on a transient failure",
682                    );
683                }
684                crate::transport::client_error(e, "API request failed", &url)
685            })?;
686
687        let body: serde_json::Value = response
688            .json()
689            .await
690            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
691
692        let ms = started.elapsed().as_millis() as u64;
693        let probe = window == Some((0, 1));
694        if ms >= 1000 {
695            tracing::info!(
696                target: "vantage_api_client::rest",
697                table = table_name,
698                url = %url,
699                ms,
700                count_probe = probe,
701                "slow REST GET",
702            );
703        } else {
704            tracing::debug!(
705                target: "vantage_api_client::rest",
706                table = table_name,
707                url = %url,
708                ms,
709                count_probe = probe,
710                "REST GET done",
711            );
712        }
713
714        Ok((body, client_filters))
715    }
716
717    /// Also reports the envelope total when `total_key` is configured and the
718    /// body carries it. Unlike [`Self::fetch_total`] this never errors on a
719    /// missing total: the rows are the point here, and a caller that needs a
720    /// definitive count can still ask for one.
721    ///
722    /// A window comes back short only when the server ran out: callers take a
723    /// short window as the end of the set.
724    async fn fetch_windowed<'a>(
725        &self,
726        table_name: &str,
727        id_field: Option<&str>,
728        window: Option<(i64, i64)>,
729        order: Order<'_>,
730        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
731    ) -> Result<(IndexMap<String, Record<CborValue>>, Option<i64>)> {
732        // Non-paginating endpoints return the whole list on the first
733        // window; a later window would just re-deliver the same rows and the
734        // perpetual grid would never mark itself exhausted. Short-circuit any
735        // window past the start to empty so the grid sees the chunk shrink
736        // and stops asking for more.
737        if self.no_pagination && window.is_some_and(|(offset, _)| offset > 0) {
738            return Ok((IndexMap::new(), None));
739        }
740        let conditions: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
741        let Some((offset, limit)) = window.filter(|_| !self.no_pagination) else {
742            let page = self
743                .fetch_page(table_name, id_field, window, order, &conditions)
744                .await?;
745            return Ok((page.rows, page.total));
746        };
747        let (offset, limit) = (offset.max(0), limit.max(1));
748
749        // Page-based APIs are addressed by whole pages: start at the page
750        // holding `offset`.
751        let start = if self.pagination.skip_based {
752            offset
753        } else {
754            offset - offset % limit
755        };
756        let first = self
757            .fetch_page(
758                table_name,
759                id_field,
760                Some((start, limit)),
761                order,
762                &conditions,
763            )
764            .await?;
765        if start == offset && !first.lossy {
766            return Ok((first.rows, first.total));
767        }
768
769        // Assemble the window page by page until it is full or the server
770        // runs out. When rows were dropped (client filters, repeated ids) a
771        // row's place in the result is only known by walking from the start.
772        let (mut server_offset, mut skip) = if first.lossy {
773            (0, offset)
774        } else {
775            (start, offset - start)
776        };
777        let mut page = (server_offset == start).then_some(first);
778        let mut rows = IndexMap::new();
779        let mut total = None;
780        let mut lossy = false;
781        loop {
782            let current = match page.take() {
783                Some(p) => p,
784                None => {
785                    self.fetch_page(
786                        table_name,
787                        id_field,
788                        Some((server_offset, limit)),
789                        order,
790                        &conditions,
791                    )
792                    .await?
793                }
794            };
795            total = total.or(current.total);
796            lossy |= current.lossy;
797            for (id, row) in current.rows {
798                if skip > 0 {
799                    skip -= 1;
800                } else if (rows.len() as i64) < limit {
801                    rows.insert(id, row);
802                }
803            }
804            if rows.len() as i64 >= limit || (current.server_rows as i64) < limit {
805                break;
806            }
807            server_offset += limit;
808        }
809        Ok((rows, if lossy { None } else { total }))
810    }
811
812    /// One request: the page's rows keyed by id, the envelope total, and how
813    /// many rows the server sent — more than `rows` holds when client filters
814    /// or repeated ids dropped some (`lossy`).
815    async fn fetch_page(
816        &self,
817        table_name: &str,
818        id_field: Option<&str>,
819        window: Option<(i64, i64)>,
820        order: Order<'_>,
821        conditions: &[&Expression<CborValue>],
822    ) -> Result<Page> {
823        let (body, client_filters) = self
824            .fetch_raw_body(table_name, window, order, conditions.iter().copied())
825            .await?;
826        let total = self
827            .total_key
828            .as_deref()
829            .and_then(|key| body.get(key))
830            .and_then(|v| v.as_i64());
831        let data = self.extract_array(&body, table_name)?;
832
833        let mut records = IndexMap::new();
834        for (row_idx, item) in data.iter().enumerate() {
835            let obj = item
836                .as_object()
837                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
838
839            // Extract ID from the configured id_field, or use row index
840            let id = id_field
841                .and_then(|field| obj.get(field))
842                .and_then(|v| match v {
843                    serde_json::Value::String(s) => Some(s.clone()),
844                    serde_json::Value::Number(n) => Some(n.to_string()),
845                    _ => None,
846                })
847                .unwrap_or_else(|| row_idx.to_string());
848
849            // The HTTP body parses as JSON for free; convert to CBOR
850            // at this single boundary so the rest of the pipeline
851            // (Table, Vista) sees the universal carrier. json_to_cbor
852            // is total — JSON is a strict subset of CBOR.
853            let mut record: Record<CborValue> = Record::new();
854            for (k, v) in obj {
855                record.insert(k.clone(), vantage_types::json_to_cbor(v.clone()));
856            }
857
858            records.insert(id, record);
859        }
860
861        // Client-side filtering (FilterStrategy::Client): drop rows that
862        // don't match the non-path eq-conditions. A condition whose field
863        // is absent from a row is treated as a pass (it was a path/request
864        // param, not a record field) — mirroring the AWS connector and the
865        // Mercury CLI's own post-fetch `_filter_deployments`.
866        if !client_filters.is_empty() {
867            records.retain(|_id, record| {
868                client_filters
869                    .iter()
870                    .all(|(field, want)| match record.get(field) {
871                        Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
872                        None => true,
873                    })
874            });
875        }
876
877        // Counts the rows this call hands back, so a client-side filter shows
878        // up as fewer rows pulled than the server sent.
879        self.client
880            .report(TransportEvent::RowsPulled { n: records.len() });
881
882        // The envelope counted what the SERVER matched, before any rows were
883        // dropped above — reporting that total now would size a grid to rows it
884        // will never be given. No total is better than a wrong one.
885        let total = if client_filters.is_empty() {
886            total
887        } else {
888            None
889        };
890        Ok(Page {
891            lossy: records.len() < data.len(),
892            server_rows: data.len(),
893            rows: records,
894            total,
895        })
896    }
897}
898
899fn urlencode(s: &str) -> String {
900    urlencoding::encode(s).into_owned()
901}
902
903/// Join a base URL and a path with exactly one slash between them,
904/// regardless of whether either side already carries one.
905fn join_base_path(base: &str, path: &str) -> String {
906    format!(
907        "{}/{}",
908        base.trim_end_matches('/'),
909        path.trim_start_matches('/')
910    )
911}
912
913/// Append a `build_query_string` result (always opening with `?`, or empty)
914/// to an endpoint URL. The table path may itself carry a query string (e.g.
915/// `launches/?mode=detailed`), in which case the appended params must join
916/// with `&` — otherwise the URL gets two `?` and the API rejects it.
917fn join_query(endpoint: &str, query: &str) -> String {
918    match query.strip_prefix('?') {
919        Some(rest) if endpoint.contains('?') => format!("{endpoint}&{rest}"),
920        _ => format!("{endpoint}{query}"),
921    }
922}
923
924/// Walk an `Expression`'s parameter tree and force any `Deferred`
925/// branches to their resolved form. Used at the `fetch_records`
926/// boundary so the URL builder only sees sync scalars.
927///
928/// Recursion lives on the heap (boxed) because the future's body
929/// contains another `async` call of the same shape — Rust can't size
930/// a directly-recursive `async fn` without indirection.
931fn resolve_deferreds(
932    mut expr: Expression<CborValue>,
933) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
934    Box::pin(async move {
935        for param in expr.parameters.iter_mut() {
936            match param {
937                ExpressiveEnum::Deferred(deferred) => {
938                    *param = deferred.call().await?;
939                }
940                ExpressiveEnum::Nested(inner) => {
941                    let resolved = resolve_deferreds(inner.clone()).await?;
942                    *inner = resolved;
943                }
944                ExpressiveEnum::Scalar(_) => {}
945            }
946        }
947        Ok(expr)
948    })
949}
950
951impl RestApi {
952    /// Pull the row array out of the response body, according to the
953    /// configured `ResponseShape`. A bare-array API answers a single-record
954    /// endpoint (`activities/{id}`) with one object: that reads as one row.
955    fn extract_array<'a>(
956        &self,
957        body: &'a serde_json::Value,
958        table_name: &str,
959    ) -> Result<&'a [serde_json::Value]> {
960        match &self.response_shape {
961            ResponseShape::BareArray => match body {
962                serde_json::Value::Array(rows) => Ok(rows),
963                serde_json::Value::Object(_) => Ok(std::slice::from_ref(body)),
964                _ => Err(error!(
965                    "Expected response body to be a JSON array or object (BareArray shape)"
966                )),
967            },
968            ResponseShape::Wrapped { array_key } => body[array_key]
969                .as_array()
970                .map(Vec::as_slice)
971                .ok_or_else(|| {
972                    error!(
973                        "Response missing array under wrapper key",
974                        array_key = array_key
975                    )
976                }),
977            ResponseShape::WrappedByTableName => body[table_name]
978                .as_array()
979                .map(Vec::as_slice)
980                .ok_or_else(|| {
981                    error!(
982                        "Response missing array under table-name key",
983                        table_name = table_name
984                    )
985                }),
986        }
987    }
988}
989
990/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
991/// override the pagination parameter names.
992///
993/// ```no_run
994/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
995///
996/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
997/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
998///     .response_shape(ResponseShape::BareArray)
999///     .build();
1000///
1001/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
1002/// let api = RestApi::builder("https://dummyjson.com")
1003///     .response_shape(ResponseShape::WrappedByTableName)
1004///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
1005///     .build();
1006/// ```
1007#[derive(Clone, Debug)]
1008pub struct RestApiBuilder {
1009    base_url: String,
1010    auth_header: AuthHeader,
1011    response_shape: ResponseShape,
1012    pagination: PaginationParams,
1013    /// `pagination_params` was called: the API is known to page.
1014    paged: bool,
1015    no_pagination: bool,
1016    filter_strategy: FilterStrategy,
1017    total_key: Option<String>,
1018    ordering: Option<OrderingParams>,
1019    debug: bool,
1020    transport: crate::transport::ClientConfig,
1021}
1022
1023impl RestApiBuilder {
1024    fn new(base_url: String) -> Self {
1025        Self {
1026            base_url,
1027            auth_header: AuthHeader::default(),
1028            response_shape: ResponseShape::default(),
1029            pagination: PaginationParams::default(),
1030            paged: false,
1031            no_pagination: false,
1032            filter_strategy: FilterStrategy::default(),
1033            total_key: None,
1034            ordering: None,
1035            debug: false,
1036            transport: crate::transport::ClientConfig::default(),
1037        }
1038    }
1039
1040    /// Concurrent requests to this API at most (default 4).
1041    pub fn max_parallel(mut self, n: usize) -> Self {
1042        self.transport.max_parallel = n.max(1);
1043        self
1044    }
1045
1046    /// Requests per second to this API at most.
1047    pub fn rate_limit(mut self, per_second: f64) -> Self {
1048        self.transport.rate_limit = Some(per_second);
1049        self
1050    }
1051
1052    /// Report every attempt, retry and breaker transition under `key`
1053    /// (the datasource name).
1054    pub fn observer(
1055        mut self,
1056        key: impl Into<Arc<str>>,
1057        observer: Arc<dyn TransportObserver>,
1058    ) -> Self {
1059        self.transport.observer = Some((key.into(), observer));
1060        self
1061    }
1062
1063    /// A pre-configured `reqwest::Client` (timeouts, proxies, TLS).
1064    pub fn http_client(mut self, client: reqwest::Client) -> Self {
1065        self.transport.http = Some(client);
1066        self
1067    }
1068
1069    /// Set the Authorization header value (e.g. "Bearer `<token>`").
1070    pub fn auth(mut self, auth: impl Into<String>) -> Self {
1071        self.auth_header = AuthHeader::new(auth);
1072        self.transport.auth_refresher = None;
1073        self
1074    }
1075
1076    /// Get the bearer token from `refresher` instead of a fixed header: it
1077    /// is asked on the first request, and again when the API answers `401`.
1078    /// A request waits for it, so a refresher may run an interactive
1079    /// sign-in. Replaces [`auth`](Self::auth).
1080    pub fn auth_refresher(mut self, refresher: crate::AuthRefresher) -> Self {
1081        self.auth_header = AuthHeader::default();
1082        self.transport.auth_refresher = Some(refresher);
1083        self
1084    }
1085
1086    /// Choose how the API wraps its row array. Defaults to
1087    /// `Wrapped { array_key: "data" }` for backwards compat.
1088    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
1089        self.response_shape = shape;
1090        self
1091    }
1092
1093    /// Override the page/limit query parameter names. Default is
1094    /// `("_page", "_limit")` (JSON Server convention).
1095    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
1096        self.pagination = pagination;
1097        self.paged = true;
1098        self
1099    }
1100
1101    /// Disable pagination entirely — no `_page`/`_limit` query
1102    /// params are appended, and a request for page > 1 is short-
1103    /// circuited to an empty result. Use this for APIs that don't
1104    /// paginate (return the full list every call) or that treat
1105    /// unknown query params as strict filters.
1106    pub fn no_pagination(mut self) -> Self {
1107        self.no_pagination = true;
1108        self
1109    }
1110
1111    /// Choose how non-path eq-conditions are applied. Default is
1112    /// [`FilterStrategy::Query`]; use [`FilterStrategy::Client`] for
1113    /// APIs that only filter via path segments and reject/ignore unknown
1114    /// query params (the conditions are then applied in memory).
1115    pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
1116        self.filter_strategy = strategy;
1117        self
1118    }
1119
1120    /// Name the response-envelope key carrying the grand total of matching
1121    /// rows (e.g. `count`). Setting it lets the shell report an exact count
1122    /// and advertise `can_fetch_window` for lazy/scroll loading.
1123    pub fn total_key(mut self, key: impl Into<String>) -> Self {
1124        self.total_key = Some(key.into());
1125        self
1126    }
1127
1128    /// The API's sort query param (see [`OrderingParams`]). With it set the
1129    /// vista reports `can_order` and every column is orderable.
1130    pub fn ordering(mut self, ordering: OrderingParams) -> Self {
1131        self.ordering = Some(ordering);
1132        self
1133    }
1134
1135    /// Emit `tracing` events for window/count requests.
1136    pub fn debug(mut self, debug: bool) -> Self {
1137        self.debug = debug;
1138        self
1139    }
1140
1141    pub fn build(self) -> RestApi {
1142        RestApi {
1143            base_url: self.base_url,
1144            client: crate::transport::build_client(self.transport),
1145            auth_header: self.auth_header,
1146            response_shape: self.response_shape,
1147            pagination: self.pagination,
1148            paged: self.paged,
1149            no_pagination: self.no_pagination,
1150            filter_strategy: self.filter_strategy,
1151            total_key: self.total_key,
1152            ordering: self.ordering,
1153            debug: self.debug,
1154        }
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    /// `build_query_string` with no conditions, exercising only the
1163    /// window → pagination-param mapping.
1164    fn qs(api: &RestApi, window: Option<(i64, i64)>) -> String {
1165        api.build_query_string(window, &[], &[], None)
1166    }
1167
1168    #[test]
1169    fn debug_masks_auth_header() {
1170        let api = RestApi::builder("http://x")
1171            .auth("Bearer secret-token")
1172            .build();
1173        let text = format!("{api:?}");
1174        assert!(!text.contains("secret-token"), "{text}");
1175        assert!(text.contains("<set>"), "{text}");
1176    }
1177
1178    #[test]
1179    fn skip_based_window_uses_offset_verbatim() {
1180        let api = RestApi::builder("http://x")
1181            .pagination_params(PaginationParams::skip_limit("skip", "limit"))
1182            .build();
1183        assert_eq!(qs(&api, Some((20, 10))), "?skip=20&limit=10");
1184    }
1185
1186    #[test]
1187    fn page_based_window_derives_one_based_page() {
1188        let api = RestApi::builder("http://x").build(); // default _page/_limit
1189        // offset 20 / limit 10 → page 3 (1-based).
1190        assert_eq!(qs(&api, Some((20, 10))), "?_page=3&_limit=10");
1191    }
1192
1193    #[test]
1194    fn no_window_emits_no_pagination_params() {
1195        let api = RestApi::builder("http://x").build();
1196        assert_eq!(qs(&api, None), "");
1197    }
1198
1199    #[test]
1200    fn no_pagination_suppresses_window_params() {
1201        let api = RestApi::builder("http://x").no_pagination().build();
1202        assert_eq!(qs(&api, Some((20, 10))), "");
1203    }
1204
1205    #[test]
1206    fn query_string_joins_plain_endpoint_with_question_mark() {
1207        assert_eq!(
1208            join_query("http://x/launches/", "?_page=1&_limit=10"),
1209            "http://x/launches/?_page=1&_limit=10"
1210        );
1211    }
1212
1213    #[test]
1214    fn query_string_joins_templated_endpoint_with_ampersand() {
1215        // Endpoint already carries `?mode=detailed`; pagination must append
1216        // with `&`, not a second `?`.
1217        assert_eq!(
1218            join_query("http://x/launches/?mode=detailed", "?offset=0&limit=1"),
1219            "http://x/launches/?mode=detailed&offset=0&limit=1"
1220        );
1221    }
1222
1223    #[test]
1224    fn empty_query_string_leaves_endpoint_untouched() {
1225        assert_eq!(
1226            join_query("http://x/launches/?mode=detailed", ""),
1227            "http://x/launches/?mode=detailed"
1228        );
1229    }
1230
1231    /// Live regression for the double-`?` bug: a real fetch against the
1232    /// Launch Library 2 dev API using a table path that already carries a
1233    /// query string (`launches/?mode=detailed`). Before the `join_query`
1234    /// fix the request URL was `…/launches/?mode=detailed?offset=0&limit=1`
1235    /// and the server answered 500. Network-gated, so `#[ignore]`d:
1236    /// `cargo test -p vantage-api-client -- --ignored query_string`.
1237    #[tokio::test]
1238    #[ignore = "hits the live Launch Library 2 dev API"]
1239    async fn live_templated_table_path_fetches_rows() {
1240        let api = RestApi::builder("https://lldev.thespacedevs.com/2.3.0")
1241            .pagination_params(PaginationParams::skip_limit("offset", "limit"))
1242            .response_shape(ResponseShape::Wrapped {
1243                array_key: "results".into(),
1244            })
1245            .total_key("count")
1246            .build();
1247
1248        let total = api
1249            .fetch_total("launches/?mode=detailed", [])
1250            .await
1251            .expect("fetch_total");
1252        assert!(total.is_some_and(|n| n > 0), "expected a positive count");
1253
1254        let (rows, window_total) = api
1255            .fetch_window_records_counted("launches/?mode=detailed", Some("id"), 0, 3, None, [])
1256            .await
1257            .expect("fetch_window_records_counted");
1258        assert_eq!(rows.len(), 3, "expected the requested 3-row window");
1259        // The whole point of the counted window: the same response that
1260        // carried the rows also carried the count, so `fetch_total`'s extra
1261        // round trip buys nothing a caller couldn't already have.
1262        assert_eq!(
1263            window_total, total,
1264            "the window's envelope total should match the dedicated count",
1265        );
1266    }
1267}