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