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