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