use std::time::Duration;
use anyhow::{Context, anyhow};
use reqwest::{Method, RequestBuilder, Response, StatusCode};
use semora_proto::{
ApiErrorBody, CreateKeyRequest, CreateProject, CreatedKey, DocDetail, DocSummary,
EventsResponse, JoinNeeded, KeyInfo, Level, ListResponse, Mapping, MappingList, PatchDoc,
ProjectInfo, ProjectList, 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub adopt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: 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 project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: 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")
}
fn no_projects_api() -> anyhow::Error {
exit::not_found(
"this server has no projects; it may be older than this CLI. Update the server.",
)
}
pub enum ProjectCreated {
Created(ProjectInfo),
Linked(ProjectInfo),
Taken,
JoinNeeded {
owner: String,
level: Level,
},
}
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,
if_match: Option<i64>,
) -> 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 mut rb = rb
.query(q)
.header(reqwest::header::CONTENT_TYPE, content_type)
.timeout(Duration::from_secs(300))
.body(body);
if let Some(rev) = if_match {
rb = rb.header(reqwest::header::IF_MATCH, format!("\"{rev}\""));
}
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 project_docs(&self, project: &str) -> anyhow::Result<Vec<DocSummary>> {
let mut q = ListQuery {
project: Some(project.to_string()),
sort: Some("created".into()),
limit: Some(200),
..Default::default()
};
let mut all = Vec::new();
loop {
let page = self.list(&q).await.map_err(|e| {
if exit::is_not_found(&e) {
exit::not_found(format!("no project `{project}` on {}", self.origin()))
} else {
e
}
})?;
if page
.docs
.iter()
.any(|d| d.project.as_deref() != Some(project))
{
anyhow::bail!(
"this server has no projects; it may be older than this CLI. Update the server."
);
}
all.extend(page.docs);
match page.next_cursor {
Some(c) => q.cursor = Some(c),
None => return Ok(all),
}
}
}
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 doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
if !crate::project_link::valid_slug(project) {
anyhow::bail!("`{project}` is not a project id");
}
let resp = self
.request(
Method::GET,
&format!("/api/v1/projects/{project}/docs/{}", encode_path(path)),
)
.send()
.await
.with_context(|| format!("could not reach {}", self.origin()))?;
if resp.status() == StatusCode::NOT_FOUND {
let message = resp
.json::<ApiErrorBody>()
.await
.map(|b| b.message)
.unwrap_or_default();
return Err(exit::not_found(if message.contains("is a folder") {
message
} else {
format!("nothing at `{path}` in {project}, or no such project")
}));
}
check(resp, &format!("{project}:{path}"))
.await?
.json()
.await
.with_context(|| format!("reading the {project}:{path} response"))
}
pub async fn projects(&self) -> anyhow::Result<Vec<ProjectInfo>> {
let list: ProjectList = self
.json(self.request(Method::GET, "/api/v1/projects"), "projects")
.await
.map_err(|e| {
if exit::is_not_found(&e) {
no_projects_api()
} else {
e
}
})?;
Ok(list.projects)
}
pub async fn create_project(&self, req: &CreateProject) -> anyhow::Result<ProjectCreated> {
let rb = self.request(Method::POST, "/api/v1/projects").json(req);
let resp = rb
.send()
.await
.with_context(|| format!("could not reach {}", self.origin()))?;
if resp.status() == StatusCode::CONFLICT {
let text = resp.text().await.unwrap_or_default();
return Ok(match serde_json::from_str::<JoinNeeded>(&text) {
Ok(j) if j.error == "join_needed" => ProjectCreated::JoinNeeded {
owner: j.owner,
level: j.level,
},
_ => ProjectCreated::Taken,
});
}
if resp.status() == StatusCode::NOT_FOUND {
return Err(no_projects_api());
}
let created = resp.status() == StatusCode::CREATED;
let resp = check(resp, "project").await?;
let info: ProjectInfo = resp.json().await.context("reading the project response")?;
Ok(if created {
ProjectCreated::Created(info)
} else {
ProjectCreated::Linked(info)
})
}
pub async fn mappings(&self, slug: &str) -> anyhow::Result<MappingList> {
self.json(
self.request(
Method::GET,
&format!("/api/v1/projects/{}/mappings", encode_path(slug)),
),
&format!("project {slug} mappings"),
)
.await
}
pub async fn set_mappings(&self, slug: &str, list: &[Mapping]) -> anyhow::Result<MappingList> {
let body = MappingList {
mappings: list.to_vec(),
};
self.json(
self.request(
Method::PUT,
&format!("/api/v1/projects/{}/mappings", encode_path(slug)),
)
.json(&body),
&format!("project {slug} mappings"),
)
.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")),
StatusCode::CONFLICT => anyhow::Error::new(exit::Conflict(format!(
"{what} failed ({status}): {message}"
))),
StatusCode::PRECONDITION_FAILED => exit::stale(message),
_ => 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
}