1use 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
45 pub project: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub path: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub adopt: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub base: Option<String>,
55 #[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 #[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
90fn 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
97pub enum ProjectCreated {
99 Created(ProjectInfo),
100 Linked(ProjectInfo),
101 Taken,
103 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 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 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 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 pub async fn doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
322 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 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 pub async fn project(&self, slug: &str) -> anyhow::Result<ProjectInfo> {
357 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
447pub 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}