Skip to main content

semora_cli/
client.rs

1//! The HTTP API client.
2
3use std::time::Duration;
4
5use anyhow::{Context, anyhow};
6use reqwest::{Method, RequestBuilder, Response, StatusCode};
7use semora_proto::{
8    ApiErrorBody, CreateKeyRequest, CreatedKey, DocDetail, DocSummary, EventsResponse, KeyInfo,
9    ListResponse, PatchDoc, PushResponse, SayOut, SayRequest, WhoAmI,
10};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use url::Url;
14
15use crate::config::{Settings, origin};
16use crate::exit;
17
18pub const USER_AGENT: &str = concat!("semora-cli/", env!("CARGO_PKG_VERSION"));
19
20#[derive(Clone)]
21pub struct Client {
22    pub base: Url,
23    key: String,
24    http: reqwest::Client,
25}
26
27/// Query parameters for a push.
28#[derive(Debug, Default, Clone, Serialize)]
29pub struct PushQuery {
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub title: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub tags: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub message: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub entry: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub meta: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub filename: Option<String>,
42}
43
44#[derive(Debug, Default, Clone, Serialize)]
45pub struct ListQuery {
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub q: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub tag: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub kind: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub limit: Option<u32>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub cursor: Option<String>,
56}
57
58pub fn http_client() -> anyhow::Result<reqwest::Client> {
59    Ok(reqwest::Client::builder()
60        .user_agent(USER_AGENT)
61        .connect_timeout(Duration::from_secs(15))
62        .build()?)
63}
64
65pub fn no_server_error() -> anyhow::Error {
66    anyhow!("no server configured: set SEMORA_URL (environment or .env), or pass --server")
67}
68
69impl Client {
70    pub fn new(settings: &Settings) -> anyhow::Result<Client> {
71        let base = settings.server.clone().ok_or_else(no_server_error)?;
72        let key = settings.api_key.clone().ok_or_else(|| {
73            exit::auth(format!(
74                "no API key for {}: run `semora login`, set SEMORA_API_KEY, or pass --api-key",
75                origin(&base)
76            ))
77        })?;
78        Ok(Client {
79            base,
80            key,
81            http: http_client()?,
82        })
83    }
84
85    pub fn key(&self) -> &str {
86        &self.key
87    }
88
89    pub fn origin(&self) -> String {
90        origin(&self.base)
91    }
92
93    pub fn url(&self, path: &str) -> Url {
94        self.base.join(path).expect("API paths are valid")
95    }
96
97    fn request(&self, method: Method, path: &str) -> RequestBuilder {
98        self.http
99            .request(method, self.url(path))
100            .bearer_auth(&self.key)
101    }
102
103    async fn send(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<Response> {
104        let resp = rb
105            .send()
106            .await
107            .with_context(|| format!("could not reach {}", self.origin()))?;
108        check(resp, what).await
109    }
110
111    async fn json<T: DeserializeOwned>(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<T> {
112        let resp = self.send(rb, what).await?;
113        resp.json()
114            .await
115            .with_context(|| format!("reading the {what} response"))
116    }
117
118    pub async fn whoami(&self) -> anyhow::Result<WhoAmI> {
119        self.json(self.request(Method::GET, "/api/v1/whoami"), "whoami")
120            .await
121    }
122
123    pub async fn push(
124        &self,
125        id: Option<&str>,
126        body: Vec<u8>,
127        content_type: &str,
128        q: &PushQuery,
129    ) -> anyhow::Result<PushResponse> {
130        let rb = match id {
131            Some(id) => self.request(Method::PUT, &format!("/api/v1/docs/{id}")),
132            None => self.request(Method::POST, "/api/v1/docs"),
133        };
134        let rb = rb
135            .query(q)
136            .header(reqwest::header::CONTENT_TYPE, content_type)
137            .timeout(Duration::from_secs(300))
138            .body(body);
139        self.json(rb, "push").await
140    }
141
142    pub async fn doc(&self, id: &str) -> anyhow::Result<DocDetail> {
143        self.json(
144            self.request(Method::GET, &format!("/api/v1/docs/{id}")),
145            &format!("document {id}"),
146        )
147        .await
148    }
149
150    pub async fn list(&self, q: &ListQuery) -> anyhow::Result<ListResponse> {
151        self.json(self.request(Method::GET, "/api/v1/docs").query(q), "list")
152            .await
153    }
154
155    pub async fn raw(
156        &self,
157        id: &str,
158        path: Option<&str>,
159        rev: Option<i64>,
160    ) -> anyhow::Result<Vec<u8>> {
161        let p = match path {
162            Some(p) if !p.is_empty() => format!("/api/v1/docs/{id}/raw/{}", encode_path(p)),
163            _ => format!("/api/v1/docs/{id}/raw"),
164        };
165        let mut rb = self.request(Method::GET, &p);
166        if let Some(r) = rev {
167            rb = rb.query(&[("rev", r)]);
168        }
169        let resp = self.send(rb, &format!("document {id}")).await?;
170        Ok(resp.bytes().await?.to_vec())
171    }
172
173    pub async fn patch(&self, id: &str, p: &PatchDoc) -> anyhow::Result<DocSummary> {
174        self.json(
175            self.request(Method::PATCH, &format!("/api/v1/docs/{id}"))
176                .json(p),
177            &format!("document {id}"),
178        )
179        .await
180    }
181
182    pub async fn delete(&self, id: &str) -> anyhow::Result<()> {
183        self.send(
184            self.request(Method::DELETE, &format!("/api/v1/docs/{id}")),
185            &format!("document {id}"),
186        )
187        .await?;
188        Ok(())
189    }
190
191    pub async fn say(&self, id: &str, text: &str, reply_to: Option<i64>) -> anyhow::Result<SayOut> {
192        let body = SayRequest {
193            text: text.to_string(),
194            reply_to,
195        };
196        self.json(
197            self.request(Method::POST, &format!("/api/v1/docs/{id}/events"))
198                .json(&body),
199            &format!("document {id}"),
200        )
201        .await
202    }
203
204    pub async fn events(
205        &self,
206        id: &str,
207        after: Option<i64>,
208        wait: u64,
209        kinds: Option<&str>,
210    ) -> anyhow::Result<EventsResponse> {
211        let mut rb = self
212            .request(Method::GET, &format!("/api/v1/docs/{id}/events"))
213            .query(&[("wait", wait)])
214            .timeout(Duration::from_secs(wait + 30));
215        if let Some(a) = after {
216            rb = rb.query(&[("after", a)]);
217        }
218        if let Some(k) = kinds {
219            rb = rb.query(&[("kinds", k)]);
220        }
221        self.json(rb, &format!("document {id}")).await
222    }
223
224    pub async fn download(&self, url: &str) -> anyhow::Result<Vec<u8>> {
225        let rb = self.http.get(url).bearer_auth(&self.key);
226        let resp = self.send(rb, "attachment").await?;
227        Ok(resp.bytes().await?.to_vec())
228    }
229
230    pub async fn keys(&self) -> anyhow::Result<Vec<KeyInfo>> {
231        self.json(self.request(Method::GET, "/api/v1/keys"), "keys")
232            .await
233    }
234
235    pub async fn create_key(&self, req: &CreateKeyRequest) -> anyhow::Result<CreatedKey> {
236        self.json(self.request(Method::POST, "/api/v1/keys").json(req), "keys")
237            .await
238    }
239
240    pub async fn revoke_key(&self, id: i64) -> anyhow::Result<()> {
241        self.send(
242            self.request(Method::DELETE, &format!("/api/v1/keys/{id}")),
243            &format!("key {id}"),
244        )
245        .await?;
246        Ok(())
247    }
248}
249
250/// Maps HTTP failures to exit codes: 401/403 → 3, 404 → 4.
251pub async fn check(resp: Response, what: &str) -> anyhow::Result<Response> {
252    let status = resp.status();
253    if status.is_success() {
254        return Ok(resp);
255    }
256    let text = resp.text().await.unwrap_or_default();
257    let message = serde_json::from_str::<ApiErrorBody>(&text)
258        .map(|b| b.message)
259        .unwrap_or_else(|_| text.trim().to_string());
260    let message = if message.is_empty() {
261        status.to_string()
262    } else {
263        message
264    };
265    Err(match status {
266        StatusCode::UNAUTHORIZED => exit::auth(format!(
267            "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
268        )),
269        StatusCode::FORBIDDEN => exit::auth(message),
270        StatusCode::NOT_FOUND => exit::not_found(format!("{what}: not found")),
271        _ => anyhow!("{what} failed ({status}): {message}"),
272    })
273}
274
275pub fn encode_path(path: &str) -> String {
276    let mut out = String::with_capacity(path.len());
277    for b in path.bytes() {
278        if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
279            out.push(b as char);
280        } else {
281            out.push_str(&format!("%{b:02X}"));
282        }
283    }
284    out
285}