vantage_api_client/api.rs
1use indexmap::IndexMap;
2use serde_json::Value;
3use vantage_core::error;
4use vantage_dataset::traits::Result;
5use vantage_expressions::Expression;
6use vantage_table::pagination::Pagination;
7use vantage_types::Record;
8
9/// How the API wraps its row array in the response body.
10///
11/// Most public APIs use one of these three shapes; the legacy vantage
12/// "wrapped under `data`" shape is `Wrapped { array_key: "data" }`.
13#[derive(Clone, Debug)]
14pub enum ResponseShape {
15 /// Body is a bare JSON array of records.
16 /// Example: `GET /users` → `[ {…}, {…} ]`. JSONPlaceholder, GitHub, etc.
17 BareArray,
18
19 /// Body is a JSON object with the array under a fixed key.
20 /// Example: `GET /users` → `{ "data": [ … ] }`.
21 Wrapped { array_key: String },
22
23 /// Body is a JSON object with the array under a key matching the
24 /// table name. Example (DummyJSON):
25 /// `GET /products` → `{ "products": [ … ], "total": …, "skip": …, "limit": … }`.
26 WrappedByTableName,
27}
28
29impl Default for ResponseShape {
30 /// Default matches the legacy 0.1.x shape: `{ "data": [...] }`.
31 fn default() -> Self {
32 ResponseShape::Wrapped {
33 array_key: "data".to_string(),
34 }
35 }
36}
37
38/// Names of the page/limit query parameters the API expects.
39///
40/// Defaults to `("_page", "_limit")` — the JSON Server convention used
41/// by JSONPlaceholder. DummyJSON uses `("skip", "limit")` (in items not
42/// pages). Customise via `RestApiBuilder::pagination_params`.
43#[derive(Clone, Debug)]
44pub struct PaginationParams {
45 pub page: String,
46 pub limit: String,
47 /// If true, the page parameter is sent as a *0-based item offset*
48 /// (`skip`) instead of a 1-based page index. DummyJSON-style.
49 pub skip_based: bool,
50}
51
52impl PaginationParams {
53 pub fn page_limit(page: impl Into<String>, limit: impl Into<String>) -> Self {
54 Self {
55 page: page.into(),
56 limit: limit.into(),
57 skip_based: false,
58 }
59 }
60
61 pub fn skip_limit(skip: impl Into<String>, limit: impl Into<String>) -> Self {
62 Self {
63 page: skip.into(),
64 limit: limit.into(),
65 skip_based: true,
66 }
67 }
68}
69
70impl Default for PaginationParams {
71 fn default() -> Self {
72 Self::page_limit("_page", "_limit")
73 }
74}
75
76/// REST API backend for Vantage — reads data from HTTP JSON endpoints.
77///
78/// Each table maps to an API endpoint: `{base_url}/{table_name}`.
79/// Response shape is configurable via [`RestApi::builder`]; see
80/// [`ResponseShape`] for the supported variants.
81///
82/// Currently read-only — write operations return errors.
83#[derive(Clone, Debug)]
84pub struct RestApi {
85 base_url: String,
86 client: reqwest::Client,
87 pub(crate) auth_header: Option<String>,
88 response_shape: ResponseShape,
89 pagination: PaginationParams,
90 /// When true, no `_page`/`_limit` query params are appended and
91 /// list endpoints are assumed to return the full result set in
92 /// one shot. Caller-side requests for page > 1 short-circuit to
93 /// an empty result so a perpetual-grid stops paging after the
94 /// first chunk. Useful for FastAPI/Pydantic services that treat
95 /// unknown query params as strict filters.
96 no_pagination: bool,
97}
98
99impl RestApi {
100 /// Create a new REST API pointing at `base_url`. Uses the legacy
101 /// default response shape (`{ "data": [...] }`). For other shapes
102 /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
103 pub fn new(base_url: impl Into<String>) -> Self {
104 RestApi::builder(base_url).build()
105 }
106
107 /// Start configuring a [`RestApi`] via the builder.
108 pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
109 RestApiBuilder::new(base_url.into())
110 }
111
112 /// Set the Authorization header value (e.g. "Bearer `<token>`").
113 /// Provided for backwards compatibility — prefer
114 /// `RestApi::builder(...).auth(...)`.
115 pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
116 self.auth_header = Some(auth.into());
117 self
118 }
119
120 /// Build the endpoint URL for a given table name. No query string.
121 fn endpoint_url(&self, table_name: &str) -> String {
122 format!("{}/{}", self.base_url, table_name)
123 }
124
125 /// Build the combined query-string from pagination + conditions.
126 /// Conditions that don't peel cleanly into eq pairs are skipped (we
127 /// could fail loudly here, but silently ignoring matches the v1
128 /// "best effort" stance — the caller still gets correct data, just
129 /// with less efficient filtering).
130 fn build_query_string<'a>(
131 &self,
132 pagination: Option<&Pagination>,
133 conditions: impl IntoIterator<Item = &'a Expression<Value>>,
134 ) -> String {
135 let mut params: Vec<(String, String)> = Vec::new();
136
137 // Pagination first — matches the order users see in the URL bar.
138 // When `no_pagination` is set the API doesn't accept page/limit
139 // query params (and may treat them as strict filters that
140 // return empty), so we leave them off.
141 if !self.no_pagination {
142 if let Some(p) = pagination {
143 let page_value = if self.pagination.skip_based {
144 p.skip().to_string()
145 } else {
146 p.get_page().to_string()
147 };
148 params.push((self.pagination.page.clone(), page_value));
149 params.push((self.pagination.limit.clone(), p.limit().to_string()));
150 }
151 }
152
153 // Conditions: each `eq` becomes `?field=value`. Multiple
154 // conditions AND together (JSON Server semantics).
155 for cond in conditions {
156 if let Some((field, value)) = crate::condition_to_query_param(cond) {
157 params.push((field, value));
158 }
159 }
160
161 if params.is_empty() {
162 return String::new();
163 }
164 let mut s = String::from("?");
165 for (i, (k, v)) in params.iter().enumerate() {
166 if i > 0 {
167 s.push('&');
168 }
169 // Minimal URL encoding — we encode `&` and `=` and spaces
170 // because those break the query format. Anything else
171 // passes through; the JSON Server convention is permissive.
172 s.push_str(&urlencode(k));
173 s.push('=');
174 s.push_str(&urlencode(v));
175 }
176 s
177 }
178
179 /// Fetch data from the API endpoint and return parsed records.
180 ///
181 /// `id_field` selects which JSON field is treated as the record ID;
182 /// if `None`, row indices are used. `pagination` and `conditions`
183 /// are pushed into the URL query string — eq-conditions become
184 /// `?field=value`. Conditions that can't be peeled into a simple
185 /// eq are silently skipped (caller-side filtering still applies if
186 /// needed).
187 pub(crate) async fn fetch_records<'a>(
188 &self,
189 table_name: &str,
190 id_field: Option<&str>,
191 pagination: Option<&Pagination>,
192 conditions: impl IntoIterator<Item = &'a Expression<Value>>,
193 ) -> Result<IndexMap<String, Record<serde_json::Value>>> {
194 // Non-paginating endpoints return the whole list on page 1; a
195 // page-2 fetch would just re-deliver the same rows and the
196 // perpetual grid would never mark itself exhausted. Short-
197 // circuit page > 1 to empty so the grid sees the chunk shrink
198 // and stops asking for more.
199 if self.no_pagination {
200 if let Some(p) = pagination {
201 if p.get_page() > 1 {
202 return Ok(IndexMap::new());
203 }
204 }
205 }
206
207 let url = format!(
208 "{}{}",
209 self.endpoint_url(table_name),
210 self.build_query_string(pagination, conditions)
211 );
212
213 let mut request = self.client.get(&url);
214 if let Some(ref auth) = self.auth_header {
215 request = request.header("Authorization", auth);
216 }
217
218 let response = request
219 .send()
220 .await
221 .map_err(|e| error!("API request failed", url = url, detail = e))?;
222
223 if !response.status().is_success() {
224 return Err(error!(
225 "API returned error status",
226 url = url,
227 status = response.status().as_u16()
228 ));
229 }
230
231 let body: serde_json::Value = response
232 .json()
233 .await
234 .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
235
236 let data = self.extract_array(&body, table_name)?;
237
238 let mut records = IndexMap::new();
239 for (row_idx, item) in data.iter().enumerate() {
240 let obj = item
241 .as_object()
242 .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
243
244 // Extract ID from the configured id_field, or use row index
245 let id = id_field
246 .and_then(|field| obj.get(field))
247 .and_then(|v| match v {
248 serde_json::Value::String(s) => Some(s.clone()),
249 serde_json::Value::Number(n) => Some(n.to_string()),
250 _ => None,
251 })
252 .unwrap_or_else(|| row_idx.to_string());
253
254 let record: Record<serde_json::Value> =
255 obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
256
257 records.insert(id, record);
258 }
259
260 Ok(records)
261 }
262}
263
264fn urlencode(s: &str) -> String {
265 urlencoding::encode(s).into_owned()
266}
267
268impl RestApi {
269 /// Pull the row array out of the response body, according to the
270 /// configured `ResponseShape`.
271 fn extract_array<'a>(
272 &self,
273 body: &'a serde_json::Value,
274 table_name: &str,
275 ) -> Result<&'a Vec<serde_json::Value>> {
276 match &self.response_shape {
277 ResponseShape::BareArray => body.as_array().ok_or_else(|| {
278 error!("Expected response body to be a JSON array (BareArray shape)")
279 }),
280 ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
281 error!(
282 "Response missing array under wrapper key",
283 array_key = array_key
284 )
285 }),
286 ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
287 error!(
288 "Response missing array under table-name key",
289 table_name = table_name
290 )
291 }),
292 }
293 }
294}
295
296/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
297/// override the pagination parameter names.
298///
299/// ```no_run
300/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
301///
302/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
303/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
304/// .response_shape(ResponseShape::BareArray)
305/// .build();
306///
307/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
308/// let api = RestApi::builder("https://dummyjson.com")
309/// .response_shape(ResponseShape::WrappedByTableName)
310/// .pagination_params(PaginationParams::skip_limit("skip", "limit"))
311/// .build();
312/// ```
313#[derive(Clone, Debug)]
314pub struct RestApiBuilder {
315 base_url: String,
316 auth_header: Option<String>,
317 response_shape: ResponseShape,
318 pagination: PaginationParams,
319 no_pagination: bool,
320}
321
322impl RestApiBuilder {
323 fn new(base_url: String) -> Self {
324 Self {
325 base_url,
326 auth_header: None,
327 response_shape: ResponseShape::default(),
328 pagination: PaginationParams::default(),
329 no_pagination: false,
330 }
331 }
332
333 /// Set the Authorization header value (e.g. "Bearer `<token>`").
334 pub fn auth(mut self, auth: impl Into<String>) -> Self {
335 self.auth_header = Some(auth.into());
336 self
337 }
338
339 /// Choose how the API wraps its row array. Defaults to
340 /// `Wrapped { array_key: "data" }` for backwards compat.
341 pub fn response_shape(mut self, shape: ResponseShape) -> Self {
342 self.response_shape = shape;
343 self
344 }
345
346 /// Override the page/limit query parameter names. Default is
347 /// `("_page", "_limit")` (JSON Server convention).
348 pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
349 self.pagination = pagination;
350 self
351 }
352
353 /// Disable pagination entirely — no `_page`/`_limit` query
354 /// params are appended, and a request for page > 1 is short-
355 /// circuited to an empty result. Use this for APIs that don't
356 /// paginate (return the full list every call) or that treat
357 /// unknown query params as strict filters.
358 pub fn no_pagination(mut self) -> Self {
359 self.no_pagination = true;
360 self
361 }
362
363 pub fn build(self) -> RestApi {
364 RestApi {
365 base_url: self.base_url,
366 client: reqwest::Client::new(),
367 auth_header: self.auth_header,
368 response_shape: self.response_shape,
369 pagination: self.pagination,
370 no_pagination: self.no_pagination,
371 }
372 }
373}