vantage-api-client 2.2.1

Vantage extension for REST and GraphQL HTTP API backends
Documentation
# vantage-api-client Architecture

Two adapters, one crate: `RestApi` turns a `Table<RestApi, E>` into
`GET {base_url}/{endpoint}?…`, and `GraphqlApi` turns a `Table<GraphqlApi, E>`
into a rendered query document posted to one endpoint. Both implement
`vantage-table`'s `TableSource`, both bridge into `vantage-vista`'s `Vista`
through a factory, and both send through one `ResilientClient` per API.
This document is for maintainers and adapter authors; the reader guides
are linked from the [README](README.md).

## Layer diagram

```
Vista (vantage-vista)                  what UIs and Diorama consume
   │  RestApiVistaFactory / GraphqlApiVistaFactory     vista/factory.rs, vista/spec.rs
   │  GraphQL spec lowering + references               graphql/vista/lowering.rs, traversal.rs
   │  RestApiTableShell / GraphqlApiTableShell          vista/source.rs, graphql/vista/paging.rs
   ▼
Table<RestApi, E> / Table<GraphqlApi, E>  (vantage-table)
   │  TableSource impl                                  rest/table_source.rs, graphql/impls/
   │    (GraphQL: count, get_by_id, search, local_filter split out)
   ▼
RestApi                      GraphqlApi
   endpoint templates           GraphqlSelect builder + renderer   graphql/select/
   query string / client        GraphqlCondition + FilterDialect    graphql/condition.rs, operation.rs
     filters, pagination        typed scalars (AnyGraphqlType)      graphql/types/
   response shapes              response_path, root_args, aliases
                                name validation                     graphql/name.rs
   row keys (both)                                                  row_keys.rs
   │  fetch_raw_body              │  post_graphql
   ▼                              ▼
transport.rs        ClientConfig · build_client · policy_for · client_error
   ▼
ResilientClient (vantage-api-pool)   cap · rate limit · breaker · retry · observer
   ▼
reqwest
```

## Request lifecycle: REST

`RestApi::fetch_raw_body(table_name, window, conditions)` is the single
request site for REST.

1. **Resolve deferred conditions.** A `with_one` traversal may carry a
   `DeferredFn` that fetches the parent row at request time; every
   condition is resolved to a plain scalar first.
2. **Endpoint.** `table_name` is a path under `base_url`, optionally with
   `{placeholder}` segments (`users/{userId}/albums`). Each placeholder is
   filled from the eq-condition whose field has that name; those conditions
   are consumed. A missing one is an error.
3. **Filters.** Under `FilterStrategy::Query` the remaining eq-conditions
   become `?field=value`. Under `FilterStrategy::Client` none go on the
   wire; they are kept as `(field, value)` pairs and applied to the parsed
   rows, which is for APIs that reject or ignore unknown query params.
4. **Pagination.** A window `(offset, limit)` is appended as
   `?_page=N&_limit=M` (1-based page) or `?skip=N&limit=M` (0-based offset)
   per `PaginationParams`. `no_pagination` short-circuits any page after the
   first to an empty result.
5. **Send.** `policy_for(Priority::current())` picks `essential()` or
   `background()`; `execute_with` builds `GET url` with the auth header on
   every attempt. Failures become `VantageError { "API request failed",
   url, attempts, kind, status?, body? }` via `client_error`.
6. **Parse.** The body is read as JSON; `ResponseShape` locates the row
   array: the body itself (`BareArray`), `body[array_key]` (`Wrapped`), or
   `body[table_name]` (`WrappedByTableName`). `total_key`, when configured,
   is read from the envelope. Each row becomes a `Record<CborValue>` keyed
   by the id field; a row without a usable id is keyed `~row:<position>`
   (absolute position in the result), and two rows with the same id in one
   response are an error (`row_keys.rs`). Client filters are applied.
   `RowsPulled { n }` is reported with the final count.

`fetch_total` sends a `(0, 1)` window and reads only `total_key`; it is the
count probe the paged grid uses to size its scrollbar. `preview_request`
renders the same URL without sending, and masks the auth header.

Capabilities advertised to Vista: `can_count`, `can_traverse_to_record`,
`can_fetch_window` when the API pages (`pagination_params`, or a
`total_key`, and not `no_pagination`), and `can_order` when an
`OrderingParams` sort param is configured. No server-side search or
operators: a REST Vista searches what it loaded. REST is read-only at the
`TableSource` level (writes and `sum`/`min`/`max` return `Unsupported`);
writes go through `RestApi::http_request`.

## Request lifecycle: GraphQL

`GraphqlApi::post_graphql(query, variables)` is the single request site.

1. **Build.** `GraphqlSelect` collects the root field, the selected fields
   (nested objects render as `rocket { rocket_name }`), the condition tree,
   ordering and pagination. `root_args` (literal arguments from YAML or the
   builder) render before the filter.
2. **Render.** The filter renders inline as GraphQL value syntax under the
   dialect's argument name (`where` for Hasura, `find` or `filter_arg` for
   Generic) so no input-type name is needed; with `paginate` on, the page
   goes through typed variables `$limit: Int`, `$offset: Int`. Every name
   put into the document (root field, fields, argument names and keys,
   filter keys, order and count fields) is checked against the GraphQL
   `Name` grammar first (`graphql/name.rs`), and strings escape every
   control character. `preview` is the synchronous twin used by
   `preview_query`; it inlines `limit`/`offset`.
3. **Send.** Same policy selection as REST; `POST endpoint` with
   `{ query, variables }` and the auth header, rebuilt per attempt. A
   non-2xx becomes `VantageError { "GraphQL request failed", endpoint,
   attempts, kind, status?, body? }`.
4. **Parse.** A `2xx` whose envelope has a non-empty `errors` array is a
   logical error: `GraphQL response carried errors` with the joined
   messages. It happens after the transport succeeded, so it never retries
   and never counts against the breaker. Otherwise `data[root]`, descended
   through `response_path`, is the row array (or a single object for
   get-by-id). Rows are keyed as for REST. `RowsPulled` is reported after
   decoding.

Capabilities: `can_count`, `can_traverse_to_record`, and `can_order`,
`can_search`, `can_filter_operators` and the paging set according to the
`Supports` flags: filtering on by default, ordering and search on for
Hasura only, paging off until `paginate: true` (and only with filtering
on, `GraphqlApi::can_page`). A YAML `graphql:` block overrides each, so for
endpoints that ignore an argument the consumer sorts or filters locally
instead of sending something the server drops; with `filter: false` the
conditions run on the fetched rows (`impls/local_filter.rs`). GraphQL is
read-only: writes and `sum`/`min`/`max` return `Unsupported`.

Counting follows `Supports.count`: `CountMode::List` (the default) lists and
counts; `CountMode::Aggregate` on Hasura with filtering on queries
`<root>_aggregate { aggregate { count } }` (`impls/count.rs`). A rejected
aggregate is an error, never a silent fallback. Get-by-id
(`impls/get_by_id.rs`) adds the id filter only with filtering on and
`limit: 1` only with paging on; otherwise it lists and picks the row. An
int id column gets an integer literal.

Quicksearch (`impls/search.rs`) ORs `_ilike` over columns flagged
`searchable`, else over string-typed non-id columns; dotted columns are
skipped, and no column renders the always-false `{_not: {}}`. Ordering on a
dotted column is refused. Paged reads with no order are ordered by the id
column where the dialect can order (`vista/paging.rs`).

### Dialects

`FilterDialect::Hasura` renders `{ field: { _eq: v } }` with `_and`, `_or`,
`_not` and the full `GraphqlOp` set (`Eq Ne Gt Gte Lt Lte In NotIn Like
ILike IsNull IsNotNull`). `FilterDialect::Generic` renders `{ field: v }`,
equality only; any other operator, `OR` or `NOT` fails at render time with
an error naming the dialect. A Vista `FilterOp::Like` (contains) renders as
an escaped `_ilike: "%…%"` on Hasura; on Generic `add_op_condition` reports
it as `Unimplemented` so a Vista consumer evaluates it locally.

A column's `graphql: { field }` is a wire-name alias (`select/aliases.rs`):
the selection renders `column: field`, and conditions and sort keys are
renamed to `field` at render.

### Types

`AnyGraphqlType` wraps `serde_json::Value` with a scalar variant
(`Int`, `BigInt`, `Float`, `String`, `Id`, `DateTime`, `Date`, `Time`,
`Uuid`, `Decimal`, `Json`, `Object`, `Array`, `Bool`, `Null`) so a value's
meaning survives the JSON round trip; `GraphqlType` maps Rust types onto
variants, and `vantage_type_system!` lets a downstream crate widen the
variant set.

## Traversal

Both adapters implement `related_in_condition` for `with_many` /
`with_one`:

- **Peel.** When the parent table carries an eq-condition on the join
  field (`users` narrowed to `id = 1`), the child gets that value directly:
  REST substitutes it into a template or adds a query param; GraphQL adds
  an eq filter.
- **Defer.** Otherwise the child gets a `DeferredFn` that fetches the parent
  row at request time and reads the foreign key from it. REST resolves
  deferreds at the top of `fetch_raw_body`; GraphQL at render time.

A GraphQL relation is two round trips: the parent fetch produces the key,
the child fetch filters on it. A dotted column is the one-document
alternative (a nested selection).

## Vista bridge and YAML

`RestApiVistaSpec` / `GraphqlApiVistaSpec` are `vantage-vista` specs with
adapter extras. REST: `api: { endpoint }` per table (defaults to the table
name). GraphQL: `graphql: { root_field, dialect, filter_arg, args,
response_path, filter, order, search, paginate, count, count_field }` per
table and `graphql: { field }` per column. The factories build a `Vista`
whose `driver()` is `"rest-api"` or `"graphql"`. Both factories keep a
`register_yaml` registry so references resolve by name.

REST traverses YAML references in its shell, resolving the target to a
built `Vista` through a `VistaResolver`. GraphQL registers them on the
typed table with `Table::with_spec_references` and an `Arc<dyn SpecResolver<GraphqlApiVistaSpec>>`
(`with_resolver`), which returns the target's *spec*; `vista/lowering.rs`
lowers the target onto the factory's API so the parent table's `graphql:`
overrides never leak into it, and `vista/traversal.rs` checks the target
before traversal so a missing spec is an error rather than an empty table.

## Transport glue

`transport.rs` is the only file that knows both this crate and
`vantage-api-pool`:

- `ClientConfig { max_parallel: 4, rate_limit, observer, http,
  request_timeout, auth_refresher }` is what both builders collect;
  `build_client` turns it into a `ResilientClient` with
  `default_breaker()`.
- `policy_for(Priority)` maps `Essential` → `CallPolicy::essential()`,
  everything else → `background()`.
- `client_error(ClientError, what, url)` produces the `VantageError` shape
  above, keeping the URL and body in the context and out of the message.
- `AuthHeader` is the enum behind `auth_header`: none, a fixed value, or a
  `BearerSource` asked inside the request-building closure, so once per
  attempt. Its `Debug` prints `<set>` or `<per request>`; every `Debug`
  derive in the crate goes through it so a token can never reach a log.
  An `auth_refresher` isn't in it: the `ResilientClient` adds that header
  itself, which is why a `with_bearer_source` clone can't drop it.

The behaviour of the client itself, including the breaker rules and the
observer contract, is documented in
[vantage-api-pool/ARCHITECTURE.md](../vantage-api-pool/ARCHITECTURE.md);
the datasource-side view is [README_transport.md](README_transport.md).

## Errors

| Message | Where | Context keys |
|---|---|---|
| `API request failed` | REST transport/status | `url`, `attempts`, `kind`, `status`, `body` or `detail` |
| `GraphQL request failed` | GraphQL transport/status | `url`, `attempts`, `kind`, `status`, `body` or `detail` |
| `GraphQL response carried errors` | logical errors in a 2xx | `errors` (joined messages) |
| `Failed to parse API response as JSON` | body not JSON | `detail` |
| `Response missing array under wrapper key` | `ResponseShape` mismatch | `key` |
| `Expected response body to be a JSON array (BareArray shape)` | envelope where an array was expected | |
| `total_key missing or not an integer in API response` | count probe | `total_key` |
| `No eq-condition provided for URI placeholder` | template without its condition | `placeholder`, `table_name` |
| `Two rows in the response share an id` | duplicate id within one response | `id` |
| `REST data source supports reads only` | REST write or `sum`/`min`/`max` (`Unsupported`) | `method` |
| `GraphQL data source supports reads and count only` | GraphQL write or `sum`/`min`/`max` (`Unsupported`) | `method` |
| `GraphQL name is not valid` | a name outside the GraphQL `Name` grammar, at build or render | `kind`, `name` |
| `GraphQL server rejected the aggregate count; …` | `count: aggregate` refused by the server | `field`, `errors` |

`VantageError::Display` prints the message followed by `(key: value, …)`,
so status codes and server messages are greppable in logs and testable
with `to_string().contains(..)`.

## Concurrency

Nothing in this crate spawns. A request runs on the caller's task; cancel
the caller and the request, its retries and its permit are released. The
`ResilientClient` inside each `RestApi`/`GraphqlApi` is shared by every
clone of that API, so the parallel cap, rate limit, breaker and observer
key are per API instance: build one per remote API and clone it per table,
never one per table.

## Tests

- `tests/rest_transport.rs`, `tests/graphql_transport.rs`, with
  `tests/support/mod.rs`: wiremock contract tests for the transport
  behaviour (priority, retries, breaker, errors, observer, writes, custom
  client). Nothing touches the network.
- `tests/builder.rs`, `tests/yaml_factory.rs`: builder defaults and YAML
  spec parsing.
- `tests/graphql_vista_reads.rs`, `graphql_paging.rs`, `graphql_aliases.rs`,
  `graphql_references.rs`, `graphql_traversal.rs`: GraphQL Vista reads
  (order, search, get-by-id, paging, count, aliases, references).
- `tests/rest_open_paging.rs`, `rest_ordering.rs`: REST windows and sort.
- `tests/graphql_safety.rs`, `rest_safety.rs`: name validation, header
  errors, unsupported writes, row keys.
- Tests build clients through `support::test_client()` (`no_proxy`), so no
  system proxy lookup slows them.
- `src/**` unit tests: query-string rendering, templates, dialect
  rendering, type mapping. One `#[ignore]`d test hits the live Launch
  Library dev host.