semora-cli 0.3.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! The HTTP API client.

use std::time::Duration;

use anyhow::{Context, anyhow};
use reqwest::{Method, RequestBuilder, Response, StatusCode};
use semora_proto::{
    ApiErrorBody, CreateKeyRequest, CreatedKey, DocDetail, DocSummary, EventsResponse, KeyInfo,
    ListResponse, PatchDoc, PushResponse, SayOut, SayRequest, WhoAmI,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use url::Url;

use crate::config::{Settings, origin};
use crate::exit;

pub const USER_AGENT: &str = concat!("semora-cli/", env!("CARGO_PKG_VERSION"));

#[derive(Clone)]
pub struct Client {
    pub base: Url,
    key: String,
    http: reqwest::Client,
}

/// Query parameters for a push.
#[derive(Debug, Default, Clone, Serialize)]
pub struct PushQuery {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub entry: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub meta: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,
}

#[derive(Debug, Default, Clone, Serialize)]
pub struct ListQuery {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub q: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
}

pub fn http_client() -> anyhow::Result<reqwest::Client> {
    Ok(reqwest::Client::builder()
        .user_agent(USER_AGENT)
        .connect_timeout(Duration::from_secs(15))
        .build()?)
}

pub fn no_server_error() -> anyhow::Error {
    anyhow!("no server configured: set SEMORA_URL (environment or .env), or pass --server")
}

impl Client {
    pub fn new(settings: &Settings) -> anyhow::Result<Client> {
        let base = settings.server.clone().ok_or_else(no_server_error)?;
        let key = settings.api_key.clone().ok_or_else(|| {
            exit::auth(format!(
                "no API key for {}: run `semora login`, set SEMORA_API_KEY, or pass --api-key",
                origin(&base)
            ))
        })?;
        Ok(Client {
            base,
            key,
            http: http_client()?,
        })
    }

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

    pub fn origin(&self) -> String {
        origin(&self.base)
    }

    pub fn url(&self, path: &str) -> Url {
        self.base.join(path).expect("API paths are valid")
    }

    fn request(&self, method: Method, path: &str) -> RequestBuilder {
        self.http
            .request(method, self.url(path))
            .bearer_auth(&self.key)
    }

    async fn send(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<Response> {
        let resp = rb
            .send()
            .await
            .with_context(|| format!("could not reach {}", self.origin()))?;
        check(resp, what).await
    }

    async fn json<T: DeserializeOwned>(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<T> {
        let resp = self.send(rb, what).await?;
        resp.json()
            .await
            .with_context(|| format!("reading the {what} response"))
    }

    pub async fn whoami(&self) -> anyhow::Result<WhoAmI> {
        self.json(self.request(Method::GET, "/api/v1/whoami"), "whoami")
            .await
    }

    pub async fn push(
        &self,
        id: Option<&str>,
        body: Vec<u8>,
        content_type: &str,
        q: &PushQuery,
    ) -> anyhow::Result<PushResponse> {
        let rb = match id {
            Some(id) => self.request(Method::PUT, &format!("/api/v1/docs/{id}")),
            None => self.request(Method::POST, "/api/v1/docs"),
        };
        let rb = rb
            .query(q)
            .header(reqwest::header::CONTENT_TYPE, content_type)
            .timeout(Duration::from_secs(300))
            .body(body);
        self.json(rb, "push").await
    }

    pub async fn doc(&self, id: &str) -> anyhow::Result<DocDetail> {
        self.json(
            self.request(Method::GET, &format!("/api/v1/docs/{id}")),
            &format!("document {id}"),
        )
        .await
    }

    pub async fn list(&self, q: &ListQuery) -> anyhow::Result<ListResponse> {
        self.json(self.request(Method::GET, "/api/v1/docs").query(q), "list")
            .await
    }

    pub async fn raw(
        &self,
        id: &str,
        path: Option<&str>,
        rev: Option<i64>,
    ) -> anyhow::Result<Vec<u8>> {
        let p = match path {
            Some(p) if !p.is_empty() => format!("/api/v1/docs/{id}/raw/{}", encode_path(p)),
            _ => format!("/api/v1/docs/{id}/raw"),
        };
        let mut rb = self.request(Method::GET, &p);
        if let Some(r) = rev {
            rb = rb.query(&[("rev", r)]);
        }
        let resp = self.send(rb, &format!("document {id}")).await?;
        Ok(resp.bytes().await?.to_vec())
    }

    pub async fn patch(&self, id: &str, p: &PatchDoc) -> anyhow::Result<DocSummary> {
        self.json(
            self.request(Method::PATCH, &format!("/api/v1/docs/{id}"))
                .json(p),
            &format!("document {id}"),
        )
        .await
    }

    pub async fn delete(&self, id: &str) -> anyhow::Result<()> {
        self.send(
            self.request(Method::DELETE, &format!("/api/v1/docs/{id}")),
            &format!("document {id}"),
        )
        .await?;
        Ok(())
    }

    pub async fn say(&self, id: &str, text: &str, reply_to: Option<i64>) -> anyhow::Result<SayOut> {
        let body = SayRequest {
            text: text.to_string(),
            reply_to,
        };
        self.json(
            self.request(Method::POST, &format!("/api/v1/docs/{id}/events"))
                .json(&body),
            &format!("document {id}"),
        )
        .await
    }

    pub async fn events(
        &self,
        id: &str,
        after: Option<i64>,
        wait: u64,
        kinds: Option<&str>,
    ) -> anyhow::Result<EventsResponse> {
        let mut rb = self
            .request(Method::GET, &format!("/api/v1/docs/{id}/events"))
            .query(&[("wait", wait)])
            .timeout(Duration::from_secs(wait + 30));
        if let Some(a) = after {
            rb = rb.query(&[("after", a)]);
        }
        if let Some(k) = kinds {
            rb = rb.query(&[("kinds", k)]);
        }
        self.json(rb, &format!("document {id}")).await
    }

    pub async fn download(&self, url: &str) -> anyhow::Result<Vec<u8>> {
        let rb = self.http.get(url).bearer_auth(&self.key);
        let resp = self.send(rb, "attachment").await?;
        Ok(resp.bytes().await?.to_vec())
    }

    pub async fn keys(&self) -> anyhow::Result<Vec<KeyInfo>> {
        self.json(self.request(Method::GET, "/api/v1/keys"), "keys")
            .await
    }

    pub async fn create_key(&self, req: &CreateKeyRequest) -> anyhow::Result<CreatedKey> {
        self.json(self.request(Method::POST, "/api/v1/keys").json(req), "keys")
            .await
    }

    pub async fn revoke_key(&self, id: i64) -> anyhow::Result<()> {
        self.send(
            self.request(Method::DELETE, &format!("/api/v1/keys/{id}")),
            &format!("key {id}"),
        )
        .await?;
        Ok(())
    }
}

/// Maps HTTP failures to exit codes: 401/403 → 3, 404 → 4.
pub async fn check(resp: Response, what: &str) -> anyhow::Result<Response> {
    let status = resp.status();
    if status.is_success() {
        return Ok(resp);
    }
    let text = resp.text().await.unwrap_or_default();
    let message = serde_json::from_str::<ApiErrorBody>(&text)
        .map(|b| b.message)
        .unwrap_or_else(|_| text.trim().to_string());
    let message = if message.is_empty() {
        status.to_string()
    } else {
        message
    };
    Err(match status {
        StatusCode::UNAUTHORIZED => exit::auth(format!(
            "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
        )),
        StatusCode::FORBIDDEN => exit::auth(message),
        StatusCode::NOT_FOUND => exit::not_found(format!("{what}: not found")),
        _ => anyhow!("{what} failed ({status}): {message}"),
    })
}

pub fn encode_path(path: &str) -> String {
    let mut out = String::with_capacity(path.len());
    for b in path.bytes() {
        if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}