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
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 pub async fn doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
324 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 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
435pub 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}