kagi-sdk 0.1.0

Rust-first Kagi SDK with explicit official-api and session-web surfaces
Documentation
use reqwest::header::{HeaderMap, CONTENT_TYPE, LOCATION};
use serde_json::Value;

use crate::{
    auth::Credentials,
    config::ClientConfig,
    error::KagiError,
    routing::{EndpointId, ProtocolSurface},
};

#[derive(Debug, Clone)]
pub(crate) struct Transport {
    http: reqwest::Client,
    config: ClientConfig,
}

#[derive(Debug, Clone)]
pub(crate) struct TransportResponse {
    pub endpoint: EndpointId,
    pub status: u16,
    pub body: String,
    pub content_type: Option<String>,
    pub redirect_location: Option<String>,
}

#[derive(Debug, Clone)]
pub(crate) enum RequestBody {
    Empty,
    Json(Value),
    Form(Vec<(String, String)>),
}

impl Transport {
    pub(crate) fn new(config: ClientConfig) -> Result<Self, KagiError> {
        config.validate()?;

        let http = reqwest::Client::builder()
            .timeout(config.timeout)
            .user_agent(config.user_agent.clone())
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|source| KagiError::InvalidClientConfiguration {
                reason: format!("failed to build reqwest client: {source}"),
            })?;

        Ok(Self { http, config })
    }

    pub(crate) async fn execute(
        &self,
        credentials: &Credentials,
        endpoint: EndpointId,
        query: &[(String, String)],
        body: RequestBody,
    ) -> Result<TransportResponse, KagiError> {
        let spec = endpoint.spec();
        let provided_kind = credentials.kind();

        if provided_kind != spec.allowed_credential {
            return Err(KagiError::UnsupportedCapability {
                endpoint,
                credential: provided_kind,
                expected: spec.allowed_credential,
            });
        }

        let mut headers = HeaderMap::new();
        credentials.apply_to_headers(&mut headers)?;

        let url = self.config.base_url.join(spec.route).map_err(|source| {
            KagiError::InvalidClientConfiguration {
                reason: format!("invalid route join for {endpoint}: {source}"),
            }
        })?;

        let mut request = self
            .http
            .request(spec.method.as_reqwest(), url)
            .headers(headers);
        if !query.is_empty() {
            request = request.query(query);
        }

        request = match body {
            RequestBody::Empty => request,
            RequestBody::Json(json) => request.json(&json),
            RequestBody::Form(form) => request.form(&form),
        };

        let response = request
            .send()
            .await
            .map_err(|source| KagiError::Transport { endpoint, source })?;

        let status = response.status().as_u16();
        let content_type = response
            .headers()
            .get(CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .map(ToOwned::to_owned);
        let redirect_location = response
            .headers()
            .get(LOCATION)
            .and_then(|value| value.to_str().ok())
            .map(ToOwned::to_owned);
        let body = response
            .text()
            .await
            .map_err(|source| KagiError::Transport { endpoint, source })?;

        if matches!(status, 401 | 403) {
            return match spec.surface {
                ProtocolSurface::OfficialApi => Err(KagiError::UnauthorizedBotToken {
                    endpoint,
                    message: body,
                }),
                ProtocolSurface::SessionWeb => Err(KagiError::InvalidSession {
                    endpoint,
                    status,
                    message: body,
                }),
            };
        }

        Ok(TransportResponse {
            endpoint,
            status,
            body,
            content_type,
            redirect_location,
        })
    }
}