Skip to main content

vantage_api_client/rest/
api.rs

1use ciborium::Value as CborValue;
2use indexmap::IndexMap;
3use vantage_core::error;
4use vantage_dataset::traits::Result;
5use vantage_expressions::Expression;
6use vantage_expressions::traits::expressive::ExpressiveEnum;
7use vantage_table::pagination::Pagination;
8use vantage_types::Record;
9
10/// How the API wraps its row array in the response body.
11///
12/// Most public APIs use one of these three shapes; the legacy vantage
13/// "wrapped under `data`" shape is `Wrapped { array_key: "data" }`.
14#[derive(Clone, Debug)]
15pub enum ResponseShape {
16    /// Body is a bare JSON array of records.
17    /// Example: `GET /users` → `[ {…}, {…} ]`. JSONPlaceholder, GitHub, etc.
18    BareArray,
19
20    /// Body is a JSON object with the array under a fixed key.
21    /// Example: `GET /users` → `{ "data": [ … ] }`.
22    Wrapped { array_key: String },
23
24    /// Body is a JSON object with the array under a key matching the
25    /// table name. Example (DummyJSON):
26    /// `GET /products` → `{ "products": [ … ], "total": …, "skip": …, "limit": … }`.
27    WrappedByTableName,
28}
29
30impl Default for ResponseShape {
31    /// Default matches the legacy 0.1.x shape: `{ "data": [...] }`.
32    fn default() -> Self {
33        ResponseShape::Wrapped {
34            array_key: "data".to_string(),
35        }
36    }
37}
38
39/// Names of the page/limit query parameters the API expects.
40///
41/// Defaults to `("_page", "_limit")` — the JSON Server convention used
42/// by JSONPlaceholder. DummyJSON uses `("skip", "limit")` (in items not
43/// pages). Customise via `RestApiBuilder::pagination_params`.
44#[derive(Clone, Debug)]
45pub struct PaginationParams {
46    pub page: String,
47    pub limit: String,
48    /// If true, the page parameter is sent as a *0-based item offset*
49    /// (`skip`) instead of a 1-based page index. DummyJSON-style.
50    pub skip_based: bool,
51}
52
53impl PaginationParams {
54    pub fn page_limit(page: impl Into<String>, limit: impl Into<String>) -> Self {
55        Self {
56            page: page.into(),
57            limit: limit.into(),
58            skip_based: false,
59        }
60    }
61
62    pub fn skip_limit(skip: impl Into<String>, limit: impl Into<String>) -> Self {
63        Self {
64            page: skip.into(),
65            limit: limit.into(),
66            skip_based: true,
67        }
68    }
69}
70
71impl Default for PaginationParams {
72    fn default() -> Self {
73        Self::page_limit("_page", "_limit")
74    }
75}
76
77/// REST API backend for Vantage — reads data from HTTP JSON endpoints.
78///
79/// Each table maps to an API endpoint: `{base_url}/{table_name}`.
80/// Response shape is configurable via [`RestApi::builder`]; see
81/// [`ResponseShape`] for the supported variants.
82///
83/// Currently read-only — write operations return errors.
84/// How a table's conditions are applied to a request.
85///
86/// URL `{placeholder}` path segments are always filled from matching
87/// eq-conditions regardless of strategy; this governs what happens to
88/// the *remaining* (non-path) eq-conditions.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum FilterStrategy {
91    /// Append remaining eq-conditions as `?field=value` query params
92    /// (JSON-Server semantics). The default.
93    #[default]
94    Query,
95    /// Apply remaining eq-conditions as in-memory row filters after the
96    /// fetch, never as query params. For APIs whose only server-side
97    /// filters are path segments and that reject (or ignore) unknown
98    /// query params — e.g. the Mercury control-API, whose CLI likewise
99    /// filters version/env client-side after fetching by product path.
100    Client,
101}
102
103#[derive(Clone, Debug)]
104pub struct RestApi {
105    base_url: String,
106    client: reqwest::Client,
107    pub(crate) auth_header: Option<String>,
108    response_shape: ResponseShape,
109    pagination: PaginationParams,
110    /// When true, no `_page`/`_limit` query params are appended and
111    /// list endpoints are assumed to return the full result set in
112    /// one shot. Caller-side requests for page > 1 short-circuit to
113    /// an empty result so a perpetual-grid stops paging after the
114    /// first chunk. Useful for FastAPI/Pydantic services that treat
115    /// unknown query params as strict filters.
116    no_pagination: bool,
117    /// How non-path eq-conditions are applied — query params vs.
118    /// in-memory post-fetch filtering. See [`FilterStrategy`].
119    filter_strategy: FilterStrategy,
120    /// Response-envelope key carrying the grand total of matching rows
121    /// (e.g. `count`). When set, the shell reports an exact count and
122    /// advertises `can_fetch_window` for lazy/scroll loading; when `None`
123    /// it falls back to counting fetched rows.
124    total_key: Option<String>,
125    /// Emit `tracing` events for window/count requests.
126    debug: bool,
127}
128
129impl RestApi {
130    /// Create a new REST API pointing at `base_url`. Uses the legacy
131    /// default response shape (`{ "data": [...] }`). For other shapes
132    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
133    pub fn new(base_url: impl Into<String>) -> Self {
134        RestApi::builder(base_url).build()
135    }
136
137    /// Start configuring a [`RestApi`] via the builder.
138    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
139        RestApiBuilder::new(base_url.into())
140    }
141
142    /// Set the Authorization header value (e.g. "Bearer `<token>`").
143    /// Provided for backwards compatibility — prefer
144    /// `RestApi::builder(...).auth(...)`.
145    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
146        self.auth_header = Some(auth.into());
147        self
148    }
149
150    /// The configured response-envelope total key, if any. When set, the
151    /// REST shell can report an exact count and serve `fetch_window`.
152    pub fn total_key(&self) -> Option<&str> {
153        self.total_key.as_deref()
154    }
155
156    /// Build the endpoint path for `table_name`, substituting any
157    /// `{placeholder}` segments from matching eq-conditions.
158    ///
159    /// Returns the absolute URL up to (but excluding) the query string,
160    /// alongside the indices of conditions consumed by the substitution
161    /// — those are dropped from the query string by `build_query_string`.
162    ///
163    /// Tables that don't use templates (no `{}` in the name) pass
164    /// through unchanged and consume no conditions.
165    fn endpoint_url(
166        &self,
167        table_name: &str,
168        conditions: &[&Expression<CborValue>],
169    ) -> Result<(String, Vec<usize>)> {
170        let mut consumed = Vec::new();
171        let mut path = String::with_capacity(table_name.len());
172        let mut rest = table_name;
173        while let Some(open) = rest.find('{') {
174            path.push_str(&rest[..open]);
175            let after = &rest[open + 1..];
176            let close = after.find('}').ok_or_else(|| {
177                error!(
178                    "Unclosed `{` in table name URI template",
179                    table_name = table_name
180                )
181            })?;
182            let placeholder = &after[..close];
183            let (idx, value) = conditions
184                .iter()
185                .enumerate()
186                .find_map(|(i, cond)| {
187                    if consumed.contains(&i) {
188                        return None;
189                    }
190                    let (field, value) = crate::condition_to_query_param(cond)?;
191                    (field == placeholder).then_some((i, value))
192                })
193                .ok_or_else(|| {
194                    error!(
195                        "No eq-condition provided for URI placeholder",
196                        placeholder = placeholder,
197                        table_name = table_name
198                    )
199                })?;
200            consumed.push(idx);
201            path.push_str(&urlencode(&value));
202            rest = &after[close + 1..];
203        }
204        path.push_str(rest);
205        Ok((format!("{}/{}", self.base_url, path), consumed))
206    }
207
208    /// Build the combined query-string from pagination + conditions.
209    /// `consumed` lists condition indices already baked into the URI
210    /// path; those don't appear in the query string. Conditions that
211    /// don't peel cleanly into eq pairs are skipped — same "best effort"
212    /// stance as before.
213    fn build_query_string(
214        &self,
215        window: Option<(i64, i64)>,
216        conditions: &[&Expression<CborValue>],
217        consumed: &[usize],
218    ) -> String {
219        let mut params: Vec<(String, String)> = Vec::new();
220
221        // Pagination first — matches the order users see in the URL bar.
222        // When `no_pagination` is set the API doesn't accept page/limit
223        // query params (and may treat them as strict filters that
224        // return empty), so we leave them off.
225        //
226        // `window` is a half-open `[offset, offset+limit)` band. Skip-based
227        // APIs take the offset verbatim; page-based APIs are addressed by
228        // 1-based page, derived from the offset (the loader may hand
229        // non-page-aligned windows, so it rounds down to the containing page).
230        if !self.no_pagination
231            && let Some((offset, limit)) = window
232        {
233            let offset = offset.max(0);
234            let limit = limit.max(1);
235            let page_value = if self.pagination.skip_based {
236                offset.to_string()
237            } else {
238                (offset / limit + 1).to_string()
239            };
240            params.push((self.pagination.page.clone(), page_value));
241            params.push((self.pagination.limit.clone(), limit.to_string()));
242        }
243
244        // Conditions: each `eq` becomes `?field=value`. Multiple
245        // conditions AND together (JSON Server semantics).
246        for (i, cond) in conditions.iter().enumerate() {
247            if consumed.contains(&i) {
248                continue;
249            }
250            if let Some((field, value)) = crate::condition_to_query_param(cond) {
251                params.push((field, value));
252            }
253        }
254
255        if params.is_empty() {
256            return String::new();
257        }
258        let mut s = String::from("?");
259        for (i, (k, v)) in params.iter().enumerate() {
260            if i > 0 {
261                s.push('&');
262            }
263            // Minimal URL encoding — we encode `&` and `=` and spaces
264            // because those break the query format. Anything else
265            // passes through; the JSON Server convention is permissive.
266            s.push_str(&urlencode(k));
267            s.push('=');
268            s.push_str(&urlencode(v));
269        }
270        s
271    }
272
273    /// Fetch data from the API endpoint and return parsed records.
274    ///
275    /// `id_field` selects which JSON field is treated as the record ID;
276    /// if `None`, row indices are used. The page-based `pagination` is
277    /// lowered to a `[offset, offset+limit)` window; `conditions` are
278    /// pushed into the URL query string — eq-conditions become
279    /// `?field=value`. Conditions that can't be peeled into a simple
280    /// eq are silently skipped (caller-side filtering still applies if
281    /// needed).
282    pub(crate) async fn fetch_records<'a>(
283        &self,
284        table_name: &str,
285        id_field: Option<&str>,
286        pagination: Option<&Pagination>,
287        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
288    ) -> Result<IndexMap<String, Record<CborValue>>> {
289        let window = pagination.map(|p| (p.skip(), p.limit()));
290        self.fetch_windowed(table_name, id_field, window, conditions)
291            .await
292    }
293
294    /// Fetch a single half-open row window `[offset, offset+limit)` — the
295    /// primitive a paged, lazily-loaded grid drives on scroll (offset is
296    /// an absolute row index, not a page number).
297    pub(crate) async fn fetch_window_records<'a>(
298        &self,
299        table_name: &str,
300        id_field: Option<&str>,
301        offset: i64,
302        limit: i64,
303        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
304    ) -> Result<IndexMap<String, Record<CborValue>>> {
305        self.fetch_windowed(table_name, id_field, Some((offset, limit)), conditions)
306            .await
307    }
308
309    /// Read the grand total of matching rows from the response envelope's
310    /// configured `total_key` (e.g. `count`). Returns `None` when no
311    /// `total_key` is set — the caller then falls back to counting fetched
312    /// rows. Issues a cheap `limit=1` request so the body carries the count
313    /// without paying for the rows.
314    pub(crate) async fn fetch_total<'a>(
315        &self,
316        table_name: &str,
317        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
318    ) -> Result<Option<i64>> {
319        let Some(total_key) = self.total_key.clone() else {
320            return Ok(None);
321        };
322        let (body, _client_filters) = self
323            .fetch_raw_body(table_name, Some((0, 1)), conditions)
324            .await?;
325        let total = body
326            .get(total_key.as_str())
327            .and_then(|v| v.as_i64())
328            .ok_or_else(|| {
329                error!(
330                    "total_key missing or not an integer in API response",
331                    total_key = total_key.as_str()
332                )
333            })?;
334        if self.debug {
335            tracing::info!(target: "vantage_api_client::rest", total, "REST count");
336        }
337        Ok(Some(total))
338    }
339
340    /// Resolve conditions, build the windowed request URL, GET it (with the
341    /// auth header if configured), and return the parsed JSON body together
342    /// with any client-side filters that still need applying (under
343    /// [`FilterStrategy::Client`]).
344    async fn fetch_raw_body<'a>(
345        &self,
346        table_name: &str,
347        window: Option<(i64, i64)>,
348        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
349    ) -> Result<(serde_json::Value, Vec<(String, String)>)> {
350        // Conditions may carry `DeferredFn` values — typically from
351        // `related_in_condition` for `with_one`-style traversals where the FK
352        // lives in a parent record we haven't fetched yet. Resolve them once,
353        // up front, so the rest of the pipeline sees only sync scalars.
354        let raw: Vec<&Expression<CborValue>> = conditions.into_iter().collect();
355        let mut resolved: Vec<Expression<CborValue>> = Vec::with_capacity(raw.len());
356        for cond in raw {
357            resolved.push(resolve_deferreds(cond.clone()).await?);
358        }
359        let conds: Vec<&Expression<CborValue>> = resolved.iter().collect();
360        let (endpoint, consumed) = self.endpoint_url(table_name, &conds)?;
361
362        // Under `FilterStrategy::Client`, non-path eq-conditions are applied
363        // to the fetched rows in memory rather than sent as query params (the
364        // API rejects/ignores unknown params). Collect them, and keep them out
365        // of the query string by marking every condition as consumed.
366        let (query_consumed, client_filters): (Vec<usize>, Vec<(String, String)>) =
367            if self.filter_strategy == FilterStrategy::Client {
368                let filters = conds
369                    .iter()
370                    .enumerate()
371                    .filter(|(i, _)| !consumed.contains(i))
372                    .filter_map(|(_, c)| crate::condition_to_query_param(c))
373                    .collect();
374                ((0..conds.len()).collect(), filters)
375            } else {
376                (consumed, Vec::new())
377            };
378
379        let url = format!(
380            "{}{}",
381            endpoint,
382            self.build_query_string(window, &conds, &query_consumed)
383        );
384
385        let mut request = self.client.get(&url);
386        if let Some(ref auth) = self.auth_header {
387            request = request.header("Authorization", auth);
388        }
389
390        let response = request
391            .send()
392            .await
393            .map_err(|e| error!("API request failed", url = url, detail = e))?;
394
395        if !response.status().is_success() {
396            return Err(error!(
397                "API returned error status",
398                url = url,
399                status = response.status().as_u16()
400            ));
401        }
402
403        let body: serde_json::Value = response
404            .json()
405            .await
406            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
407
408        Ok((body, client_filters))
409    }
410
411    async fn fetch_windowed<'a>(
412        &self,
413        table_name: &str,
414        id_field: Option<&str>,
415        window: Option<(i64, i64)>,
416        conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
417    ) -> Result<IndexMap<String, Record<CborValue>>> {
418        // Non-paginating endpoints return the whole list on the first
419        // window; a later window would just re-deliver the same rows and the
420        // perpetual grid would never mark itself exhausted. Short-circuit any
421        // window past the start to empty so the grid sees the chunk shrink
422        // and stops asking for more.
423        if self.no_pagination && window.is_some_and(|(offset, _)| offset > 0) {
424            return Ok(IndexMap::new());
425        }
426
427        let (body, client_filters) = self.fetch_raw_body(table_name, window, conditions).await?;
428        let data = self.extract_array(&body, table_name)?;
429
430        let mut records = IndexMap::new();
431        for (row_idx, item) in data.iter().enumerate() {
432            let obj = item
433                .as_object()
434                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
435
436            // Extract ID from the configured id_field, or use row index
437            let id = id_field
438                .and_then(|field| obj.get(field))
439                .and_then(|v| match v {
440                    serde_json::Value::String(s) => Some(s.clone()),
441                    serde_json::Value::Number(n) => Some(n.to_string()),
442                    _ => None,
443                })
444                .unwrap_or_else(|| row_idx.to_string());
445
446            // The HTTP body parses as JSON for free; convert to CBOR
447            // at this single boundary so the rest of the pipeline
448            // (Table, Vista) sees the universal carrier.
449            let mut record: Record<CborValue> = Record::new();
450            for (k, v) in obj {
451                let cbor = CborValue::serialized(v).map_err(|e| {
452                    error!(
453                        "JSON → CBOR conversion failed",
454                        field = k.clone(),
455                        detail = e.to_string()
456                    )
457                })?;
458                record.insert(k.clone(), cbor);
459            }
460
461            records.insert(id, record);
462        }
463
464        // Client-side filtering (FilterStrategy::Client): drop rows that
465        // don't match the non-path eq-conditions. A condition whose field
466        // is absent from a row is treated as a pass (it was a path/request
467        // param, not a record field) — mirroring the AWS connector and the
468        // Mercury CLI's own post-fetch `_filter_deployments`.
469        if !client_filters.is_empty() {
470            records.retain(|_id, record| {
471                client_filters
472                    .iter()
473                    .all(|(field, want)| match record.get(field) {
474                        Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
475                        None => true,
476                    })
477            });
478        }
479
480        Ok(records)
481    }
482}
483
484fn urlencode(s: &str) -> String {
485    urlencoding::encode(s).into_owned()
486}
487
488/// Walk an `Expression`'s parameter tree and force any `Deferred`
489/// branches to their resolved form. Used at the `fetch_records`
490/// boundary so the URL builder only sees sync scalars.
491///
492/// Recursion lives on the heap (boxed) because the future's body
493/// contains another `async` call of the same shape — Rust can't size
494/// a directly-recursive `async fn` without indirection.
495fn resolve_deferreds(
496    mut expr: Expression<CborValue>,
497) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
498    Box::pin(async move {
499        for param in expr.parameters.iter_mut() {
500            match param {
501                ExpressiveEnum::Deferred(deferred) => {
502                    *param = deferred.call().await?;
503                }
504                ExpressiveEnum::Nested(inner) => {
505                    let resolved = resolve_deferreds(inner.clone()).await?;
506                    *inner = resolved;
507                }
508                ExpressiveEnum::Scalar(_) => {}
509            }
510        }
511        Ok(expr)
512    })
513}
514
515impl RestApi {
516    /// Pull the row array out of the response body, according to the
517    /// configured `ResponseShape`.
518    fn extract_array<'a>(
519        &self,
520        body: &'a serde_json::Value,
521        table_name: &str,
522    ) -> Result<&'a Vec<serde_json::Value>> {
523        match &self.response_shape {
524            ResponseShape::BareArray => body.as_array().ok_or_else(|| {
525                error!("Expected response body to be a JSON array (BareArray shape)")
526            }),
527            ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
528                error!(
529                    "Response missing array under wrapper key",
530                    array_key = array_key
531                )
532            }),
533            ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
534                error!(
535                    "Response missing array under table-name key",
536                    table_name = table_name
537                )
538            }),
539        }
540    }
541}
542
543/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
544/// override the pagination parameter names.
545///
546/// ```no_run
547/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
548///
549/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
550/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
551///     .response_shape(ResponseShape::BareArray)
552///     .build();
553///
554/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
555/// let api = RestApi::builder("https://dummyjson.com")
556///     .response_shape(ResponseShape::WrappedByTableName)
557///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
558///     .build();
559/// ```
560#[derive(Clone, Debug)]
561pub struct RestApiBuilder {
562    base_url: String,
563    auth_header: Option<String>,
564    response_shape: ResponseShape,
565    pagination: PaginationParams,
566    no_pagination: bool,
567    filter_strategy: FilterStrategy,
568    total_key: Option<String>,
569    debug: bool,
570}
571
572impl RestApiBuilder {
573    fn new(base_url: String) -> Self {
574        Self {
575            base_url,
576            auth_header: None,
577            response_shape: ResponseShape::default(),
578            pagination: PaginationParams::default(),
579            no_pagination: false,
580            filter_strategy: FilterStrategy::default(),
581            total_key: None,
582            debug: false,
583        }
584    }
585
586    /// Set the Authorization header value (e.g. "Bearer `<token>`").
587    pub fn auth(mut self, auth: impl Into<String>) -> Self {
588        self.auth_header = Some(auth.into());
589        self
590    }
591
592    /// Choose how the API wraps its row array. Defaults to
593    /// `Wrapped { array_key: "data" }` for backwards compat.
594    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
595        self.response_shape = shape;
596        self
597    }
598
599    /// Override the page/limit query parameter names. Default is
600    /// `("_page", "_limit")` (JSON Server convention).
601    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
602        self.pagination = pagination;
603        self
604    }
605
606    /// Disable pagination entirely — no `_page`/`_limit` query
607    /// params are appended, and a request for page > 1 is short-
608    /// circuited to an empty result. Use this for APIs that don't
609    /// paginate (return the full list every call) or that treat
610    /// unknown query params as strict filters.
611    pub fn no_pagination(mut self) -> Self {
612        self.no_pagination = true;
613        self
614    }
615
616    /// Choose how non-path eq-conditions are applied. Default is
617    /// [`FilterStrategy::Query`]; use [`FilterStrategy::Client`] for
618    /// APIs that only filter via path segments and reject/ignore unknown
619    /// query params (the conditions are then applied in memory).
620    pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
621        self.filter_strategy = strategy;
622        self
623    }
624
625    /// Name the response-envelope key carrying the grand total of matching
626    /// rows (e.g. `count`). Setting it lets the shell report an exact count
627    /// and advertise `can_fetch_window` for lazy/scroll loading.
628    pub fn total_key(mut self, key: impl Into<String>) -> Self {
629        self.total_key = Some(key.into());
630        self
631    }
632
633    /// Emit `tracing` events for window/count requests.
634    pub fn debug(mut self, debug: bool) -> Self {
635        self.debug = debug;
636        self
637    }
638
639    pub fn build(self) -> RestApi {
640        RestApi {
641            base_url: self.base_url,
642            client: reqwest::Client::new(),
643            auth_header: self.auth_header,
644            response_shape: self.response_shape,
645            pagination: self.pagination,
646            no_pagination: self.no_pagination,
647            filter_strategy: self.filter_strategy,
648            total_key: self.total_key,
649            debug: self.debug,
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    /// `build_query_string` with no conditions, exercising only the
659    /// window → pagination-param mapping.
660    fn qs(api: &RestApi, window: Option<(i64, i64)>) -> String {
661        api.build_query_string(window, &[], &[])
662    }
663
664    #[test]
665    fn skip_based_window_uses_offset_verbatim() {
666        let api = RestApi::builder("http://x")
667            .pagination_params(PaginationParams::skip_limit("skip", "limit"))
668            .build();
669        assert_eq!(qs(&api, Some((20, 10))), "?skip=20&limit=10");
670    }
671
672    #[test]
673    fn page_based_window_derives_one_based_page() {
674        let api = RestApi::builder("http://x").build(); // default _page/_limit
675        // offset 20 / limit 10 → page 3 (1-based).
676        assert_eq!(qs(&api, Some((20, 10))), "?_page=3&_limit=10");
677    }
678
679    #[test]
680    fn no_window_emits_no_pagination_params() {
681        let api = RestApi::builder("http://x").build();
682        assert_eq!(qs(&api, None), "");
683    }
684
685    #[test]
686    fn no_pagination_suppresses_window_params() {
687        let api = RestApi::builder("http://x").no_pagination().build();
688        assert_eq!(qs(&api, Some((20, 10))), "");
689    }
690}