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}