vantage_api_client/rest/api.rs
1//! [`RestApi`]: the REST data source, its configuration and its one-off
2//! requests. Reads are lowered to URLs in `request`, sent in `fetch`, and
3//! assembled into windows in `window` and `page`.
4
5use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
6use vantage_api_pool::resilient::{ResilientClient, TransportEvent};
7use vantage_core::{Priority, error};
8
9use crate::transport::AuthHeader;
10
11mod builder;
12mod config;
13mod fetch;
14mod page;
15mod preview;
16mod request;
17mod window;
18
19pub use builder::RestApiBuilder;
20pub(crate) use config::Order;
21pub use config::{FilterStrategy, OrderingParams, PaginationMode, PaginationParams, ResponseShape};
22
23/// The REST shell's `driver_name`.
24pub(crate) const REST_DRIVER: &str = "rest-api";
25
26/// REST API backend for Vantage — reads data from HTTP JSON endpoints.
27///
28/// Each table maps to an API endpoint: `{base_url}/{table_name}`.
29/// Response shape, paging, filtering and auth are set through
30/// [`RestApi::builder`]. Table writes return `Unsupported` errors; send a
31/// write with [`http_request`](Self::http_request). Cheap to clone: the
32/// inner [`ResilientClient`] is shared.
33#[derive(Clone, Debug)]
34pub struct RestApi {
35 base_url: String,
36 client: ResilientClient,
37 auth_header: AuthHeader,
38 response_shape: ResponseShape,
39 pagination: PaginationParams,
40 /// Paging params were configured explicitly, so windows can be fetched
41 /// even with no `total_key` — the end of the set shows as a short page.
42 paged: bool,
43 /// When true, no `_page`/`_limit` query params are appended and
44 /// list endpoints are assumed to return the full result set in
45 /// one shot. Caller-side requests for page > 1 short-circuit to
46 /// an empty result so a perpetual-grid stops paging after the
47 /// first chunk. Useful for FastAPI/Pydantic services that treat
48 /// unknown query params as strict filters.
49 no_pagination: bool,
50 /// How non-path eq-conditions are applied — query params vs.
51 /// in-memory post-fetch filtering. See [`FilterStrategy`].
52 filter_strategy: FilterStrategy,
53 /// Response-envelope key carrying the grand total of matching rows
54 /// (e.g. `count`). When set, the shell reports an exact count and
55 /// advertises the `Window` capability for lazy/scroll loading; when
56 /// `None` it falls back to counting fetched rows.
57 total_key: Option<String>,
58 /// The sort query param, when the API has one. Unset means the API
59 /// cannot sort and consumers order fetched rows themselves.
60 ordering: Option<OrderingParams>,
61 /// Emit `tracing` events for window/count requests.
62 debug: bool,
63}
64
65impl RestApi {
66 /// Create a new REST API pointing at `base_url`, with every builder
67 /// default: the [`ResponseShape::default`] envelope (`{ "data": [...] }`)
68 /// and JSON Server paging params. For other shapes (bare array,
69 /// wrapped-by-table-name) or auth, use [`RestApi::builder`].
70 pub fn new(base_url: impl Into<String>) -> Self {
71 RestApi::builder(base_url).build()
72 }
73
74 /// Start configuring a [`RestApi`] via the builder.
75 pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
76 RestApiBuilder::new(base_url.into())
77 }
78
79 /// The configured response-envelope total key, if any. When set, the
80 /// REST shell can report an exact count and serve `fetch_window`.
81 pub fn total_key(&self) -> Option<&str> {
82 self.total_key.as_deref()
83 }
84
85 /// Whether the shell can serve absolute-offset windows: the API pages
86 /// (explicit `pagination_params`, or a `total_key`) and pagination isn't
87 /// switched off. Without a total the loader learns the end of the set
88 /// from a short page.
89 pub(crate) fn serves_windows(&self) -> bool {
90 !self.no_pagination && (self.paged || self.total_key.is_some())
91 }
92
93 /// The sort query param, when configured.
94 pub fn ordering(&self) -> Option<&OrderingParams> {
95 self.ordering.as_ref()
96 }
97
98 /// What the circuit breaker is doing right now. `None` when the
99 /// underlying client has no breaker configured.
100 pub fn breaker_state(&self) -> Option<crate::BreakerState> {
101 self.client.breaker_state()
102 }
103
104 /// The resilient client backing this API — the pool, breaker and
105 /// observer a caller outside the read path (e.g. an outbox replaying a
106 /// queued write) should share rather than build its own.
107 pub fn client(&self) -> &ResilientClient {
108 &self.client
109 }
110
111 /// The configured base URL requests are joined against.
112 pub fn base_url(&self) -> &str {
113 &self.base_url
114 }
115
116 /// A clone whose requests carry the bearer token `source` gives at the
117 /// moment each one goes out, in place of the configured `auth`. The
118 /// clone shares this API's client — connection pool, parallel cap,
119 /// breaker and observer — so one clone per session costs no extra
120 /// connections. See [`BearerSource`](crate::BearerSource).
121 ///
122 /// The client adds an [`auth_refresher`](RestApiBuilder::auth_refresher)'s
123 /// header itself, so a clone can't withdraw it: build the shared API
124 /// without a refresher when its clones bring their own tokens.
125 pub fn with_bearer_source(&self, source: crate::BearerSource) -> Self {
126 Self {
127 auth_header: AuthHeader::Source(source),
128 ..self.clone()
129 }
130 }
131
132 /// Issue an arbitrary HTTP request against `base_url`/`path`, through
133 /// the same resilient client and auth header as reads.
134 ///
135 /// `headers` are applied after the configured `Authorization` header, so
136 /// a caller-supplied header of the same name wins. `body`, when given,
137 /// is sent as the JSON request body. The call policy follows
138 /// [`vantage_core::Priority::current`], same as a table read. On success,
139 /// a non-`GET` method reports [`TransportEvent::WritePushed`] to the
140 /// observer.
141 ///
142 /// A header name or value that HTTP cannot carry (a newline, say) is an
143 /// error, returned before anything is sent.
144 pub async fn http_request(
145 &self,
146 method: reqwest::Method,
147 path: &str,
148 headers: &[(&str, &str)],
149 body: Option<&serde_json::Value>,
150 ) -> vantage_core::Result<reqwest::Response> {
151 let url = join_base_path(&self.base_url, path);
152
153 // A fixed value is checked before anything is sent; a bad one would
154 // otherwise surface as a transport error, which an essential call
155 // retries forever. A bearer source is asked per attempt, below.
156 if let AuthHeader::Fixed(auth) = &self.auth_header {
157 HeaderValue::from_str(auth)
158 .map_err(|_| error!("Configured Authorization header value is not valid"))?;
159 }
160
161 let mut header_map = HeaderMap::new();
162 for (name, value) in headers {
163 let header_name = HeaderName::from_bytes(name.as_bytes())
164 .map_err(|_| error!("HTTP header name is not valid", name = *name))?;
165 // The value may be a secret, so the error names only the header.
166 let header_value = HeaderValue::from_str(value)
167 .map_err(|_| error!("HTTP header value is not valid", name = *name))?;
168 header_map.insert(header_name, header_value);
169 }
170
171 let policy = crate::transport::policy_for(Priority::current());
172 let response = self
173 .client
174 .execute_with(&policy, |http| {
175 // `RequestBuilder::headers` replaces same-named entries
176 // rather than appending, so a caller header overrides the
177 // configured auth instead of riding alongside it on the wire.
178 let req = self
179 .auth_header
180 .apply(http.request(method.clone(), &url))
181 .headers(header_map.clone());
182 match body {
183 Some(body) => req.json(body),
184 None => req,
185 }
186 })
187 .await
188 .map_err(|e| crate::transport::client_error(e, "API request failed", &url))?;
189
190 if method != reqwest::Method::GET {
191 self.client.report(TransportEvent::WritePushed);
192 }
193
194 Ok(response)
195 }
196}
197
198/// Join a base URL and a path with exactly one slash between them,
199/// regardless of whether either side already carries one.
200fn join_base_path(base: &str, path: &str) -> String {
201 format!(
202 "{}/{}",
203 base.trim_end_matches('/'),
204 path.trim_start_matches('/')
205 )
206}