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