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::{AUTHORIZATION, 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 /// Issue an arbitrary HTTP request against `base_url`/`path`, through
117 /// the same resilient client and auth header as reads.
118 ///
119 /// `headers` are applied after the configured `Authorization` header, so
120 /// a caller-supplied header of the same name wins. `body`, when given,
121 /// is sent as the JSON request body. The call policy follows
122 /// [`vantage_core::Priority::current`], same as a table read. On success,
123 /// a non-`GET` method reports [`TransportEvent::WritePushed`] to the
124 /// observer.
125 ///
126 /// A header name or value that HTTP cannot carry (a newline, say) is an
127 /// error, returned before anything is sent.
128 pub async fn http_request(
129 &self,
130 method: reqwest::Method,
131 path: &str,
132 headers: &[(&str, &str)],
133 body: Option<&serde_json::Value>,
134 ) -> vantage_core::Result<reqwest::Response> {
135 let url = join_base_path(&self.base_url, path);
136
137 // `HeaderMap::insert` replaces a same-named entry rather than
138 // appending — unlike `RequestBuilder::header` — so a caller header
139 // actually overrides the configured auth instead of riding alongside
140 // it on the wire.
141 let mut header_map = HeaderMap::new();
142 if let Some(auth) = self.auth_header.value() {
143 let value = HeaderValue::from_str(auth)
144 .map_err(|_| error!("Configured Authorization header value is not valid"))?;
145 header_map.insert(AUTHORIZATION, value);
146 }
147 for (name, value) in headers {
148 let header_name = HeaderName::from_bytes(name.as_bytes())
149 .map_err(|_| error!("HTTP header name is not valid", name = *name))?;
150 // The value may be a secret, so the error names only the header.
151 let header_value = HeaderValue::from_str(value)
152 .map_err(|_| error!("HTTP header value is not valid", name = *name))?;
153 header_map.insert(header_name, header_value);
154 }
155
156 let policy = crate::transport::policy_for(Priority::current());
157 let response = self
158 .client
159 .execute_with(&policy, |http| {
160 let req = http
161 .request(method.clone(), &url)
162 .headers(header_map.clone());
163 match body {
164 Some(body) => req.json(body),
165 None => req,
166 }
167 })
168 .await
169 .map_err(|e| crate::transport::client_error(e, "API request failed", &url))?;
170
171 if method != reqwest::Method::GET {
172 self.client.report(TransportEvent::WritePushed);
173 }
174
175 Ok(response)
176 }
177}
178
179/// Join a base URL and a path with exactly one slash between them,
180/// regardless of whether either side already carries one.
181fn join_base_path(base: &str, path: &str) -> String {
182 format!(
183 "{}/{}",
184 base.trim_end_matches('/'),
185 path.trim_start_matches('/')
186 )
187}
188
189#[cfg(test)]
190mod tests;