openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `reqwest::Client` wrapper for the platform REST API.
//!
//! Centralises:
//!   - bearer-auth header insertion
//!   - rustls-only TLS
//!   - retry-on-5xx with exponential back-off (3 attempts, 1s/2s/4s)
//!   - HTTP status -> OL-42xx mapping (OL-4230..4239 + OL-4290..4293)
//!   - rate-limit (`Retry-After`) parsing
//!
//! Hand-rolled in lieu of progenitor codegen — see
//! `phase-1-editor-cli.md` Open Question (2026-05-04).

use std::time::Duration;

use reqwest::{Method, StatusCode};
use secrecy::{ExposeSecret, SecretString};
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::error::{
    ErrorCode, OlError, OL_4230_BACKEND_4XX, OL_4231_BACKEND_5XX, OL_4232_BACKEND_UNAUTHORIZED,
    OL_4233_BACKEND_FORBIDDEN, OL_4234_BACKEND_NOT_FOUND, OL_4235_BACKEND_CONFLICT,
    OL_4236_BACKEND_GONE, OL_4237_BACKEND_INTERNAL, OL_4238_BACKEND_BAD_GATEWAY,
    OL_4239_BACKEND_UNAVAILABLE, OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG,
    OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG, OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG,
    OL_4283_PLATFORM_DUPLICATE_BINDING, OL_4290_RATE_LIMIT,
};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const RETRY_BACKOFF: [Duration; 3] = [
    Duration::from_secs(1),
    Duration::from_secs(2),
    Duration::from_secs(4),
];

/// Thin wrapper around `reqwest::Client` that prepends the configured base URL
/// and bearer token, retries on 5xx, and maps statuses to OL-42xx errors.
pub struct ApiClient {
    inner: reqwest::Client,
    base_url: String,
    token: SecretString,
}

impl ApiClient {
    pub fn new(base_url: impl Into<String>, token: SecretString) -> Result<Self, OlError> {
        let inner = reqwest::Client::builder()
            .use_rustls_tls()
            .timeout(DEFAULT_TIMEOUT)
            .build()
            .map_err(|e| OlError::new(OL_4231_BACKEND_5XX, format!("reqwest builder: {e}")))?;
        Ok(Self {
            inner,
            base_url: base_url.into().trim_end_matches('/').to_string(),
            token,
        })
    }

    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    /// GET `<base>/<path>`, parse JSON response.
    pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R, OlError> {
        let url = self.url(path);
        self.send_with_retry(Method::GET, &url, None::<&()>).await
    }

    pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OlError> {
        let url = self.url(path);
        self.send_with_retry(Method::DELETE, &url, None::<&()>)
            .await
    }

    pub async fn post<B: Serialize, R: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<R, OlError> {
        let url = self.url(path);
        self.send_with_retry(Method::POST, &url, Some(body)).await
    }

    pub async fn patch<B: Serialize, R: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<R, OlError> {
        let url = self.url(path);
        self.send_with_retry(Method::PATCH, &url, Some(body)).await
    }

    fn url(&self, path: &str) -> String {
        let path = path.strip_prefix('/').unwrap_or(path);
        format!("{}/{}", self.base_url, path)
    }

    async fn send_with_retry<B: Serialize, R: DeserializeOwned>(
        &self,
        method: Method,
        url: &str,
        body: Option<&B>,
    ) -> Result<R, OlError> {
        for (attempt, backoff) in std::iter::once(Duration::ZERO)
            .chain(RETRY_BACKOFF.iter().copied())
            .enumerate()
        {
            if !backoff.is_zero() {
                tokio::time::sleep(backoff).await;
            }
            let mut req = self
                .inner
                .request(method.clone(), url)
                .bearer_auth(self.token.expose_secret());
            if let Some(b) = body {
                req = req.json(b);
            }
            let resp = match req.send().await {
                Ok(r) => r,
                Err(e) if attempt < RETRY_BACKOFF.len() => {
                    tracing::warn!(error = %e, "api request failed; retrying");
                    continue;
                }
                Err(e) => {
                    return Err(OlError::new(
                        OL_4231_BACKEND_5XX,
                        format!("network error after {} attempts: {e}", attempt + 1),
                    ));
                }
            };
            let status = resp.status();
            // 5xx are retried; everything else is terminal (success or 4xx).
            if status.is_server_error() && attempt < RETRY_BACKOFF.len() {
                tracing::warn!(status = status.as_u16(), "5xx response; retrying");
                continue;
            }
            return decode(resp).await;
        }
        unreachable!("retry loop must always return")
    }
}

/// Platform error envelope shape. The platform may return any of:
///
/// ```json
/// { "error": { "code": "OL-4203", "message": "..." } }
/// { "error": { "code": "OL-4203", "message": "...", "details": [...] } }
/// ```
#[derive(serde::Deserialize)]
struct PlatformErrorEnvelope {
    error: PlatformErrorBody,
}

#[derive(serde::Deserialize)]
struct PlatformErrorBody {
    #[serde(default)]
    code: String,
    #[serde(default)]
    message: String,
}

async fn decode<R: DeserializeOwned>(resp: reqwest::Response) -> Result<R, OlError> {
    let status = resp.status();
    if status.is_success() {
        return resp
            .json::<R>()
            .await
            .map_err(|e| OlError::new(OL_4230_BACKEND_4XX, format!("parse response: {e}")));
    }
    let retry_after = resp
        .headers()
        .get(reqwest::header::RETRY_AFTER)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    let body = resp.text().await.unwrap_or_default();

    // First try to parse a structured platform error envelope and remap its
    // OL-42xx code to the matching CLI-side code. Falls back to raw HTTP
    // status mapping when the body is missing/non-JSON or the platform code
    // is not in our remap table.
    if let Ok(env) = serde_json::from_str::<PlatformErrorEnvelope>(&body) {
        if let Some(cli_code) = map_platform_code(&env.error.code) {
            let message = if env.error.message.is_empty() {
                format!("platform reported {}", env.error.code)
            } else {
                env.error.message.clone()
            };
            let mut err = OlError::new(cli_code, message);
            err = err.with_context(serde_json::json!({
                "platform_code": env.error.code,
                "http_status": status.as_u16(),
            }));
            return Err(err);
        }
    }

    Err(map_status(status, &body, retry_after.as_deref()))
}

/// Remap a platform `OL-42xx` code to the CLI-side equivalent. Returns
/// `None` for codes that should fall through to status-based mapping.
fn map_platform_code(platform: &str) -> Option<ErrorCode> {
    match platform {
        "OL-4203" => Some(OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG),
        "OL-4204" => Some(OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG),
        "OL-4214" => Some(OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG),
        "OL-4215" => Some(OL_4283_PLATFORM_DUPLICATE_BINDING),
        _ => None,
    }
}

fn map_status(status: StatusCode, body: &str, retry_after: Option<&str>) -> OlError {
    let snippet = truncate(body, 240);
    match status {
        StatusCode::UNAUTHORIZED => {
            OlError::new(OL_4232_BACKEND_UNAUTHORIZED, format!("401: {snippet}"))
        }
        StatusCode::FORBIDDEN => OlError::new(OL_4233_BACKEND_FORBIDDEN, format!("403: {snippet}")),
        StatusCode::NOT_FOUND => OlError::new(OL_4234_BACKEND_NOT_FOUND, format!("404: {snippet}")),
        StatusCode::CONFLICT => OlError::new(OL_4235_BACKEND_CONFLICT, format!("409: {snippet}")),
        StatusCode::GONE => OlError::new(OL_4236_BACKEND_GONE, format!("410: {snippet}")),
        StatusCode::TOO_MANY_REQUESTS => {
            let mut e = OlError::new(OL_4290_RATE_LIMIT, format!("429 rate-limited: {snippet}"));
            if let Some(s) = retry_after {
                e = e.with_suggestion(format!("Retry after {s}s."));
            }
            e
        }
        StatusCode::INTERNAL_SERVER_ERROR => {
            OlError::new(OL_4237_BACKEND_INTERNAL, format!("500: {snippet}"))
        }
        StatusCode::BAD_GATEWAY => {
            OlError::new(OL_4238_BACKEND_BAD_GATEWAY, format!("502: {snippet}"))
        }
        StatusCode::SERVICE_UNAVAILABLE => {
            OlError::new(OL_4239_BACKEND_UNAVAILABLE, format!("503: {snippet}"))
        }
        s if s.is_client_error() => {
            OlError::new(OL_4230_BACKEND_4XX, format!("{}: {snippet}", s.as_u16()))
        }
        s => OlError::new(OL_4231_BACKEND_5XX, format!("{}: {snippet}", s.as_u16())),
    }
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        format!("{}", &s[..n])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn map_status_routes_401_to_unauthorized() {
        let e = map_status(StatusCode::UNAUTHORIZED, "{}", None);
        assert_eq!(e.code.code, "OL-4232");
    }

    #[test]
    fn map_status_routes_404_to_not_found() {
        let e = map_status(StatusCode::NOT_FOUND, "missing", None);
        assert_eq!(e.code.code, "OL-4234");
    }

    #[test]
    fn map_status_routes_429_with_retry_after() {
        let e = map_status(StatusCode::TOO_MANY_REQUESTS, "slow down", Some("60"));
        assert_eq!(e.code.code, "OL-4290");
        assert!(e.suggestion.unwrap().contains("60"));
    }

    #[test]
    fn map_status_routes_500_to_internal() {
        let e = map_status(StatusCode::INTERNAL_SERVER_ERROR, "boom", None);
        assert_eq!(e.code.code, "OL-4237");
    }

    #[test]
    fn map_platform_code_remaps_known_codes() {
        assert_eq!(map_platform_code("OL-4203").unwrap().code, "OL-4280");
        assert_eq!(map_platform_code("OL-4204").unwrap().code, "OL-4281");
        assert_eq!(map_platform_code("OL-4214").unwrap().code, "OL-4282");
        assert_eq!(map_platform_code("OL-4215").unwrap().code, "OL-4283");
        assert!(map_platform_code("OL-4242").is_none());
        assert!(map_platform_code("").is_none());
    }

    #[test]
    fn platform_error_envelope_parses() {
        let body = r#"{"error":{"code":"OL-4203","message":"Tool slug already in use."}}"#;
        let env: PlatformErrorEnvelope = serde_json::from_str(body).unwrap();
        assert_eq!(env.error.code, "OL-4203");
        assert_eq!(env.error.message, "Tool slug already in use.");
    }

    #[test]
    fn truncate_caps_long_strings() {
        let s = "a".repeat(500);
        let got = truncate(&s, 240);
        // 240 ASCII bytes + 3-byte UTF-8 ellipsis ('…').
        assert_eq!(got.len(), 240 + ''.len_utf8());
        assert!(got.ends_with(''));
    }
}