openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Thin wrappers over the platform's `/api/v1/editor/*` endpoints. Compose
//! [`super::client::ApiClient`] (bearer + retry + OL-42xx mapping) with the
//! typify-generated request/response types.
//!
//! Response shapes are kept generic with `serde_json::Value` for fields the
//! platform's OpenAPI spec hasn't been hand-typed for; T6/T7 sharpen them as
//! needed.

use serde::{Deserialize, Serialize};

use crate::api::client::ApiClient;
use crate::error::OlError;

/// Tool listing response — minimal fields for `tools list` rendering.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorToolRow {
    #[serde(default)]
    pub id: Option<String>,
    pub slug: String,
    #[serde(default)]
    pub version: Option<String>,
    #[serde(default)]
    pub lifecycle_state: Option<String>,
    #[serde(default)]
    pub routing_score: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorProviderRow {
    #[serde(default)]
    pub id: Option<String>,
    pub slug: String,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default)]
    pub region: Option<String>,
    #[serde(default)]
    pub total_capacity_qps: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorBindingRow {
    pub id: String,
    pub tool: String,
    pub provider: String,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub routing_score: Option<f64>,
}

/// Wrapped list responses — the platform returns `{"items": [...]}` to leave
/// room for pagination metadata later.
#[derive(Debug, Deserialize)]
pub struct ListResponse<T> {
    #[serde(default = "Vec::new")]
    pub items: Vec<T>,
}

#[derive(Debug, Serialize, Default)]
pub struct EditorProfilePatch {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub homepage_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub docs_url: Option<String>,
}

pub async fn update_profile(
    client: &ApiClient,
    patch: &EditorProfilePatch,
) -> Result<serde_json::Value, OlError> {
    client.patch("/api/v1/editor/profile", patch).await
}

pub async fn list_tools(client: &ApiClient) -> Result<Vec<EditorToolRow>, OlError> {
    let resp: ListResponse<EditorToolRow> = client.get("/api/v1/editor/tools").await?;
    Ok(resp.items)
}

pub async fn delete_tool(client: &ApiClient, slug: &str) -> Result<(), OlError> {
    let _: serde_json::Value = client
        .delete(&format!("/api/v1/editor/tools/{slug}"))
        .await?;
    Ok(())
}

pub async fn deprecate_tool(
    client: &ApiClient,
    slug: &str,
    message: &str,
) -> Result<serde_json::Value, OlError> {
    #[derive(Serialize)]
    struct Body<'a> {
        lifecycle_state: &'a str,
        message: &'a str,
    }
    client
        .patch(
            &format!("/api/v1/editor/tools/{slug}"),
            &Body {
                lifecycle_state: "deprecated",
                message,
            },
        )
        .await
}

pub async fn list_providers(client: &ApiClient) -> Result<Vec<EditorProviderRow>, OlError> {
    let resp: ListResponse<EditorProviderRow> = client.get("/api/v1/editor/providers").await?;
    Ok(resp.items)
}

pub async fn delete_provider(client: &ApiClient, slug: &str) -> Result<(), OlError> {
    let _: serde_json::Value = client
        .delete(&format!("/api/v1/editor/providers/{slug}"))
        .await?;
    Ok(())
}

pub async fn update_provider(
    client: &ApiClient,
    slug: &str,
    patch: &serde_json::Value,
) -> Result<serde_json::Value, OlError> {
    client
        .patch(&format!("/api/v1/editor/providers/{slug}"), patch)
        .await
}

pub async fn list_bindings(client: &ApiClient) -> Result<Vec<EditorBindingRow>, OlError> {
    let resp: ListResponse<EditorBindingRow> = client.get("/api/v1/editor/bindings").await?;
    Ok(resp.items)
}

/// Upsert a tool — create if missing, otherwise update in place. The
/// platform decides which based on `slug`.
pub async fn upsert_tool(
    client: &ApiClient,
    body: &serde_json::Value,
) -> Result<serde_json::Value, OlError> {
    client.post("/api/v1/editor/tools", body).await
}

/// Upsert a provider.
pub async fn upsert_provider(
    client: &ApiClient,
    body: &serde_json::Value,
) -> Result<serde_json::Value, OlError> {
    client.post("/api/v1/editor/providers", body).await
}

/// Upsert a binding. Returns the (possibly-newly-issued) `whsec_live_…`
/// when the platform mints a fresh secret as part of creation.
#[derive(Debug, Clone, Deserialize)]
pub struct UpsertBindingResponse {
    pub id: String,
    #[serde(default)]
    pub state: Option<String>,
    /// Plaintext webhook secret revealed exactly once (on first creation
    /// or on explicit rotation). The CLI stores this locally and never
    /// reads it again.
    #[serde(default)]
    pub secret: Option<String>,
}

pub async fn upsert_binding(
    client: &ApiClient,
    body: &serde_json::Value,
) -> Result<UpsertBindingResponse, OlError> {
    client.post("/api/v1/editor/bindings", body).await
}

// ---------------------------------------------------------------------------
// Pre-flight `:validate` endpoints (introduced for `init`/`register`/`publish`
// to detect slug collisions before any mutation). Each accepts the same body
// shape as the corresponding upsert endpoint and returns 200 when the slug
// (or binding pair) is available, 409 with a structured `OL-42xx` envelope
// when it isn't. The 409 path is auto-remapped to OL-4280..4283 by
// `client::decode` — callers just match on `OlError::code`.
// ---------------------------------------------------------------------------

async fn validate(client: &ApiClient, path: &str, body: &serde_json::Value) -> Result<(), OlError> {
    let _: serde_json::Value = client.post(path, body).await?;
    Ok(())
}

/// Probe whether the calling editor can claim `slug`. The platform returns
/// 200 if the slug is available (or already owned by the calling editor)
/// and 409 OL-4214 otherwise.
///
/// Lives on the auth surface (`/auth/editor-slug:validate`) rather than
/// `/editor/...` because first-time onboarders call this before their org
/// has `is_editor=true`, so the route can't sit behind the editor-context
/// guard that protects the rest of `/api/v1/editor/...`.
pub async fn validate_editor_slug(client: &ApiClient, slug: &str) -> Result<(), OlError> {
    validate(
        client,
        "/api/v1/auth/editor-slug:validate",
        &serde_json::json!({ "slug": slug }),
    )
    .await
}

/// Probe whether a tool with the given slug can be created by the caller.
/// Pass the full tool body — the platform checks slug uniqueness and may
/// also surface manifest-level validation errors here.
pub async fn validate_tool(client: &ApiClient, body: &serde_json::Value) -> Result<(), OlError> {
    validate(client, "/api/v1/editor/tools:validate", body).await
}

/// Probe whether a provider with the given slug can be created by the caller.
pub async fn validate_provider(
    client: &ApiClient,
    body: &serde_json::Value,
) -> Result<(), OlError> {
    validate(client, "/api/v1/editor/providers:validate", body).await
}

/// Probe whether a `(tool_slug, provider_slug)` binding pair can be created.
/// The platform returns 200 even when one of the slugs hasn't been registered
/// yet (init-time use case) — only the duplicate-binding case yields 409.
///
/// Body keys are `tool_slug` / `provider_slug` per the platform's
/// `BindingPairValidateRequest` DTO — note this differs from the wire shape
/// of an actual binding (`tool` / `provider`), which is what the upsert
/// endpoint and the on-disk manifest use.
pub async fn validate_binding(
    client: &ApiClient,
    tool_slug: &str,
    provider_slug: &str,
) -> Result<(), OlError> {
    validate(
        client,
        "/api/v1/editor/bindings:validate",
        &serde_json::json!({ "tool_slug": tool_slug, "provider_slug": provider_slug }),
    )
    .await
}