1use 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#[derive(Clone)]
32pub struct HttpConfig {
33 pub base_url: String,
35 pub directory: Option<String>,
37 pub timeout: Duration,
39}
40
41#[derive(Clone)]
43pub struct HttpClient {
44 inner: ReqClient,
45 cfg: HttpConfig,
46}
47
48impl HttpClient {
49 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 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 pub fn base(&self) -> &str {
94 &self.cfg.base_url
95 }
96
97 pub fn directory(&self) -> Option<&str> {
99 self.cfg.directory.as_deref()
100 }
101
102 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 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 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 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 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 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 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 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 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 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 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 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}