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