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, CreateProject, CreatedKey, DocDetail, DocSummary,
9    EventsResponse, JoinNeeded, KeyInfo, Level, ListResponse, Mapping, MappingList, PatchDoc,
10    ProjectInfo, ProjectList, PushResponse, SayOut, SayRequest, WhoAmI,
11};
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14use url::Url;
15
16use crate::config::{Settings, origin};
17use crate::exit;
18
19pub const USER_AGENT: &str = concat!("semora-cli/", env!("CARGO_PKG_VERSION"));
20
21#[derive(Clone)]
22pub struct Client {
23    pub base: Url,
24    key: String,
25    http: reqwest::Client,
26}
27
28/// Query parameters for a push.
29#[derive(Debug, Default, Clone, Serialize)]
30pub struct PushQuery {
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub title: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub tags: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub message: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub entry: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub meta: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub filename: Option<String>,
43    /// Push by key: the project id (with `path`).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub project: Option<String>,
46    /// Push by key: the path inside the project.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub path: Option<String>,
49    /// An older document to carry into the project.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub adopt: Option<String>,
52    /// Where the push came from: machine and git branch.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub origin: Option<String>,
55}
56
57#[derive(Debug, Default, Clone, Serialize)]
58pub struct ListQuery {
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub q: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub tag: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub kind: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub project: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub limit: Option<u32>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub cursor: Option<String>,
71}
72
73pub fn http_client() -> anyhow::Result<reqwest::Client> {
74    Ok(reqwest::Client::builder()
75        .user_agent(USER_AGENT)
76        .connect_timeout(Duration::from_secs(15))
77        .build()?)
78}
79
80pub fn no_server_error() -> anyhow::Error {
81    anyhow!("no server configured: set SEMORA_URL (environment or .env), or pass --server")
82}
83
84/// A 404 from the projects API: the server predates projects.
85fn no_projects_api() -> anyhow::Error {
86    exit::not_found(
87        "this server has no projects; it may be older than this CLI. Update the server.",
88    )
89}
90
91/// What `POST /api/v1/projects` did.
92pub enum ProjectCreated {
93    Created(ProjectInfo),
94    Linked(ProjectInfo),
95    /// Someone else has that id.
96    Taken,
97    /// A project you collaborate on, and you didn't pass `--join`.
98    JoinNeeded {
99        owner: String,
100        level: Level,
101    },
102}
103
104impl Client {
105    pub fn new(settings: &Settings) -> anyhow::Result<Client> {
106        let base = settings.server.clone().ok_or_else(no_server_error)?;
107        let key = settings.api_key.clone().ok_or_else(|| {
108            exit::auth(format!(
109                "no API key for {}: run `semora login`, set SEMORA_API_KEY, or pass --api-key",
110                origin(&base)
111            ))
112        })?;
113        Ok(Client {
114            base,
115            key,
116            http: http_client()?,
117        })
118    }
119
120    pub fn key(&self) -> &str {
121        &self.key
122    }
123
124    pub fn origin(&self) -> String {
125        origin(&self.base)
126    }
127
128    pub fn url(&self, path: &str) -> Url {
129        self.base.join(path).expect("API paths are valid")
130    }
131
132    fn request(&self, method: Method, path: &str) -> RequestBuilder {
133        self.http
134            .request(method, self.url(path))
135            .bearer_auth(&self.key)
136    }
137
138    async fn send(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<Response> {
139        let resp = rb
140            .send()
141            .await
142            .with_context(|| format!("could not reach {}", self.origin()))?;
143        check(resp, what).await
144    }
145
146    async fn json<T: DeserializeOwned>(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<T> {
147        let resp = self.send(rb, what).await?;
148        resp.json()
149            .await
150            .with_context(|| format!("reading the {what} response"))
151    }
152
153    pub async fn whoami(&self) -> anyhow::Result<WhoAmI> {
154        self.json(self.request(Method::GET, "/api/v1/whoami"), "whoami")
155            .await
156    }
157
158    pub async fn push(
159        &self,
160        id: Option<&str>,
161        body: Vec<u8>,
162        content_type: &str,
163        q: &PushQuery,
164    ) -> anyhow::Result<PushResponse> {
165        let rb = match id {
166            Some(id) => self.request(Method::PUT, &format!("/api/v1/docs/{id}")),
167            None => self.request(Method::POST, "/api/v1/docs"),
168        };
169        let rb = rb
170            .query(q)
171            .header(reqwest::header::CONTENT_TYPE, content_type)
172            .timeout(Duration::from_secs(300))
173            .body(body);
174        self.json(rb, "push").await
175    }
176
177    pub async fn doc(&self, id: &str) -> anyhow::Result<DocDetail> {
178        self.json(
179            self.request(Method::GET, &format!("/api/v1/docs/{id}")),
180            &format!("document {id}"),
181        )
182        .await
183    }
184
185    pub async fn list(&self, q: &ListQuery) -> anyhow::Result<ListResponse> {
186        self.json(self.request(Method::GET, "/api/v1/docs").query(q), "list")
187            .await
188    }
189
190    pub async fn raw(
191        &self,
192        id: &str,
193        path: Option<&str>,
194        rev: Option<i64>,
195    ) -> anyhow::Result<Vec<u8>> {
196        let p = match path {
197            Some(p) if !p.is_empty() => format!("/api/v1/docs/{id}/raw/{}", encode_path(p)),
198            _ => format!("/api/v1/docs/{id}/raw"),
199        };
200        let mut rb = self.request(Method::GET, &p);
201        if let Some(r) = rev {
202            rb = rb.query(&[("rev", r)]);
203        }
204        let resp = self.send(rb, &format!("document {id}")).await?;
205        Ok(resp.bytes().await?.to_vec())
206    }
207
208    pub async fn patch(&self, id: &str, p: &PatchDoc) -> anyhow::Result<DocSummary> {
209        self.json(
210            self.request(Method::PATCH, &format!("/api/v1/docs/{id}"))
211                .json(p),
212            &format!("document {id}"),
213        )
214        .await
215    }
216
217    pub async fn delete(&self, id: &str) -> anyhow::Result<()> {
218        self.send(
219            self.request(Method::DELETE, &format!("/api/v1/docs/{id}")),
220            &format!("document {id}"),
221        )
222        .await?;
223        Ok(())
224    }
225
226    pub async fn say(&self, id: &str, text: &str, reply_to: Option<i64>) -> anyhow::Result<SayOut> {
227        let body = SayRequest {
228            text: text.to_string(),
229            reply_to,
230        };
231        self.json(
232            self.request(Method::POST, &format!("/api/v1/docs/{id}/events"))
233                .json(&body),
234            &format!("document {id}"),
235        )
236        .await
237    }
238
239    pub async fn events(
240        &self,
241        id: &str,
242        after: Option<i64>,
243        wait: u64,
244        kinds: Option<&str>,
245    ) -> anyhow::Result<EventsResponse> {
246        let mut rb = self
247            .request(Method::GET, &format!("/api/v1/docs/{id}/events"))
248            .query(&[("wait", wait)])
249            .timeout(Duration::from_secs(wait + 30));
250        if let Some(a) = after {
251            rb = rb.query(&[("after", a)]);
252        }
253        if let Some(k) = kinds {
254            rb = rb.query(&[("kinds", k)]);
255        }
256        self.json(rb, &format!("document {id}")).await
257    }
258
259    pub async fn download(&self, url: &str) -> anyhow::Result<Vec<u8>> {
260        let rb = self.http.get(url).bearer_auth(&self.key);
261        let resp = self.send(rb, "attachment").await?;
262        Ok(resp.bytes().await?.to_vec())
263    }
264
265    pub async fn keys(&self) -> anyhow::Result<Vec<KeyInfo>> {
266        self.json(self.request(Method::GET, "/api/v1/keys"), "keys")
267            .await
268    }
269
270    pub async fn create_key(&self, req: &CreateKeyRequest) -> anyhow::Result<CreatedKey> {
271        self.json(self.request(Method::POST, "/api/v1/keys").json(req), "keys")
272            .await
273    }
274
275    /// The document a project path means (exact path, or the bundle that holds it).
276    pub async fn doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
277        // The id goes into the URL as it is, so it must be one.
278        if !crate::project_link::valid_slug(project) {
279            anyhow::bail!("`{project}` is not a project id");
280        }
281        let resp = self
282            .request(
283                Method::GET,
284                &format!("/api/v1/projects/{project}/docs/{}", encode_path(path)),
285            )
286            .send()
287            .await
288            .with_context(|| format!("could not reach {}", self.origin()))?;
289        if resp.status() == StatusCode::NOT_FOUND {
290            // The server says when the path is a folder; otherwise nothing is there.
291            let message = resp
292                .json::<ApiErrorBody>()
293                .await
294                .map(|b| b.message)
295                .unwrap_or_default();
296            return Err(exit::not_found(if message.contains("is a folder") {
297                message
298            } else {
299                format!("nothing at `{path}` in {project}, or no such project")
300            }));
301        }
302        check(resp, &format!("{project}:{path}"))
303            .await?
304            .json()
305            .await
306            .with_context(|| format!("reading the {project}:{path} response"))
307    }
308
309    pub async fn projects(&self) -> anyhow::Result<Vec<ProjectInfo>> {
310        let list: ProjectList = self
311            .json(self.request(Method::GET, "/api/v1/projects"), "projects")
312            .await
313            .map_err(|e| {
314                if exit::is_not_found(&e) {
315                    no_projects_api()
316                } else {
317                    e
318                }
319            })?;
320        Ok(list.projects)
321    }
322
323    pub async fn create_project(&self, req: &CreateProject) -> anyhow::Result<ProjectCreated> {
324        let rb = self.request(Method::POST, "/api/v1/projects").json(req);
325        let resp = rb
326            .send()
327            .await
328            .with_context(|| format!("could not reach {}", self.origin()))?;
329        if resp.status() == StatusCode::CONFLICT {
330            let text = resp.text().await.unwrap_or_default();
331            return Ok(match serde_json::from_str::<JoinNeeded>(&text) {
332                Ok(j) if j.error == "join_needed" => ProjectCreated::JoinNeeded {
333                    owner: j.owner,
334                    level: j.level,
335                },
336                _ => ProjectCreated::Taken,
337            });
338        }
339        if resp.status() == StatusCode::NOT_FOUND {
340            return Err(no_projects_api());
341        }
342        let created = resp.status() == StatusCode::CREATED;
343        let resp = check(resp, "project").await?;
344        let info: ProjectInfo = resp.json().await.context("reading the project response")?;
345        Ok(if created {
346            ProjectCreated::Created(info)
347        } else {
348            ProjectCreated::Linked(info)
349        })
350    }
351
352    pub async fn mappings(&self, slug: &str) -> anyhow::Result<MappingList> {
353        self.json(
354            self.request(
355                Method::GET,
356                &format!("/api/v1/projects/{}/mappings", encode_path(slug)),
357            ),
358            &format!("project {slug} mappings"),
359        )
360        .await
361    }
362
363    pub async fn set_mappings(&self, slug: &str, list: &[Mapping]) -> anyhow::Result<MappingList> {
364        let body = MappingList {
365            mappings: list.to_vec(),
366        };
367        self.json(
368            self.request(
369                Method::PUT,
370                &format!("/api/v1/projects/{}/mappings", encode_path(slug)),
371            )
372            .json(&body),
373            &format!("project {slug} mappings"),
374        )
375        .await
376    }
377
378    pub async fn revoke_key(&self, id: i64) -> anyhow::Result<()> {
379        self.send(
380            self.request(Method::DELETE, &format!("/api/v1/keys/{id}")),
381            &format!("key {id}"),
382        )
383        .await?;
384        Ok(())
385    }
386}
387
388/// Maps HTTP failures to exit codes: 401/403 → 3, 404 → 4.
389pub async fn check(resp: Response, what: &str) -> anyhow::Result<Response> {
390    let status = resp.status();
391    if status.is_success() {
392        return Ok(resp);
393    }
394    let text = resp.text().await.unwrap_or_default();
395    let message = serde_json::from_str::<ApiErrorBody>(&text)
396        .map(|b| b.message)
397        .unwrap_or_else(|_| text.trim().to_string());
398    let message = if message.is_empty() {
399        status.to_string()
400    } else {
401        message
402    };
403    Err(match status {
404        StatusCode::UNAUTHORIZED => exit::auth(format!(
405            "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
406        )),
407        StatusCode::FORBIDDEN => exit::auth(message),
408        StatusCode::NOT_FOUND => exit::not_found(format!("{what}: not found")),
409        StatusCode::CONFLICT => anyhow::Error::new(exit::Conflict(format!(
410            "{what} failed ({status}): {message}"
411        ))),
412        _ => anyhow!("{what} failed ({status}): {message}"),
413    })
414}
415
416pub fn encode_path(path: &str) -> String {
417    let mut out = String::with_capacity(path.len());
418    for b in path.bytes() {
419        if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
420            out.push(b as char);
421        } else {
422            out.push_str(&format!("%{b:02X}"));
423        }
424    }
425    out
426}