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,
}
#[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(())
}
}
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
}