Skip to main content

opencode_sdk/http/
mod.rs

1//! HTTP client for OpenCode REST API.
2//!
3//! This module provides the core HTTP client and resource API modules.
4
5// TODO(2): Add encode_path_segment() helper and apply to all path parameter interpolations
6// across sessions, messages, parts, providers, pty, mcp, and project modules (~38 call sites)
7
8use crate::error::{OpencodeError, Result};
9use reqwest::{Client as ReqClient, Method, Response};
10use serde::de::DeserializeOwned;
11use std::path::PathBuf;
12use std::time::Duration;
13
14pub mod config;
15pub mod files;
16pub mod find;
17pub mod mcp;
18pub mod messages;
19pub mod misc;
20pub mod parts;
21pub mod permissions;
22pub mod project;
23pub mod providers;
24pub mod pty;
25pub mod questions;
26pub mod sessions;
27pub mod tools;
28pub mod worktree;
29
30/// Configuration for the HTTP client.
31#[derive(Clone)]
32pub struct HttpConfig {
33    /// Base URL for the OpenCode server.
34    pub base_url: String,
35    /// Optional directory context header.
36    pub directory: Option<String>,
37    /// Request timeout.
38    pub timeout: Duration,
39}
40
41/// HTTP client for OpenCode REST API.
42#[derive(Clone)]
43pub struct HttpClient {
44    inner: ReqClient,
45    cfg: HttpConfig,
46}
47
48impl HttpClient {
49    /// Create a new HTTP client with the given configuration.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if the HTTP client cannot be built.
54    // TODO(3): Add User-Agent header (e.g., "opencode-rs/{VERSION}") for API identification
55    pub fn new(cfg: HttpConfig) -> Result<Self> {
56        let inner = ReqClient::builder()
57            .timeout(cfg.timeout)
58            .build()
59            .map_err(|e| OpencodeError::Network(e.to_string()))?;
60        Ok(Self { inner, cfg })
61    }
62
63    /// Create from base URL, directory, and optional existing client.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the HTTP client cannot be built.
68    pub fn from_parts(
69        base_url: url::Url,
70        directory: Option<PathBuf>,
71        http: Option<ReqClient>,
72    ) -> Result<Self> {
73        let timeout = Duration::from_secs(300);
74        let inner = match http {
75            Some(client) => client,
76            None => ReqClient::builder()
77                .timeout(timeout)
78                .build()
79                .map_err(|e| OpencodeError::Network(e.to_string()))?,
80        };
81
82        Ok(Self {
83            inner,
84            cfg: HttpConfig {
85                base_url: base_url.to_string().trim_end_matches('/').to_string(),
86                directory: directory.map(|p| p.to_string_lossy().to_string()),
87                timeout,
88            },
89        })
90    }
91
92    /// Get the base URL.
93    pub fn base(&self) -> &str {
94        &self.cfg.base_url
95    }
96
97    /// Get the directory context.
98    pub fn directory(&self) -> Option<&str> {
99        self.cfg.directory.as_deref()
100    }
101
102    /// Build request headers including directory context.
103    fn build_request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
104        let url = format!("{}{}", self.cfg.base_url, path);
105        let mut req = self.inner.request(method, &url);
106
107        if let Some(dir) = &self.cfg.directory {
108            req = req.header("x-opencode-directory", dir);
109        }
110
111        req
112    }
113
114    // ==================== Typed HTTP Methods ====================
115
116    /// GET request returning deserialized JSON.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the request fails or response cannot be deserialized.
121    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
122        let resp = self
123            .build_request(Method::GET, path)
124            .send()
125            .await
126            .map_err(|e| OpencodeError::Network(e.to_string()))?;
127        Self::map_json_response(resp).await
128    }
129
130    /// DELETE request returning deserialized JSON.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the request fails or response cannot be deserialized.
135    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
136        let resp = self
137            .build_request(Method::DELETE, path)
138            .send()
139            .await
140            .map_err(|e| OpencodeError::Network(e.to_string()))?;
141        Self::map_json_response(resp).await
142    }
143
144    /// DELETE request expecting no response body.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the request fails.
149    pub async fn delete_empty(&self, path: &str) -> Result<()> {
150        let resp = self
151            .build_request(Method::DELETE, path)
152            .send()
153            .await
154            .map_err(|e| OpencodeError::Network(e.to_string()))?;
155        Self::check_status(resp).await
156    }
157
158    /// POST request with JSON body returning deserialized JSON.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the request fails or response cannot be deserialized.
163    pub async fn post<TReq: serde::Serialize, TRes: DeserializeOwned>(
164        &self,
165        path: &str,
166        body: &TReq,
167    ) -> Result<TRes> {
168        let resp = self
169            .build_request(Method::POST, path)
170            .json(body)
171            .send()
172            .await
173            .map_err(|e| OpencodeError::Network(e.to_string()))?;
174        Self::map_json_response(resp).await
175    }
176
177    /// POST request expecting no response body.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the request fails.
182    pub async fn post_empty<TReq: serde::Serialize>(&self, path: &str, body: &TReq) -> Result<()> {
183        let resp = self
184            .build_request(Method::POST, path)
185            .json(body)
186            .send()
187            .await
188            .map_err(|e| OpencodeError::Network(e.to_string()))?;
189        Self::check_status(resp).await
190    }
191
192    /// PATCH request with JSON body returning deserialized JSON.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the request fails or response cannot be deserialized.
197    pub async fn patch<TReq: serde::Serialize, TRes: DeserializeOwned>(
198        &self,
199        path: &str,
200        body: &TReq,
201    ) -> Result<TRes> {
202        let resp = self
203            .build_request(Method::PATCH, path)
204            .json(body)
205            .send()
206            .await
207            .map_err(|e| OpencodeError::Network(e.to_string()))?;
208        Self::map_json_response(resp).await
209    }
210
211    /// PUT request with JSON body returning deserialized JSON.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if the request fails or response cannot be deserialized.
216    pub async fn put<TReq: serde::Serialize, TRes: DeserializeOwned>(
217        &self,
218        path: &str,
219        body: &TReq,
220    ) -> Result<TRes> {
221        let resp = self
222            .build_request(Method::PUT, path)
223            .json(body)
224            .send()
225            .await
226            .map_err(|e| OpencodeError::Network(e.to_string()))?;
227        Self::map_json_response(resp).await
228    }
229
230    // ==================== Legacy Methods (for backwards compatibility) ====================
231
232    /// Make a JSON request and deserialize the response.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the request fails or the response cannot be deserialized.
237    pub async fn request_json<T: DeserializeOwned>(
238        &self,
239        method: Method,
240        path: &str,
241        body: Option<serde_json::Value>,
242    ) -> Result<T> {
243        let mut req = self.build_request(method, path);
244
245        if let Some(b) = body {
246            req = req.json(&b);
247        }
248
249        let resp = req
250            .send()
251            .await
252            .map_err(|e| OpencodeError::Network(e.to_string()))?;
253        Self::map_json_response(resp).await
254    }
255
256    /// Make a request that expects no response body.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the request fails or returns a non-success status.
261    pub async fn request_empty(
262        &self,
263        method: Method,
264        path: &str,
265        body: Option<serde_json::Value>,
266    ) -> Result<()> {
267        let mut req = self.build_request(method, path);
268
269        if let Some(b) = body {
270            req = req.json(&b);
271        }
272
273        let resp = req
274            .send()
275            .await
276            .map_err(|e| OpencodeError::Network(e.to_string()))?;
277        Self::check_status(resp).await
278    }
279
280    // ==================== Response Handling ====================
281
282    /// Map response to JSON, handling errors with NamedError parsing.
283    async fn map_json_response<T: DeserializeOwned>(resp: Response) -> Result<T> {
284        let status = resp.status();
285        let bytes = resp
286            .bytes()
287            .await
288            .map_err(|e| OpencodeError::Network(e.to_string()))?;
289
290        if !status.is_success() {
291            let body_text = String::from_utf8_lossy(&bytes);
292            return Err(OpencodeError::http(status.as_u16(), &body_text));
293        }
294
295        serde_json::from_slice(&bytes).map_err(OpencodeError::from)
296    }
297
298    /// Check response status, returning error with NamedError parsing on failure.
299    async fn check_status(resp: Response) -> Result<()> {
300        let status = resp.status();
301
302        if !status.is_success() {
303            let body = resp.text().await.unwrap_or_default();
304            return Err(OpencodeError::http(status.as_u16(), &body));
305        }
306
307        Ok(())
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use wiremock::matchers::{header, method, path};
315    use wiremock::{Mock, MockServer, ResponseTemplate};
316
317    #[tokio::test]
318    async fn test_get_success() {
319        let mock_server = MockServer::start().await;
320
321        Mock::given(method("GET"))
322            .and(path("/test"))
323            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
324                "id": "test123",
325                "value": 42
326            })))
327            .mount(&mock_server)
328            .await;
329
330        let client = HttpClient::new(HttpConfig {
331            base_url: mock_server.uri(),
332            directory: None,
333            timeout: Duration::from_secs(30),
334        })
335        .unwrap();
336
337        let result: serde_json::Value = client.get("/test").await.unwrap();
338        assert_eq!(result["id"], "test123");
339        assert_eq!(result["value"], 42);
340    }
341
342    #[tokio::test]
343    async fn test_post_with_body() {
344        let mock_server = MockServer::start().await;
345
346        Mock::given(method("POST"))
347            .and(path("/create"))
348            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
349                "id": "new123"
350            })))
351            .mount(&mock_server)
352            .await;
353
354        let client = HttpClient::new(HttpConfig {
355            base_url: mock_server.uri(),
356            directory: None,
357            timeout: Duration::from_secs(30),
358        })
359        .unwrap();
360
361        let body = serde_json::json!({"name": "test"});
362        let result: serde_json::Value = client.post("/create", &body).await.unwrap();
363        assert_eq!(result["id"], "new123");
364    }
365
366    #[tokio::test]
367    async fn test_request_with_directory_header() {
368        let mock_server = MockServer::start().await;
369
370        Mock::given(method("GET"))
371            .and(path("/test"))
372            .and(header("x-opencode-directory", "/my/project"))
373            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
374            .mount(&mock_server)
375            .await;
376
377        let client = HttpClient::new(HttpConfig {
378            base_url: mock_server.uri(),
379            directory: Some("/my/project".to_string()),
380            timeout: Duration::from_secs(30),
381        })
382        .unwrap();
383
384        let result: serde_json::Value = client.get("/test").await.unwrap();
385        assert_eq!(result, serde_json::json!({}));
386    }
387
388    #[tokio::test]
389    async fn test_error_with_named_error_body() {
390        let mock_server = MockServer::start().await;
391
392        Mock::given(method("GET"))
393            .and(path("/notfound"))
394            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
395                "name": "NotFound",
396                "message": "Session not found",
397                "data": {"id": "missing123"}
398            })))
399            .mount(&mock_server)
400            .await;
401
402        let client = HttpClient::new(HttpConfig {
403            base_url: mock_server.uri(),
404            directory: None,
405            timeout: Duration::from_secs(30),
406        })
407        .unwrap();
408
409        let result: Result<serde_json::Value> = client.get("/notfound").await;
410
411        match result {
412            Err(OpencodeError::Http {
413                status,
414                name,
415                message,
416                data,
417            }) => {
418                assert_eq!(status, 404);
419                assert_eq!(name, Some("NotFound".to_string()));
420                assert_eq!(message, "Session not found");
421                assert!(data.is_some());
422            }
423            _ => panic!("Expected Http error with NamedError fields"),
424        }
425    }
426
427    #[tokio::test]
428    async fn test_error_with_plain_text_body() {
429        let mock_server = MockServer::start().await;
430
431        Mock::given(method("GET"))
432            .and(path("/error"))
433            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
434            .mount(&mock_server)
435            .await;
436
437        let client = HttpClient::new(HttpConfig {
438            base_url: mock_server.uri(),
439            directory: None,
440            timeout: Duration::from_secs(30),
441        })
442        .unwrap();
443
444        let result: Result<serde_json::Value> = client.get("/error").await;
445
446        match result {
447            Err(err) => {
448                assert!(err.is_server_error());
449            }
450            _ => panic!("Expected Http error"),
451        }
452    }
453
454    #[tokio::test]
455    async fn test_delete_empty() {
456        let mock_server = MockServer::start().await;
457
458        Mock::given(method("DELETE"))
459            .and(path("/item/123"))
460            .respond_with(ResponseTemplate::new(204))
461            .mount(&mock_server)
462            .await;
463
464        let client = HttpClient::new(HttpConfig {
465            base_url: mock_server.uri(),
466            directory: None,
467            timeout: Duration::from_secs(30),
468        })
469        .unwrap();
470
471        client.delete_empty("/item/123").await.unwrap();
472    }
473
474    #[tokio::test]
475    async fn test_validation_error() {
476        let mock_server = MockServer::start().await;
477
478        Mock::given(method("POST"))
479            .and(path("/validate"))
480            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
481                "name": "ValidationError",
482                "message": "Invalid input",
483                "data": {"field": "name", "reason": "required"}
484            })))
485            .mount(&mock_server)
486            .await;
487
488        let client = HttpClient::new(HttpConfig {
489            base_url: mock_server.uri(),
490            directory: None,
491            timeout: Duration::from_secs(30),
492        })
493        .unwrap();
494
495        let result: Result<serde_json::Value> =
496            client.post("/validate", &serde_json::json!({})).await;
497
498        match result {
499            Err(err) => {
500                assert!(err.is_validation_error());
501                assert_eq!(err.error_name(), Some("ValidationError"));
502            }
503            _ => panic!("Expected validation error"),
504        }
505    }
506}