Skip to main content

iri_client/
client.rs

1use reqwest::{Method, Url};
2use serde_json::Value;
3
4use crate::ClientError;
5
6/// Generic async JSON REST client.
7///
8/// This client is transport-focused and does not require an `OpenAPI` operation id.
9/// For operation-id based calls generated from `openapi/openapi.json`, use
10/// [`crate::IriClient`].
11#[derive(Clone, Debug)]
12pub struct ApiClient {
13    base_url: Url,
14    authorization_token: Option<String>,
15    http: reqwest::Client,
16}
17
18impl ApiClient {
19    /// Creates a new client with the given base URL.
20    ///
21    /// The URL is normalized to include a trailing slash, so relative endpoint
22    /// paths join correctly.
23    pub fn new(base_url: impl AsRef<str>) -> Result<Self, ClientError> {
24        let parsed = Url::parse(base_url.as_ref())
25            .map_err(|_| ClientError::InvalidBaseUrl(base_url.as_ref().to_owned()))?;
26
27        Ok(Self {
28            base_url: ensure_trailing_slash(parsed),
29            authorization_token: None,
30            http: reqwest::Client::new(),
31        })
32    }
33
34    /// Returns a new client with a raw access token attached to all requests.
35    ///
36    /// This sets `Authorization: <token>` (without `Bearer ` prefix).
37    #[must_use]
38    pub fn with_authorization_token(mut self, token: impl Into<String>) -> Self {
39        self.authorization_token = Some(token.into());
40        self
41    }
42
43    /// Sends a `GET` request and parses the response as JSON.
44    pub async fn get_json(&self, path: &str) -> Result<Value, ClientError> {
45        self.request_json(Method::GET, path, None).await
46    }
47
48    /// Sends a `GET` request with query parameters and parses the response as JSON.
49    pub async fn get_json_with_query(
50        &self,
51        path: &str,
52        query: &[(&str, &str)],
53    ) -> Result<Value, ClientError> {
54        self.request_json_with_query(Method::GET, path, query, None)
55            .await
56    }
57
58    /// Sends a `POST` request with a JSON body and parses the response as JSON.
59    pub async fn post_json(&self, path: &str, body: Value) -> Result<Value, ClientError> {
60        self.request_json(Method::POST, path, Some(body)).await
61    }
62
63    /// Sends a `PUT` request with a JSON body and parses the response as JSON.
64    pub async fn put_json(&self, path: &str, body: Value) -> Result<Value, ClientError> {
65        self.request_json(Method::PUT, path, Some(body)).await
66    }
67
68    /// Sends a `DELETE` request and parses the response as JSON.
69    pub async fn delete_json(&self, path: &str) -> Result<Value, ClientError> {
70        self.request_json(Method::DELETE, path, None).await
71    }
72
73    /// Sends a request and parses the response as JSON.
74    ///
75    /// Use [`Self::request_json_with_query`] when query parameters are needed.
76    pub async fn request_json(
77        &self,
78        method: Method,
79        path: &str,
80        body: Option<Value>,
81    ) -> Result<Value, ClientError> {
82        self.request_json_with_query(method, path, &[], body).await
83    }
84
85    /// Sends a request with query parameters and parses the response as JSON.
86    ///
87    /// Returns [`Value::Null`] for successful responses with an empty body.
88    pub async fn request_json_with_query(
89        &self,
90        method: Method,
91        path: &str,
92        query: &[(&str, &str)],
93        body: Option<Value>,
94    ) -> Result<Value, ClientError> {
95        let url = self.build_url(path)?;
96        let mut request = self
97            .http
98            .request(method, url)
99            .header(reqwest::header::ACCEPT, "application/json");
100
101        if !query.is_empty() {
102            request = request.query(query);
103        }
104
105        if let Some(token) = &self.authorization_token {
106            request = request.bearer_auth(token);
107        }
108
109        if let Some(json_body) = body {
110            request = request.json(&json_body);
111        }
112
113        let response = request.send().await?;
114        let status = response.status();
115        let payload = response.text().await?;
116
117        if !status.is_success() {
118            return Err(ClientError::HttpStatus {
119                status,
120                body: payload,
121            });
122        }
123
124        if payload.trim().is_empty() {
125            Ok(Value::Null)
126        } else {
127            Ok(serde_json::from_str(&payload)?)
128        }
129    }
130
131    fn build_url(&self, path: &str) -> Result<Url, ClientError> {
132        let relative = path.trim_start_matches('/');
133        self.base_url
134            .join(relative)
135            .map_err(|_| ClientError::InvalidPath(path.to_owned()))
136    }
137}
138
139fn ensure_trailing_slash(mut url: Url) -> Url {
140    if !url.path().ends_with('/') {
141        let mut path = url.path().to_owned();
142        path.push('/');
143        url.set_path(&path);
144    }
145    url
146}
147
148#[cfg(test)]
149mod tests {
150    use super::ApiClient;
151
152    #[test]
153    fn joins_paths_from_base_with_nested_prefix() {
154        let client = ApiClient::new("https://example.com/api/v1").expect("valid url");
155        let resolved = client.build_url("items").expect("valid path");
156        assert_eq!(resolved.as_str(), "https://example.com/api/v1/items");
157    }
158}