Skip to main content

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}
91
92impl RestApi {
93    /// Create a new REST API pointing at `base_url`. Uses the legacy
94    /// default response shape (`{ "data": [...] }`). For other shapes
95    /// (bare array, wrapped-by-table-name) use [`RestApi::builder`].
96    pub fn new(base_url: impl Into<String>) -> Self {
97        RestApi::builder(base_url).build()
98    }
99
100    /// Start configuring a [`RestApi`] via the builder.
101    pub fn builder(base_url: impl Into<String>) -> RestApiBuilder {
102        RestApiBuilder::new(base_url.into())
103    }
104
105    /// Set the Authorization header value (e.g. "Bearer `<token>`").
106    /// Provided for backwards compatibility — prefer
107    /// `RestApi::builder(...).auth(...)`.
108    pub fn with_auth(mut self, auth: impl Into<String>) -> Self {
109        self.auth_header = Some(auth.into());
110        self
111    }
112
113    /// Build the endpoint URL for a given table name. No query string.
114    fn endpoint_url(&self, table_name: &str) -> String {
115        format!("{}/{}", self.base_url, table_name)
116    }
117
118    /// Build the combined query-string from pagination + conditions.
119    /// Conditions that don't peel cleanly into eq pairs are skipped (we
120    /// could fail loudly here, but silently ignoring matches the v1
121    /// "best effort" stance — the caller still gets correct data, just
122    /// with less efficient filtering).
123    fn build_query_string<'a>(
124        &self,
125        pagination: Option<&Pagination>,
126        conditions: impl IntoIterator<Item = &'a Expression<Value>>,
127    ) -> String {
128        let mut params: Vec<(String, String)> = Vec::new();
129
130        // Pagination first — matches the order users see in the URL bar.
131        if let Some(p) = pagination {
132            let page_value = if self.pagination.skip_based {
133                p.skip().to_string()
134            } else {
135                p.get_page().to_string()
136            };
137            params.push((self.pagination.page.clone(), page_value));
138            params.push((self.pagination.limit.clone(), p.limit().to_string()));
139        }
140
141        // Conditions: each `eq` becomes `?field=value`. Multiple
142        // conditions AND together (JSON Server semantics).
143        for cond in conditions {
144            if let Some((field, value)) = crate::condition_to_query_param(cond) {
145                params.push((field, value));
146            }
147        }
148
149        if params.is_empty() {
150            return String::new();
151        }
152        let mut s = String::from("?");
153        for (i, (k, v)) in params.iter().enumerate() {
154            if i > 0 {
155                s.push('&');
156            }
157            // Minimal URL encoding — we encode `&` and `=` and spaces
158            // because those break the query format. Anything else
159            // passes through; the JSON Server convention is permissive.
160            s.push_str(&urlencode(k));
161            s.push('=');
162            s.push_str(&urlencode(v));
163        }
164        s
165    }
166
167    /// Fetch data from the API endpoint and return parsed records.
168    ///
169    /// `id_field` selects which JSON field is treated as the record ID;
170    /// if `None`, row indices are used. `pagination` and `conditions`
171    /// are pushed into the URL query string — eq-conditions become
172    /// `?field=value`. Conditions that can't be peeled into a simple
173    /// eq are silently skipped (caller-side filtering still applies if
174    /// needed).
175    pub(crate) async fn fetch_records<'a>(
176        &self,
177        table_name: &str,
178        id_field: Option<&str>,
179        pagination: Option<&Pagination>,
180        conditions: impl IntoIterator<Item = &'a Expression<Value>>,
181    ) -> Result<IndexMap<String, Record<serde_json::Value>>> {
182        let url = format!(
183            "{}{}",
184            self.endpoint_url(table_name),
185            self.build_query_string(pagination, conditions)
186        );
187
188        let mut request = self.client.get(&url);
189        if let Some(ref auth) = self.auth_header {
190            request = request.header("Authorization", auth);
191        }
192
193        let response = request
194            .send()
195            .await
196            .map_err(|e| error!("API request failed", url = url, detail = e))?;
197
198        if !response.status().is_success() {
199            return Err(error!(
200                "API returned error status",
201                url = url,
202                status = response.status().as_u16()
203            ));
204        }
205
206        let body: serde_json::Value = response
207            .json()
208            .await
209            .map_err(|e| error!("Failed to parse API response as JSON", detail = e))?;
210
211        let data = self.extract_array(&body, table_name)?;
212
213        let mut records = IndexMap::new();
214        for (row_idx, item) in data.iter().enumerate() {
215            let obj = item
216                .as_object()
217                .ok_or_else(|| error!("API data item is not an object", index = row_idx))?;
218
219            // Extract ID from the configured id_field, or use row index
220            let id = id_field
221                .and_then(|field| obj.get(field))
222                .and_then(|v| match v {
223                    serde_json::Value::String(s) => Some(s.clone()),
224                    serde_json::Value::Number(n) => Some(n.to_string()),
225                    _ => None,
226                })
227                .unwrap_or_else(|| row_idx.to_string());
228
229            let record: Record<serde_json::Value> =
230                obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
231
232            records.insert(id, record);
233        }
234
235        Ok(records)
236    }
237}
238
239fn urlencode(s: &str) -> String {
240    urlencoding::encode(s).into_owned()
241}
242
243impl RestApi {
244    /// Pull the row array out of the response body, according to the
245    /// configured `ResponseShape`.
246    fn extract_array<'a>(
247        &self,
248        body: &'a serde_json::Value,
249        table_name: &str,
250    ) -> Result<&'a Vec<serde_json::Value>> {
251        match &self.response_shape {
252            ResponseShape::BareArray => body.as_array().ok_or_else(|| {
253                error!("Expected response body to be a JSON array (BareArray shape)")
254            }),
255            ResponseShape::Wrapped { array_key } => body[array_key].as_array().ok_or_else(|| {
256                error!(
257                    "Response missing array under wrapper key",
258                    array_key = array_key
259                )
260            }),
261            ResponseShape::WrappedByTableName => body[table_name].as_array().ok_or_else(|| {
262                error!(
263                    "Response missing array under table-name key",
264                    table_name = table_name
265                )
266            }),
267        }
268    }
269}
270
271/// Builder for [`RestApi`]. Lets callers pick a [`ResponseShape`] and
272/// override the pagination parameter names.
273///
274/// ```no_run
275/// use vantage_api_client::{RestApi, ResponseShape, PaginationParams};
276///
277/// // JSONPlaceholder: bare arrays, JSON-Server pagination conventions.
278/// let api = RestApi::builder("https://jsonplaceholder.typicode.com")
279///     .response_shape(ResponseShape::BareArray)
280///     .build();
281///
282/// // DummyJSON: wrapped-by-table-name, skip-based pagination.
283/// let api = RestApi::builder("https://dummyjson.com")
284///     .response_shape(ResponseShape::WrappedByTableName)
285///     .pagination_params(PaginationParams::skip_limit("skip", "limit"))
286///     .build();
287/// ```
288#[derive(Clone, Debug)]
289pub struct RestApiBuilder {
290    base_url: String,
291    auth_header: Option<String>,
292    response_shape: ResponseShape,
293    pagination: PaginationParams,
294}
295
296impl RestApiBuilder {
297    fn new(base_url: String) -> Self {
298        Self {
299            base_url,
300            auth_header: None,
301            response_shape: ResponseShape::default(),
302            pagination: PaginationParams::default(),
303        }
304    }
305
306    /// Set the Authorization header value (e.g. "Bearer `<token>`").
307    pub fn auth(mut self, auth: impl Into<String>) -> Self {
308        self.auth_header = Some(auth.into());
309        self
310    }
311
312    /// Choose how the API wraps its row array. Defaults to
313    /// `Wrapped { array_key: "data" }` for backwards compat.
314    pub fn response_shape(mut self, shape: ResponseShape) -> Self {
315        self.response_shape = shape;
316        self
317    }
318
319    /// Override the page/limit query parameter names. Default is
320    /// `("_page", "_limit")` (JSON Server convention).
321    pub fn pagination_params(mut self, pagination: PaginationParams) -> Self {
322        self.pagination = pagination;
323        self
324    }
325
326    pub fn build(self) -> RestApi {
327        RestApi {
328            base_url: self.base_url,
329            client: reqwest::Client::new(),
330            auth_header: self.auth_header,
331            response_shape: self.response_shape,
332            pagination: self.pagination,
333        }
334    }
335}