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 query = self.build_query_string(window, &conds, &query_consumed);
380 let url = join_query(&endpoint, &query);
381
382 let mut request = self.client.get(&url);
383 if let Some(ref auth) = self.auth_header {
384 request = request.header("Authorization", auth);
385 }
386
387 let response = request
388 .send()
389 .await
390 .map_err(|e| error!("API request failed", url = url, detail = e))?;
391
392 if !response.status().is_success() {
393 return Err(error!(
394 "API returned error status",
395 url = url,
396 status = response.status().as_u16()
397 ));
398 }
399
400 let body: serde_json::Value = response
401 .json()
402 .await
403 .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
404
405 Ok((body, client_filters))
406 }
407
408 async fn fetch_windowed<'a>(
409 &self,
410 table_name: &str,
411 id_field: Option<&str>,
412 window: Option<(i64, i64)>,
413 conditions: impl IntoIterator<Item = &'a Expression<CborValue>>,
414 ) -> Result<IndexMap<String, Record<CborValue>>> {
415 // Non-paginating endpoints return the whole list on the first
416 // window; a later window would just re-deliver the same rows and the
417 // perpetual grid would never mark itself exhausted. Short-circuit any
418 // window past the start to empty so the grid sees the chunk shrink
419 // and stops asking for more.
420 if self.no_pagination && window.is_some_and(|(offset, _)| offset > 0) {
421 return Ok(IndexMap::new());
422 }
423
424 let (body, client_filters) = self.fetch_raw_body(table_name, window, conditions).await?;
425 let data = self.extract_array(&body, table_name)?;
426
427 let mut records = IndexMap::new();
428 for (row_idx, item) in data.iter().enumerate() {
429 let obj = item
430 .as_object()
431 .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
432
433 // Extract ID from the configured id_field, or use row index
434 let id = id_field
435 .and_then(|field| obj.get(field))
436 .and_then(|v| match v {
437 serde_json::Value::String(s) => Some(s.clone()),
438 serde_json::Value::Number(n) => Some(n.to_string()),
439 _ => None,
440 })
441 .unwrap_or_else(|| row_idx.to_string());
442
443 // The HTTP body parses as JSON for free; convert to CBOR
444 // at this single boundary so the rest of the pipeline
445 // (Table, Vista) sees the universal carrier.
446 let mut record: Record<CborValue> = Record::new();
447 for (k, v) in obj {
448 let cbor = CborValue::serialized(v).map_err(|e| {
449 error!(
450 "JSON → CBOR conversion failed",
451 field = k.clone(),
452 detail = e.to_string()
453 )
454 })?;
455 record.insert(k.clone(), cbor);
456 }
457
458 records.insert(id, record);
459 }
460
461 // Client-side filtering (FilterStrategy::Client): drop rows that
462 // don't match the non-path eq-conditions. A condition whose field
463 // is absent from a row is treated as a pass (it was a path/request
464 // param, not a record field) — mirroring the AWS connector and the
465 // Mercury CLI's own post-fetch `_filter_deployments`.
466 if !client_filters.is_empty() {
467 records.retain(|_id, record| {
468 client_filters
469 .iter()
470 .all(|(field, want)| match record.get(field) {
471 Some(v) => crate::cbor_to_query_string(v).as_deref() == Some(want.as_str()),
472 None => true,
473 })
474 });
475 }
476
477 Ok(records)
478 }
479}
480
481fn urlencode(s: &str) -> String {
482 urlencoding::encode(s).into_owned()
483}
484
485/// Append a `build_query_string` result (always opening with `?`, or empty)
486/// to an endpoint URL. The table path may itself carry a query string (e.g.
487/// `launches/?mode=detailed`), in which case the appended params must join
488/// with `&` — otherwise the URL gets two `?` and the API rejects it.
489fn join_query(endpoint: &str, query: &str) -> String {
490 match query.strip_prefix('?') {
491 Some(rest) if endpoint.contains('?') => format!("{endpoint}&{rest}"),
492 _ => format!("{endpoint}{query}"),
493 }
494}
495
496/// Walk an `Expression`'s parameter tree and force any `Deferred`
497/// branches to their resolved form. Used at the `fetch_records`
498/// boundary so the URL builder only sees sync scalars.
499///
500/// Recursion lives on the heap (boxed) because the future's body
501/// contains another `async` call of the same shape — Rust can't size
502/// a directly-recursive `async fn` without indirection.
503fn resolve_deferreds(
504 mut expr: Expression<CborValue>,
505) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Expression<CborValue>>> + Send>> {
506 Box::pin(async move {
507 for param in expr.parameters.iter_mut() {
508 match param {
509 ExpressiveEnum::Deferred(deferred) => {
510 *param = deferred.call().await?;
511 }
512 ExpressiveEnum::Nested(inner) => {
513 let resolved = resolve_deferreds(inner.clone()).await?;
514 *inner = resolved;
515 }
516 ExpressiveEnum::Scalar(_) => {}
517 }
518 }
519 Ok(expr)
520 })
521}
522
523impl RestApi {
524 /// Pull the row array out of the response body, according to the
525 /// configured `ResponseShape`.
526 fn extract_array<'a>(
527 &self,
528 body: &'a serde_json::Value,
529 table_name: &str,
530 ) -> Result<&'a Vec<serde_json::Value>> {
531 match &self.response_shape {
532 ResponseShape::BareArray => body.as_array().ok_or_else(|| {
533 error!("Expected response body to be a JSON array (BareArray shape)")
534 }),
535 ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
536 error!(
537 "Response missing array under wrapper key",
538 array_key = array_key
539 )
540 }),
541 ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
542 error!(
543 "Response missing array under table-name key",
544 table_name = table_name
545 )
546 }),
547 }
548 }
549}
550
551/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
552/// override the pagination parameter names.
553///
554/// ```no_run
555/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
556///
557/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
558/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
559/// .response_shape(ResponseShape::BareArray)
560/// .build();
561///
562/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
563/// let api = RestApi::builder("https://dummyjson.com")
564/// .response_shape(ResponseShape::WrappedByTableName)
565/// .pagination_params(PaginationParams::skip_limit("skip", "limit"))
566/// .build();
567/// ```
568#[derive(Clone, Debug)]
569pub struct RestApiBuilder {
570 base_url: String,
571 auth_header: Option<String>,
572 response_shape: ResponseShape,
573 pagination: PaginationParams,
574 no_pagination: bool,
575 filter_strategy: FilterStrategy,
576 total_key: Option<String>,
577 debug: bool,
578}
579
580impl RestApiBuilder {
581 fn new(base_url: String) -> Self {
582 Self {
583 base_url,
584 auth_header: None,
585 response_shape: ResponseShape::default(),
586 pagination: PaginationParams::default(),
587 no_pagination: false,
588 filter_strategy: FilterStrategy::default(),
589 total_key: None,
590 debug: false,
591 }
592 }
593
594 /// Set the Authorization header value (e.g. "Bearer `<token>`").
595 pub fn auth(mut self, auth: impl Into<String>) -> Self {
596 self.auth_header = Some(auth.into());
597 self
598 }
599
600 /// Choose how the API wraps its row array. Defaults to
601 /// `Wrapped { array_key: "data" }` for backwards compat.
602 pub fn response_shape(mut self, shape: ResponseShape) -> Self {
603 self.response_shape = shape;
604 self
605 }
606
607 /// Override the page/limit query parameter names. Default is
608 /// `("_page", "_limit")` (JSON Server convention).
609 pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
610 self.pagination = pagination;
611 self
612 }
613
614 /// Disable pagination entirely — no `_page`/`_limit` query
615 /// params are appended, and a request for page > 1 is short-
616 /// circuited to an empty result. Use this for APIs that don't
617 /// paginate (return the full list every call) or that treat
618 /// unknown query params as strict filters.
619 pub fn no_pagination(mut self) -> Self {
620 self.no_pagination = true;
621 self
622 }
623
624 /// Choose how non-path eq-conditions are applied. Default is
625 /// [`FilterStrategy::Query`]; use [`FilterStrategy::Client`] for
626 /// APIs that only filter via path segments and reject/ignore unknown
627 /// query params (the conditions are then applied in memory).
628 pub fn filter_strategy(mut self, strategy: FilterStrategy) -> Self {
629 self.filter_strategy = strategy;
630 self
631 }
632
633 /// Name the response-envelope key carrying the grand total of matching
634 /// rows (e.g. `count`). Setting it lets the shell report an exact count
635 /// and advertise `can_fetch_window` for lazy/scroll loading.
636 pub fn total_key(mut self, key: impl Into<String>) -> Self {
637 self.total_key = Some(key.into());
638 self
639 }
640
641 /// Emit `tracing` events for window/count requests.
642 pub fn debug(mut self, debug: bool) -> Self {
643 self.debug = debug;
644 self
645 }
646
647 pub fn build(self) -> RestApi {
648 RestApi {
649 base_url: self.base_url,
650 client: reqwest::Client::new(),
651 auth_header: self.auth_header,
652 response_shape: self.response_shape,
653 pagination: self.pagination,
654 no_pagination: self.no_pagination,
655 filter_strategy: self.filter_strategy,
656 total_key: self.total_key,
657 debug: self.debug,
658 }
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 /// `build_query_string` with no conditions, exercising only the
667 /// window → pagination-param mapping.
668 fn qs(api: &RestApi, window: Option<(i64, i64)>) -> String {
669 api.build_query_string(window, &[], &[])
670 }
671
672 #[test]
673 fn skip_based_window_uses_offset_verbatim() {
674 let api = RestApi::builder("http://x")
675 .pagination_params(PaginationParams::skip_limit("skip", "limit"))
676 .build();
677 assert_eq!(qs(&api, Some((20, 10))), "?skip=20&limit=10");
678 }
679
680 #[test]
681 fn page_based_window_derives_one_based_page() {
682 let api = RestApi::builder("http://x").build(); // default _page/_limit
683 // offset 20 / limit 10 → page 3 (1-based).
684 assert_eq!(qs(&api, Some((20, 10))), "?_page=3&_limit=10");
685 }
686
687 #[test]
688 fn no_window_emits_no_pagination_params() {
689 let api = RestApi::builder("http://x").build();
690 assert_eq!(qs(&api, None), "");
691 }
692
693 #[test]
694 fn no_pagination_suppresses_window_params() {
695 let api = RestApi::builder("http://x").no_pagination().build();
696 assert_eq!(qs(&api, Some((20, 10))), "");
697 }
698
699 #[test]
700 fn query_string_joins_plain_endpoint_with_question_mark() {
701 assert_eq!(
702 join_query("http://x/launches/", "?_page=1&_limit=10"),
703 "http://x/launches/?_page=1&_limit=10"
704 );
705 }
706
707 #[test]
708 fn query_string_joins_templated_endpoint_with_ampersand() {
709 // Endpoint already carries `?mode=detailed`; pagination must append
710 // with `&`, not a second `?`.
711 assert_eq!(
712 join_query("http://x/launches/?mode=detailed", "?offset=0&limit=1"),
713 "http://x/launches/?mode=detailed&offset=0&limit=1"
714 );
715 }
716
717 #[test]
718 fn empty_query_string_leaves_endpoint_untouched() {
719 assert_eq!(
720 join_query("http://x/launches/?mode=detailed", ""),
721 "http://x/launches/?mode=detailed"
722 );
723 }
724
725 /// Live regression for the double-`?` bug: a real fetch against the
726 /// Launch Library 2 dev API using a table path that already carries a
727 /// query string (`launches/?mode=detailed`). Before the `join_query`
728 /// fix the request URL was `…/launches/?mode=detailed?offset=0&limit=1`
729 /// and the server answered 500. Network-gated, so `#[ignore]`d:
730 /// `cargo test -p vantage-api-client -- --ignored query_string`.
731 #[tokio::test]
732 #[ignore = "hits the live Launch Library 2 dev API"]
733 async fn live_templated_table_path_fetches_rows() {
734 let api = RestApi::builder("https://lldev.thespacedevs.com/2.3.0")
735 .pagination_params(PaginationParams::skip_limit("offset", "limit"))
736 .response_shape(ResponseShape::Wrapped {
737 array_key: "results".into(),
738 })
739 .total_key("count")
740 .build();
741
742 let total = api
743 .fetch_total("launches/?mode=detailed", [])
744 .await
745 .expect("fetch_total");
746 assert!(total.is_some_and(|n| n > 0), "expected a positive count");
747
748 let rows = api
749 .fetch_window_records("launches/?mode=detailed", Some("id"), 0, 3, [])
750 .await
751 .expect("fetch_window_records");
752 assert_eq!(rows.len(), 3, "expected the requested 3-row window");
753 }
754}