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 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
84fn 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
91pub enum ProjectCreated {
93 Created(ProjectInfo),
94 Linked(ProjectInfo),
95 Taken,
97 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 pub async fn doc_at(&self, project: &str, path: &str) -> anyhow::Result<DocSummary> {
277 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 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
388pub 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}