1use reqwest::{Method, Url};
2use serde_json::Value;
3
4use crate::ClientError;
5
6#[derive(Clone, Debug)]
12pub struct ApiClient {
13 base_url: Url,
14 authorization_token: Option<String>,
15 http: reqwest::Client,
16}
17
18impl ApiClient {
19 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 #[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 pub async fn get_json(&self, path: &str) -> Result<Value, ClientError> {
45 self.request_json(Method::GET, path, None).await
46 }
47
48 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 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 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 pub async fn delete_json(&self, path: &str) -> Result<Value, ClientError> {
70 self.request_json(Method::DELETE, path, None).await
71 }
72
73 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 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}