skill_web/api/
client.rs

1//! HTTP API client for the skill-http backend
2
3use gloo_net::http::Request;
4use serde::{de::DeserializeOwned, Serialize};
5use std::rc::Rc;
6
7use super::error::{ApiError, ApiErrorResponse, ApiResult};
8
9/// API client for making requests to the backend
10#[derive(Clone)]
11pub struct ApiClient {
12    base_url: Rc<str>,
13}
14
15impl Default for ApiClient {
16    fn default() -> Self {
17        Self::local()
18    }
19}
20
21impl ApiClient {
22    /// Create a new API client with the given base URL
23    pub fn new(base_url: impl Into<String>) -> Self {
24        Self {
25            base_url: base_url.into().into(),
26        }
27    }
28
29    /// Create a client pointing to the default local server (proxied through Trunk)
30    pub fn local() -> Self {
31        Self::new("/api")
32    }
33
34    /// Create a client pointing to a specific host
35    pub fn with_host(host: &str, port: u16) -> Self {
36        Self::new(format!("http://{}:{}/api", host, port))
37    }
38
39    /// Get the base URL
40    pub fn base_url(&self) -> &str {
41        &self.base_url
42    }
43
44    /// Build a full URL from a path
45    fn url(&self, path: &str) -> String {
46        if path.starts_with('/') {
47            format!("{}{}", self.base_url, path)
48        } else {
49            format!("{}/{}", self.base_url, path)
50        }
51    }
52
53    /// Handle response and parse JSON or error
54    async fn handle_response<T: DeserializeOwned>(
55        response: gloo_net::http::Response,
56    ) -> ApiResult<T> {
57        let status = response.status();
58
59        if response.ok() {
60            response
61                .json()
62                .await
63                .map_err(|e| ApiError::Deserialization(e.to_string()))
64        } else {
65            // Try to parse error response
66            let error = match response.json::<ApiErrorResponse>().await {
67                Ok(err_resp) => err_resp.into(),
68                Err(_) => ApiError::from_status(status, response.status_text()),
69            };
70            Err(error)
71        }
72    }
73
74    /// Handle response that returns no body (204 No Content)
75    async fn handle_empty_response(response: gloo_net::http::Response) -> ApiResult<()> {
76        let status = response.status();
77
78        if response.ok() {
79            Ok(())
80        } else {
81            let error = match response.json::<ApiErrorResponse>().await {
82                Ok(err_resp) => err_resp.into(),
83                Err(_) => ApiError::from_status(status, response.status_text()),
84            };
85            Err(error)
86        }
87    }
88
89    /// Make a GET request
90    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> ApiResult<T> {
91        let url = self.url(path);
92
93        let response = Request::get(&url)
94            .send()
95            .await
96            .map_err(|e| ApiError::Network(e.to_string()))?;
97
98        Self::handle_response(response).await
99    }
100
101    /// Make a GET request with query parameters
102    pub async fn get_with_query<T: DeserializeOwned, Q: Serialize>(
103        &self,
104        path: &str,
105        query: &Q,
106    ) -> ApiResult<T> {
107        let query_string = serde_urlencoded::to_string(query)
108            .map_err(|e| ApiError::Serialization(e.to_string()))?;
109
110        let url = if query_string.is_empty() {
111            self.url(path)
112        } else {
113            format!("{}?{}", self.url(path), query_string)
114        };
115
116        let response = Request::get(&url)
117            .send()
118            .await
119            .map_err(|e| ApiError::Network(e.to_string()))?;
120
121        Self::handle_response(response).await
122    }
123
124    /// Make a POST request with JSON body
125    pub async fn post<T: DeserializeOwned, B: Serialize>(
126        &self,
127        path: &str,
128        body: &B,
129    ) -> ApiResult<T> {
130        let url = self.url(path);
131
132        let response = Request::post(&url)
133            .header("Content-Type", "application/json")
134            .json(body)
135            .map_err(|e| ApiError::Serialization(e.to_string()))?
136            .send()
137            .await
138            .map_err(|e| ApiError::Network(e.to_string()))?;
139
140        Self::handle_response(response).await
141    }
142
143    /// Make a POST request without expecting a response body
144    pub async fn post_no_response<B: Serialize>(&self, path: &str, body: &B) -> ApiResult<()> {
145        let url = self.url(path);
146
147        let response = Request::post(&url)
148            .header("Content-Type", "application/json")
149            .json(body)
150            .map_err(|e| ApiError::Serialization(e.to_string()))?
151            .send()
152            .await
153            .map_err(|e| ApiError::Network(e.to_string()))?;
154
155        Self::handle_empty_response(response).await
156    }
157
158    /// Make a PUT request with JSON body
159    pub async fn put<T: DeserializeOwned, B: Serialize>(
160        &self,
161        path: &str,
162        body: &B,
163    ) -> ApiResult<T> {
164        let url = self.url(path);
165
166        let response = Request::put(&url)
167            .header("Content-Type", "application/json")
168            .json(body)
169            .map_err(|e| ApiError::Serialization(e.to_string()))?
170            .send()
171            .await
172            .map_err(|e| ApiError::Network(e.to_string()))?;
173
174        Self::handle_response(response).await
175    }
176
177    /// Make a PATCH request with JSON body
178    pub async fn patch<T: DeserializeOwned, B: Serialize>(
179        &self,
180        path: &str,
181        body: &B,
182    ) -> ApiResult<T> {
183        let url = self.url(path);
184
185        let response = Request::patch(&url)
186            .header("Content-Type", "application/json")
187            .json(body)
188            .map_err(|e| ApiError::Serialization(e.to_string()))?
189            .send()
190            .await
191            .map_err(|e| ApiError::Network(e.to_string()))?;
192
193        Self::handle_response(response).await
194    }
195
196    /// Make a DELETE request
197    pub async fn delete(&self, path: &str) -> ApiResult<()> {
198        let url = self.url(path);
199
200        let response = Request::delete(&url)
201            .send()
202            .await
203            .map_err(|e| ApiError::Network(e.to_string()))?;
204
205        Self::handle_empty_response(response).await
206    }
207
208    /// Make a DELETE request expecting a response body
209    pub async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> ApiResult<T> {
210        let url = self.url(path);
211
212        let response = Request::delete(&url)
213            .send()
214            .await
215            .map_err(|e| ApiError::Network(e.to_string()))?;
216
217        Self::handle_response(response).await
218    }
219}