Skip to main content

cratefield_adapter_colonizer/
lib.rs

1//! `cratefield-adapter-colonizer`: an HTTP client for the **Colonizer
2//! mothership** (issue #675), over the runtime's [`HttpClient`] port — no
3//! `reqwest`, no vendor SDK — so it runs on Workers and natively unchanged.
4//! The routes, the error mapping and the reachability requirement are in the
5//! crate README, which is this crate's documentation.
6
7#![doc = include_str!("../README.md")]
8#![forbid(unsafe_code)]
9
10use std::fmt;
11use std::fmt::Write as _;
12use std::sync::Arc;
13use std::time::Duration;
14
15use bytes::Bytes;
16use cratefield_core::{Clock, HttpClient, HttpError, retry_after};
17use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
18use http::{Method, Request, StatusCode};
19use serde::{Deserialize, Serialize, de::DeserializeOwned};
20
21/// The API path prefix every route hangs off.
22const API: &str = "/api/v1";
23
24/// What one call can report. `Transient` is the only one a caller retries.
25#[non_exhaustive]
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ColonizerError {
28    /// No usable token or base URL: reported without touching the network.
29    NotConfigured,
30    /// 400 and the other 4xx, and every local refusal. `detail` is the
31    /// provider's `{"error": …}` text, redacted.
32    Invalid { detail: String },
33    /// 401: the token is missing, unknown or expired.
34    Unauthorized,
35    /// 403: the token may not do that, or is missing a scope.
36    Forbidden { scope: Option<String> },
37    /// 404: no such colony, or no such question on it.
38    NotFound,
39    /// 409: the colony is in a state that refuses this call.
40    Conflict { detail: String },
41    /// 429 and every 5xx: worth retrying.
42    Transient { retry_after: Option<Duration> },
43    /// A body that was not the JSON this client expects.
44    Decode { detail: String },
45    /// The request never reached the mothership — including
46    /// `HttpError::BlockedDestination`, a base URL the port refuses.
47    Transport { detail: String },
48}
49
50impl fmt::Display for ColonizerError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::NotConfigured => f.write_str("colonizer is not configured: no API token"),
54            Self::Invalid { detail } => write!(f, "colonizer rejected the request: {detail}"),
55            Self::Unauthorized => f.write_str("colonizer rejected the token (401)"),
56            Self::Forbidden { scope: Some(s) } => {
57                write!(f, "the token lacks the {s:?} scope (403)")
58            }
59            Self::Forbidden { scope: None } => f.write_str("the token may not do that (403)"),
60            Self::NotFound => f.write_str("no such colony (404)"),
61            Self::Conflict { detail } => write!(f, "the colony is in the wrong state: {detail}"),
62            Self::Transient {
63                retry_after: Some(d),
64            } => {
65                write!(f, "colonizer is unavailable, retry in {}s", d.as_secs())
66            }
67            Self::Transient { retry_after: None } => f.write_str("colonizer is unavailable"),
68            Self::Decode { detail } => write!(f, "could not read the reply: {detail}"),
69            Self::Transport { detail } => write!(f, "could not reach the mothership: {detail}"),
70        }
71    }
72}
73
74impl std::error::Error for ColonizerError {}
75
76/// The account a token acts as, and the scopes it carries.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Whoami {
79    pub login: String,
80    #[serde(default)]
81    pub scopes: Vec<String>,
82}
83
84/// A colony to open: `owner/name`, the issue, and the brief. Unset fields are
85/// omitted from the body.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct NewColony {
88    pub repo: String,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub issue: Option<u64>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub prompt: Option<String>,
93}
94
95/// A colony as the mothership reports it. `id` is the `{id}` every other route
96/// takes; `status` is where it is; an omitted field reads as empty.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Colony {
99    pub id: String,
100    pub status: String,
101    #[serde(default)]
102    pub repo: String,
103    #[serde(default)]
104    pub issue: Option<u64>,
105    #[serde(default)]
106    pub branch: Option<String>,
107}
108
109/// One page of colonies and the cursor for the next, which goes back to
110/// [`Colonizer::list`] and is absent on the last page.
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ColonyPage {
113    #[serde(default)]
114    pub colonies: Vec<Colony>,
115    #[serde(default)]
116    pub next_cursor: Option<String>,
117}
118
119/// A question a colony is blocked on: the `id` [`Colonizer::answer`] quotes
120/// back, the `text` asked, and the `options` offered.
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Question {
123    pub id: String,
124    pub text: String,
125    #[serde(default)]
126    pub options: Vec<String>,
127}
128
129/// A client for the Colonizer mothership API. `Debug` prints the base URL and
130/// whether a token is configured, never the token itself.
131pub struct Colonizer {
132    inner: Option<Live>,
133}
134
135struct Live {
136    http: Arc<dyn HttpClient>,
137    /// Reads the HTTP-date form of `Retry-After` (issue #278).
138    clock: Arc<dyn Clock>,
139    token: String,
140    base_url: String,
141}
142
143impl fmt::Debug for Colonizer {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match &self.inner {
146            Some(live) => f
147                .debug_struct("Colonizer")
148                .field("base_url", &live.base_url)
149                .field("token_configured", &!live.token.is_empty())
150                .finish_non_exhaustive(),
151            None => f.write_str("Colonizer(NotConfigured)"),
152        }
153    }
154}
155
156impl Colonizer {
157    /// A client for the mothership at `base_url` (a trailing slash is
158    /// harmless); with no usable token or base URL it makes no request.
159    #[must_use]
160    pub fn new(
161        http: Arc<dyn HttpClient>,
162        clock: Arc<dyn Clock>,
163        base_url: impl Into<String>,
164        token: Option<String>,
165    ) -> Self {
166        let (base_url, token) = (base_url.into(), token.unwrap_or_default());
167        let usable = !base_url.trim().is_empty() && !token.trim().is_empty();
168        let inner = usable.then(|| Live {
169            http,
170            clock,
171            token: token.trim().to_owned(),
172            base_url: base_url.trim().to_owned(),
173        });
174        Self { inner }
175    }
176
177    /// `GET {base}/api/v1/whoami`: the account this token acts as.
178    ///
179    /// # Errors
180    /// [`ColonizerError`]: the status mapping, or `NotConfigured` with no token.
181    pub async fn whoami(&self) -> Result<Whoami, ColonizerError> {
182        self.json(Method::GET, &format!("{API}/whoami"), None::<&()>)
183            .await
184    }
185
186    /// `POST {base}/api/v1/colonies`: opens a colony.
187    ///
188    /// # Errors
189    /// [`ColonizerError`]: `Conflict` when one is already running.
190    pub async fn create_session(&self, new: &NewColony) -> Result<Colony, ColonizerError> {
191        self.json(Method::POST, &format!("{API}/colonies"), Some(new))
192            .await
193    }
194
195    /// `GET {base}/api/v1/colonies/{id}`.
196    ///
197    /// # Errors
198    /// [`ColonizerError`]: `Invalid` for a path-like id, else the status mapping.
199    pub async fn get(&self, id: &str) -> Result<Colony, ColonizerError> {
200        self.colony(id, "get").await
201    }
202
203    /// `GET {base}/api/v1/colonies?limit=N[&cursor=…]`: one page plus the next
204    /// cursor, percent-encoded so it cannot inject a parameter.
205    ///
206    /// # Errors
207    /// [`ColonizerError`]: the status mapping, or `NotConfigured` with no token.
208    pub async fn list(
209        &self,
210        limit: u32,
211        cursor: Option<&str>,
212    ) -> Result<ColonyPage, ColonizerError> {
213        let mut path = format!("{API}/colonies?limit={limit}");
214        if let Some(cursor) = cursor {
215            let _ = write!(path, "&cursor={}", percent_encode(cursor));
216        }
217        self.json(Method::GET, &path, None::<&()>).await
218    }
219
220    /// `GET {base}/api/v1/colonies/{id}/question`: what the colony is blocked
221    /// on, or `Ok(None)` when it asks nothing (a 204 or a JSON `null`).
222    ///
223    /// # Errors
224    /// [`ColonizerError`]: `Invalid` for a path-like id, else the status mapping.
225    pub async fn question(&self, id: &str) -> Result<Option<Question>, ColonizerError> {
226        let path = format!("{}/question", Self::colony_path(id)?);
227        self.json(Method::GET, &path, None::<&()>).await
228    }
229
230    /// `POST {base}/api/v1/colonies/{id}/answer`: answers `question_id`.
231    ///
232    /// # Errors
233    /// As [`question`](Self::question), plus `NotFound` once answered elsewhere.
234    pub async fn answer(
235        &self,
236        id: &str,
237        question_id: &str,
238        answer: &str,
239    ) -> Result<(), ColonizerError> {
240        self.post(
241            id,
242            "answer",
243            &serde_json::json!({
244                "question_id": question_id,
245                "answer": answer,
246            }),
247        )
248        .await
249    }
250
251    /// `POST {base}/api/v1/colonies/{id}/messages`: notes something in the log.
252    ///
253    /// # Errors
254    /// [`ColonizerError`]: `Invalid` for a path-like id, else the status mapping.
255    pub async fn message(&self, id: &str, text: &str) -> Result<(), ColonizerError> {
256        self.post(id, "messages", &serde_json::json!({ "text": text }))
257            .await
258    }
259
260    /// `POST {base}/api/v1/colonies/{id}/stop`: stops a colony, returning it
261    /// in its new state.
262    ///
263    /// # Errors
264    /// As [`question`](Self::question), plus `Conflict` when already stopped.
265    pub async fn stop(&self, id: &str) -> Result<Colony, ColonizerError> {
266        self.colony(id, "stop").await
267    }
268
269    /// `POST {base}/api/v1/colonies/{id}/resume`: starts a colony again,
270    /// returning it in its new state.
271    ///
272    /// # Errors
273    /// As [`question`](Self::question), plus `Conflict` when already running.
274    pub async fn resume(&self, id: &str) -> Result<Colony, ColonizerError> {
275        self.colony(id, "resume").await
276    }
277
278    /// A body-less colony route: `{id}` for `get`, `{id}/{action}` for the two
279    /// mutations.
280    async fn colony(&self, id: &str, action: &str) -> Result<Colony, ColonizerError> {
281        let path = Self::colony_path(id)?;
282        let (method, path) = if action == "get" {
283            (Method::GET, path)
284        } else {
285            (Method::POST, format!("{path}/{action}"))
286        };
287        self.json(method, &path, None::<&()>).await
288    }
289
290    /// A body-bearing call under `{id}/{action}`, discarding the empty reply.
291    async fn post(
292        &self,
293        id: &str,
294        action: &str,
295        body: &serde_json::Value,
296    ) -> Result<(), ColonizerError> {
297        let path = format!("{}/{action}", Self::colony_path(id)?);
298        let _: serde_json::Value = self.json(Method::POST, &path, Some(body)).await?;
299        Ok(())
300    }
301
302    /// `/api/v1/colonies/{id}`, refusing an id that is not one plain path
303    /// segment before any request. A colony id is `[A-Za-z0-9_.-]` without
304    /// `..`; anything else would rewrite the path.
305    fn colony_path(id: &str) -> Result<String, ColonizerError> {
306        let refused = id.is_empty()
307            || id.contains("..")
308            || !id
309                .chars()
310                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
311        if refused {
312            let detail = format!("{id:?} is not a colony id");
313            return Err(ColonizerError::Invalid {
314                detail: cratefield_core::scrub_text(&detail),
315            });
316        }
317        Ok(format!("{API}/colonies/{id}"))
318    }
319
320    /// Send `body` (if any) and read the reply as `T`; a 204 or a JSON `null`
321    /// is `Ok(None)`.
322    async fn json<T: DeserializeOwned, B: Serialize>(
323        &self,
324        method: Method,
325        path: &str,
326        body: Option<&B>,
327    ) -> Result<T, ColonizerError> {
328        let live = self.live()?;
329        let body = body
330            .map(serde_json::to_vec)
331            .transpose()
332            .map_err(|err| live.transport(&err.to_string()))?
333            .map(Bytes::from);
334        let text = live.send(&method, path, body).await?;
335        match serde_json::from_str(&text) {
336            Ok(value) => Ok(value),
337            // An empty or `null` body is `None`, not a decode failure — but
338            // only when `T` can actually be `Option<_>`.
339            Err(_) if text.trim().is_empty() || text.trim() == "null" => {
340                serde_json::from_str("null").map_err(|e| live.decode(&e.to_string()))
341            }
342            Err(err) => Err(live.decode(&err.to_string())),
343        }
344    }
345
346    fn live(&self) -> Result<&Live, ColonizerError> {
347        self.inner.as_ref().ok_or_else(|| {
348            tracing::info!(
349                provider = "colonizer",
350                outcome = "not_configured",
351                "colony outcome"
352            );
353            ColonizerError::NotConfigured
354        })
355    }
356}
357
358impl Live {
359    /// One request: the URL, `Bearer` auth and the JSON content type, then the
360    /// status mapping. Returns the body text on success.
361    async fn send(
362        &self,
363        method: &Method,
364        path: &str,
365        body: Option<Bytes>,
366    ) -> Result<String, ColonizerError> {
367        let mut builder = Request::builder()
368            .method(method.clone())
369            .uri(format!("{}{path}", self.base_url.trim_end_matches('/')))
370            .header(AUTHORIZATION, format!("Bearer {}", self.token))
371            .header(ACCEPT, "application/json");
372        if body.is_some() {
373            builder = builder.header(CONTENT_TYPE, "application/json");
374        }
375        let request = builder
376            .body(body.unwrap_or_default())
377            .map_err(|err| self.transport(&err.to_string()))?;
378
379        let response = self
380            .http
381            .send(request)
382            .await
383            .map_err(|err: HttpError| self.transport(&err.to_string()))?;
384        let status = response.status();
385        let retry_after = retry_after(response.headers(), self.clock.as_ref());
386        let text = String::from_utf8_lossy(response.body()).to_string();
387        if status.is_success() {
388            tracing::info!(provider = "colonizer", code = status.as_u16(), method = %method, outcome = "ok", "colony outcome");
389            return Ok(text);
390        }
391        let error = self.map_status(status, &text, retry_after);
392        tracing::warn!(provider = "colonizer", code = status.as_u16(), method = %method, outcome = "failed", error = %error, "colony outcome");
393        Err(error)
394    }
395
396    /// The status mapping, one place for every route.
397    fn map_status(
398        &self,
399        status: StatusCode,
400        body: &str,
401        retry_after: Option<Duration>,
402    ) -> ColonizerError {
403        let detail = || self.provider_detail(body);
404        match status {
405            StatusCode::UNAUTHORIZED => ColonizerError::Unauthorized,
406            StatusCode::FORBIDDEN => ColonizerError::Forbidden {
407                scope: self.scope(body),
408            },
409            StatusCode::NOT_FOUND => ColonizerError::NotFound,
410            StatusCode::CONFLICT => ColonizerError::Conflict { detail: detail() },
411            // 429 and every 5xx are the caller's business to retry.
412            other if other == StatusCode::TOO_MANY_REQUESTS || other.is_server_error() => {
413                ColonizerError::Transient { retry_after }
414            }
415            // 400 and every other 4xx are a request this client got wrong.
416            _ => ColonizerError::Invalid { detail: detail() },
417        }
418    }
419
420    /// The mothership's `{"error": "…"}` text, redacted; any other body
421    /// still yields something worth putting in the error.
422    fn provider_detail(&self, body: &str) -> String {
423        let text = Self::field(body, "error").unwrap_or_else(|| body.trim().to_owned());
424        redact(&text, &self.token)
425    }
426
427    /// The scope a 403 named, from `scope` or `required_scope`. It is provider
428    /// text, so it is redacted like any other detail.
429    fn scope(&self, body: &str) -> Option<String> {
430        Self::field(body, "scope")
431            .or_else(|| Self::field(body, "required_scope"))
432            .map(|scope| redact(&scope, &self.token))
433    }
434
435    fn field(body: &str, name: &str) -> Option<String> {
436        serde_json::from_str::<serde_json::Value>(body)
437            .ok()?
438            .get(name)?
439            .as_str()
440            .map(str::to_owned)
441    }
442
443    /// A failure that never reached the mothership, token-redacted.
444    fn transport(&self, err: &str) -> ColonizerError {
445        ColonizerError::Transport {
446            detail: redact(err, &self.token),
447        }
448    }
449
450    /// A body that was not the JSON this client expects.
451    fn decode(&self, err: &str) -> ColonizerError {
452        ColonizerError::Decode {
453            detail: redact(err, &self.token),
454        }
455    }
456}
457
458/// Cuts the token out of text on its way into an error, then runs core's
459/// scrubber over what is left.
460fn redact(text: &str, token: &str) -> String {
461    cratefield_core::scrub_text(&text.replace(token, "[redacted]"))
462}
463
464/// Percent-encodes a query value: anything outside the unreserved set is `%XX`.
465fn percent_encode(value: &str) -> String {
466    let mut out = String::with_capacity(value.len());
467    for byte in value.bytes() {
468        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
469            out.push(char::from(byte));
470        } else {
471            let _ = write!(out, "%{byte:02X}");
472        }
473    }
474    out
475}