Skip to main content

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}