Skip to main content

opencode_codes/
http.rs

1//! Low-level REST transport for the opencode server.
2//!
3//! This module owns three concerns shared by every hand-wrapped endpoint:
4//!
5//! - **Base-URL handling** — a normalized origin (trailing slashes stripped)
6//!   against which typed path builders produce absolute request URLs.
7//! - **HTTP Basic auth** — opencode requires Basic auth only when the server was
8//!   started with a password (username defaults to `"opencode"`). Credentials are
9//!   supplied explicitly; the `OPENCODE_SERVER_PASSWORD` environment variable is
10//!   read only through [`BasicAuth::from_env`], never silently on a request path.
11//! - **A typed request helper** — [`HttpTransport::request_json`] /
12//!   [`HttpTransport::request_unit`] send a JSON body (if any), apply auth and
13//!   the optional per-request timeout, and map any non-2xx status to
14//!   [`Error::Http`] carrying the server's response body.
15//!
16//! Path parameters (`{sessionID}`, `{permissionID}`) are percent-encoded against
17//! the RFC 3986 unreserved set before being placed into the path.
18
19use std::time::Duration;
20
21use reqwest::{Client, Method, RequestBuilder};
22use serde::de::DeserializeOwned;
23use serde_json::Value;
24
25use crate::error::{Error, Result};
26
27/// Default HTTP Basic auth username opencode expects when a server password is
28/// configured.
29pub const DEFAULT_USERNAME: &str = "opencode";
30
31/// Environment variable opencode reads for its server password. Used only by
32/// [`BasicAuth::from_env`].
33pub const SERVER_PASSWORD_ENV: &str = "OPENCODE_SERVER_PASSWORD";
34
35/// HTTP Basic credentials for the opencode server.
36///
37/// opencode uses Basic auth only when it was launched with a password; against a
38/// no-auth server the transport is constructed without any [`BasicAuth`].
39#[derive(Clone, Debug)]
40pub struct BasicAuth {
41    /// Basic auth username. Defaults to [`DEFAULT_USERNAME`] in
42    /// [`BasicAuth::from_env`].
43    pub username: String,
44    /// Basic auth password (the value opencode was started with).
45    pub password: String,
46}
47
48impl BasicAuth {
49    /// Construct credentials from an explicit username and password.
50    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
51        Self {
52            username: username.into(),
53            password: password.into(),
54        }
55    }
56
57    /// Build credentials from `OPENCODE_SERVER_PASSWORD`, using [`DEFAULT_USERNAME`]
58    /// as the username.
59    ///
60    /// Returns `None` when the variable is unset or empty. This is the *only*
61    /// place the crate consults the environment for auth; request paths never do.
62    pub fn from_env() -> Option<Self> {
63        std::env::var(SERVER_PASSWORD_ENV)
64            .ok()
65            .filter(|p| !p.is_empty())
66            .map(|password| Self {
67                username: DEFAULT_USERNAME.to_string(),
68                password,
69            })
70    }
71}
72
73/// Directory / workspace scoping for the session endpoints.
74///
75/// `opencode serve` can manage several project directories at once, and every
76/// session endpoint (create, prompt, message, abort, permissions) plus the
77/// `GET /event` stream accepts optional `directory` and `workspace` query
78/// parameters that pin the call to one of them. A [`Scope`] carries those
79/// values; an empty scope (the default) leaves the server on its own working
80/// directory.
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct Scope {
83    /// The `directory` query parameter — an absolute project directory path.
84    pub directory: Option<String>,
85    /// The `workspace` query parameter — a workspace identifier.
86    pub workspace: Option<String>,
87}
88
89impl Scope {
90    /// Whether neither `directory` nor `workspace` is set (no scoping applied).
91    pub fn is_empty(&self) -> bool {
92        self.directory.is_none() && self.workspace.is_none()
93    }
94}
95
96/// Append `key=value` to `url`, using `*sep` (`?` or `&`) and advancing it.
97fn push_query(url: &mut String, sep: &mut char, key: &str, value: &str) {
98    url.push(*sep);
99    url.push_str(key);
100    url.push('=');
101    url.push_str(&encode_segment(value));
102    *sep = '&';
103}
104
105/// Percent-encode a single path segment against the RFC 3986 unreserved set
106/// (`ALPHA / DIGIT / "-" / "." / "_" / "~"`); every other byte becomes `%XX`.
107fn encode_segment(segment: &str) -> String {
108    const UPPER_HEX: &[u8; 16] = b"0123456789ABCDEF";
109    let mut out = String::with_capacity(segment.len());
110    for &byte in segment.as_bytes() {
111        match byte {
112            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
113                out.push(byte as char);
114            }
115            _ => {
116                out.push('%');
117                out.push(UPPER_HEX[(byte >> 4) as usize] as char);
118                out.push(UPPER_HEX[(byte & 0xf) as usize] as char);
119            }
120        }
121    }
122    out
123}
124
125/// Low-level JSON-over-HTTP transport bound to one opencode base URL.
126///
127/// Cloning is cheap: the inner [`reqwest::Client`] is reference-counted.
128#[derive(Clone, Debug)]
129pub struct HttpTransport {
130    client: Client,
131    base_url: String,
132    timeout: Option<Duration>,
133    auth: Option<BasicAuth>,
134    scope: Scope,
135}
136
137impl HttpTransport {
138    /// Bind a transport to a base URL.
139    ///
140    /// Trailing slashes on `base_url` are stripped so path builders can append
141    /// `/`-prefixed segments without producing `//`. The URL itself is validated
142    /// lazily by reqwest at send time; a malformed base surfaces as
143    /// [`Error::Transport`].
144    pub fn new(
145        client: Client,
146        base_url: impl Into<String>,
147        timeout: Option<Duration>,
148        auth: Option<BasicAuth>,
149        scope: Scope,
150    ) -> Self {
151        let base_url = base_url.into().trim_end_matches('/').to_string();
152        Self {
153            client,
154            base_url,
155            timeout,
156            auth,
157            scope,
158        }
159    }
160
161    /// The normalized base URL (no trailing slash).
162    pub fn base_url(&self) -> &str {
163        &self.base_url
164    }
165
166    /// Credentials attached to every request, if any.
167    pub fn auth(&self) -> Option<&BasicAuth> {
168        self.auth.as_ref()
169    }
170
171    /// The `directory` / `workspace` scoping applied to every session URL and
172    /// the `GET /event` stream this transport builds.
173    pub fn scope(&self) -> &Scope {
174        &self.scope
175    }
176
177    /// Append the configured [`Scope`] query parameters to `url`, given the
178    /// current separator (`?` when `url` carries no query yet, else `&`).
179    fn append_scope(&self, url: &mut String, sep: &mut char) {
180        if let Some(directory) = &self.scope.directory {
181            push_query(url, sep, "directory", directory);
182        }
183        if let Some(workspace) = &self.scope.workspace {
184            push_query(url, sep, "workspace", workspace);
185        }
186    }
187
188    /// Join an arbitrary path (leading slash optional) onto the base URL. Backing
189    /// helper for the client's raw escape hatch; the path is used verbatim (not
190    /// segment-encoded).
191    pub fn join(&self, path: &str) -> String {
192        format!("{}/{}", self.base_url, path.trim_start_matches('/'))
193    }
194
195    /// URL for `POST /session` (session creation).
196    pub fn session_create_url(&self) -> String {
197        let mut url = format!("{}/session", self.base_url);
198        let mut sep = '?';
199        self.append_scope(&mut url, &mut sep);
200        url
201    }
202
203    /// URL for `POST /session/{sessionID}/prompt_async`.
204    pub fn prompt_async_url(&self, session_id: &str) -> String {
205        let mut url = format!(
206            "{}/session/{}/prompt_async",
207            self.base_url,
208            encode_segment(session_id)
209        );
210        let mut sep = '?';
211        self.append_scope(&mut url, &mut sep);
212        url
213    }
214
215    /// URL for `GET /session/{sessionID}/message`, optionally paginated with the
216    /// `limit` and `before` query parameters used for reconciliation polling.
217    pub fn messages_url(
218        &self,
219        session_id: &str,
220        limit: Option<u64>,
221        before: Option<&str>,
222    ) -> String {
223        let mut url = format!(
224            "{}/session/{}/message",
225            self.base_url,
226            encode_segment(session_id)
227        );
228        let mut sep = '?';
229        if let Some(limit) = limit {
230            push_query(&mut url, &mut sep, "limit", &limit.to_string());
231        }
232        if let Some(before) = before {
233            push_query(&mut url, &mut sep, "before", before);
234        }
235        self.append_scope(&mut url, &mut sep);
236        url
237    }
238
239    /// URL for `POST /session/{sessionID}/abort`.
240    pub fn abort_url(&self, session_id: &str) -> String {
241        let mut url = format!(
242            "{}/session/{}/abort",
243            self.base_url,
244            encode_segment(session_id)
245        );
246        let mut sep = '?';
247        self.append_scope(&mut url, &mut sep);
248        url
249    }
250
251    /// URL for `POST /session/{sessionID}/permissions/{permissionID}`.
252    pub fn permission_url(&self, session_id: &str, permission_id: &str) -> String {
253        let mut url = format!(
254            "{}/session/{}/permissions/{}",
255            self.base_url,
256            encode_segment(session_id),
257            encode_segment(permission_id)
258        );
259        let mut sep = '?';
260        self.append_scope(&mut url, &mut sep);
261        url
262    }
263
264    /// URL for the `GET /event` SSE stream, with any configured [`Scope`]
265    /// applied as `directory` / `workspace` query parameters.
266    pub fn event_url(&self) -> String {
267        let mut url = format!("{}/event", self.base_url);
268        let mut sep = '?';
269        self.append_scope(&mut url, &mut sep);
270        url
271    }
272
273    /// A [`RequestBuilder`] for the `GET /event` SSE stream on this transport's
274    /// [`Client`], with the configured base URL, [`Scope`], and auth applied.
275    ///
276    /// Hand it to [`crate::sse::EventStream::from_request`] to open an
277    /// authenticated event stream that reuses this transport's connection pool.
278    /// No per-request timeout is applied, since the stream is long-lived.
279    pub fn event_request(&self) -> RequestBuilder {
280        let mut builder = self.client.get(self.event_url());
281        if let Some(auth) = &self.auth {
282            builder = builder.basic_auth(&auth.username, Some(&auth.password));
283        }
284        builder
285    }
286
287    /// Send a request and return the raw response body on 2xx, mapping any other
288    /// status to [`Error::Http`].
289    async fn send(&self, method: Method, url: &str, body: Option<Value>) -> Result<String> {
290        let mut builder = self.client.request(method, url);
291        if let Some(timeout) = self.timeout {
292            builder = builder.timeout(timeout);
293        }
294        if let Some(auth) = &self.auth {
295            builder = builder.basic_auth(&auth.username, Some(&auth.password));
296        }
297        if let Some(body) = body {
298            builder = builder.json(&body);
299        }
300        let response = builder.send().await?;
301        let status = response.status();
302        let text = response.text().await?;
303        if status.is_success() {
304            Ok(text)
305        } else {
306            Err(Error::Http {
307                status: status.as_u16(),
308                body: text,
309            })
310        }
311    }
312
313    /// Send a request and deserialize the JSON response body into `R`.
314    pub async fn request_json<R: DeserializeOwned>(
315        &self,
316        method: Method,
317        url: &str,
318        body: Option<Value>,
319    ) -> Result<R> {
320        let text = self.send(method, url, body).await?;
321        Ok(serde_json::from_str(&text)?)
322    }
323
324    /// Send a request that yields no response body (e.g. an HTTP 204), discarding
325    /// the body on success.
326    pub async fn request_unit(&self, method: Method, url: &str, body: Option<Value>) -> Result<()> {
327        self.send(method, url, body).await?;
328        Ok(())
329    }
330}