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, none of it in a project.
220            // (A project's documents carry its current id, which an old id the server still
221            // answers differs from.)
222            if page.docs.iter().any(|d| d.project.is_none()) {
223                anyhow::bail!(
224                    "this server has no projects; it may be older than this CLI. Update the server."
225                );
226            }
227            all.extend(page.docs);
228            match page.next_cursor {
229                Some(c) => q.cursor = Some(c),
230                None => return Ok(all),
231            }
232        }
233    }
234
235    pub async fn raw(
236        &self,
237        id: &str,
238        path: Option<&str>,
239        rev: Option<i64>,
240    ) -> anyhow::Result<Vec<u8>> {
241        let p = match path {
242            Some(p) if !p.is_empty() => format!("/api/v1/docs/{id}/raw/{}", encode_path(p)),
243            _ => format!("/api/v1/docs/{id}/raw"),
244        };
245        let mut rb = self.request(Method::GET, &p);
246        if let Some(r) = rev {
247            rb = rb.query(&[("rev", r)]);
248        }
249        let resp = self.send(rb, &format!("document {id}")).await?;
250        Ok(resp.bytes().await?.to_vec())
251    }
252
253    pub async fn patch(&self, id: &str, p: &PatchDoc) -> anyhow::Result<DocSummary> {
254        self.json(
255            self.request(Method::PATCH, &format!("/api/v1/docs/{id}"))
256                .json(p),
257            &format!("document {id}"),
258        )
259        .await
260    }
261
262    pub async fn delete(&self, id: &str) -> anyhow::Result<()> {
263        self.send(
264            self.request(Method::DELETE, &format!("/api/v1/docs/{id}")),
265            &format!("document {id}"),
266        )
267        .await?;
268        Ok(())
269    }
270
271    pub async fn say(&self, id: &str, text: &str, reply_to: Option<i64>) -> anyhow::Result<SayOut> {
272        let body = SayRequest {
273            text: text.to_string(),
274            reply_to,
275        };
276        self.json(
277            self.request(Method::POST, &format!("/api/v1/docs/{id}/events"))
278                .json(&body),
279            &format!("document {id}"),
280        )
281        .await
282    }
283
284    pub async fn events(
285        &self,
286        id: &str,
287        after: Option<i64>,
288        wait: u64,
289        kinds: Option<&str>,
290    ) -> anyhow::Result<EventsResponse> {
291        let mut rb = self
292            .request(Method::GET, &format!("/api/v1/docs/{id}/events"))
293            .query(&[("wait", wait)])
294            .timeout(Duration::from_secs(wait + 30));
295        if let Some(a) = after {
296            rb = rb.query(&[("after", a)]);
297        }
298        if let Some(k) = kinds {
299            rb = rb.query(&[("kinds", k)]);
300        }
301        self.json(rb, &format!("document {id}")).await
302    }
303
304    pub async fn download(&self, url: &str) -> anyhow::Result<Vec<u8>> {
305        let rb = self.http.get(url).bearer_auth(&self.key);
306        let resp = self.send(rb, "attachment").await?;
307        Ok(resp.bytes().await?.to_vec())
308    }
309
310    pub async fn keys(&self) -> anyhow::Result<Vec<KeyInfo>> {
311        self.json(self.request(Method::GET, "/api/v1/keys"), "keys")
312            .await
313    }
314
315    pub async fn create_key(&self, req: &CreateKeyRequest) -> anyhow::Result<CreatedKey> {
316        self.json(self.request(Method::POST, "/api/v1/keys").json(req), "keys")
317            .await
318    }
319
320    /// The document a project path means (exact path, or the bundle that holds it).
321    pub async fn doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
322        // The id goes into the URL as it is, so it must be one.
323        if !crate::project_link::valid_slug(project) {
324            anyhow::bail!("`{project}` is not a project id");
325        }
326        let resp = self
327            .request(
328                Method::GET,
329                &format!("/api/v1/projects/{project}/docs/{}", encode_path(path)),
330            )
331            .send()
332            .await
333            .with_context(|| format!("could not reach {}", self.origin()))?;
334        if resp.status() == StatusCode::NOT_FOUND {
335            // The server says when the path is a folder; otherwise nothing is there.
336            let message = resp
337                .json::<ApiErrorBody>()
338                .await
339                .map(|b| b.message)
340                .unwrap_or_default();
341            return Err(exit::not_found(if message.contains("is a folder") {
342                message
343            } else {
344                format!("nothing at `{path}` in {project}, or no such project")
345            }));
346        }
347        check(resp, &format!("{project}:{path}"))
348            .await?
349            .json()
350            .await
351            .with_context(|| format!("reading the {project}:{path} response"))
352    }
353
354    /// One project. A server that keeps an old id as an alias answers it with the project under
355    /// its current id.
356    pub async fn project(&self, slug: &str) -> anyhow::Result<ProjectInfo> {
357        // The id goes into the URL as it is, so it must be one.
358        if !crate::project_link::valid_slug(slug) {
359            anyhow::bail!("`{slug}` is not a project id");
360        }
361        self.json(
362            self.request(Method::GET, &format!("/api/v1/projects/{slug}")),
363            &format!("project {slug}"),
364        )
365        .await
366    }
367
368    pub async fn projects(&self) -> anyhow::Result<Vec<ProjectInfo>> {
369        let list: ProjectList = self
370            .json(self.request(Method::GET, "/api/v1/projects"), "projects")
371            .await
372            .map_err(|e| {
373                if exit::is_not_found(&e) {
374                    no_projects_api()
375                } else {
376                    e
377                }
378            })?;
379        Ok(list.projects)
380    }
381
382    pub async fn create_project(&self, req: &CreateProject) -> anyhow::Result<ProjectCreated> {
383        let rb = self.request(Method::POST, "/api/v1/projects").json(req);
384        let resp = rb
385            .send()
386            .await
387            .with_context(|| format!("could not reach {}", self.origin()))?;
388        if resp.status() == StatusCode::CONFLICT {
389            let text = resp.text().await.unwrap_or_default();
390            return Ok(match serde_json::from_str::<JoinNeeded>(&text) {
391                Ok(j) if j.error == "join_needed" => ProjectCreated::JoinNeeded {
392                    owner: j.owner,
393                    level: j.level,
394                },
395                _ => ProjectCreated::Taken,
396            });
397        }
398        if resp.status() == StatusCode::NOT_FOUND {
399            return Err(no_projects_api());
400        }
401        let created = resp.status() == StatusCode::CREATED;
402        let resp = check(resp, "project").await?;
403        let info: ProjectInfo = resp.json().await.context("reading the project response")?;
404        Ok(if created {
405            ProjectCreated::Created(info)
406        } else {
407            ProjectCreated::Linked(info)
408        })
409    }
410
411    pub async fn mappings(&self, slug: &str) -> anyhow::Result<MappingList> {
412        self.json(
413            self.request(
414                Method::GET,
415                &format!("/api/v1/projects/{}/mappings", encode_path(slug)),
416            ),
417            &format!("project {slug} mappings"),
418        )
419        .await
420    }
421
422    pub async fn set_mappings(&self, slug: &str, list: &[Mapping]) -> anyhow::Result<MappingList> {
423        let body = MappingList {
424            mappings: list.to_vec(),
425        };
426        self.json(
427            self.request(
428                Method::PUT,
429                &format!("/api/v1/projects/{}/mappings", encode_path(slug)),
430            )
431            .json(&body),
432            &format!("project {slug} mappings"),
433        )
434        .await
435    }
436
437    pub async fn revoke_key(&self, id: i64) -> anyhow::Result<()> {
438        self.send(
439            self.request(Method::DELETE, &format!("/api/v1/keys/{id}")),
440            &format!("key {id}"),
441        )
442        .await?;
443        Ok(())
444    }
445}
446
447/// Maps HTTP failures to exit codes: 401/403 → 3, 404 → 4, 412 → 5.
448pub async fn check(resp: Response, what: &str) -> anyhow::Result<Response> {
449    let status = resp.status();
450    if status.is_success() {
451        return Ok(resp);
452    }
453    let text = resp.text().await.unwrap_or_default();
454    let message = serde_json::from_str::<ApiErrorBody>(&text)
455        .map(|b| b.message)
456        .unwrap_or_else(|_| text.trim().to_string());
457    let message = if message.is_empty() {
458        status.to_string()
459    } else {
460        message
461    };
462    Err(match status {
463        StatusCode::UNAUTHORIZED => exit::auth(format!(
464            "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
465        )),
466        StatusCode::FORBIDDEN => exit::auth(message),
467        StatusCode::NOT_FOUND => exit::not_found(format!("{what}: not found")),
468        StatusCode::CONFLICT => anyhow::Error::new(exit::Conflict(format!(
469            "{what} failed ({status}): {message}"
470        ))),
471        StatusCode::PRECONDITION_FAILED => exit::stale(message),
472        _ => anyhow!("{what} failed ({status}): {message}"),
473    })
474}
475
476pub fn encode_path(path: &str) -> String {
477    let mut out = String::with_capacity(path.len());
478    for b in path.bytes() {
479        if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
480            out.push(b as char);
481        } else {
482            out.push_str(&format!("%{b:02X}"));
483        }
484    }
485    out
486}