1use std::time::Duration;
4
5use anyhow::{Context, anyhow};
6use reqwest::{Method, RequestBuilder, Response, StatusCode};
7use semora_proto::{
8 ApiErrorBody, CreateKeyRequest, CreatedKey, DocDetail, DocSummary, EventsResponse, KeyInfo,
9 ListResponse, PatchDoc, PushResponse, SayOut, SayRequest, WhoAmI,
10};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use url::Url;
14
15use crate::config::{Settings, origin};
16use crate::exit;
17
18pub const USER_AGENT: &str = concat!("semora-cli/", env!("CARGO_PKG_VERSION"));
19
20#[derive(Clone)]
21pub struct Client {
22 pub base: Url,
23 key: String,
24 http: reqwest::Client,
25}
26
27#[derive(Debug, Default, Clone, Serialize)]
29pub struct PushQuery {
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub title: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub tags: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub message: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub entry: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub meta: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub filename: Option<String>,
42}
43
44#[derive(Debug, Default, Clone, Serialize)]
45pub struct ListQuery {
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub q: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub tag: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub kind: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub limit: Option<u32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub cursor: Option<String>,
56}
57
58pub fn http_client() -> anyhow::Result<reqwest::Client> {
59 Ok(reqwest::Client::builder()
60 .user_agent(USER_AGENT)
61 .connect_timeout(Duration::from_secs(15))
62 .build()?)
63}
64
65pub fn no_server_error() -> anyhow::Error {
66 anyhow!("no server configured: set SEMORA_URL (environment or .env), or pass --server")
67}
68
69impl Client {
70 pub fn new(settings: &Settings) -> anyhow::Result<Client> {
71 let base = settings.server.clone().ok_or_else(no_server_error)?;
72 let key = settings.api_key.clone().ok_or_else(|| {
73 exit::auth(format!(
74 "no API key for {}: run `semora login`, set SEMORA_API_KEY, or pass --api-key",
75 origin(&base)
76 ))
77 })?;
78 Ok(Client {
79 base,
80 key,
81 http: http_client()?,
82 })
83 }
84
85 pub fn key(&self) -> &str {
86 &self.key
87 }
88
89 pub fn origin(&self) -> String {
90 origin(&self.base)
91 }
92
93 pub fn url(&self, path: &str) -> Url {
94 self.base.join(path).expect("API paths are valid")
95 }
96
97 fn request(&self, method: Method, path: &str) -> RequestBuilder {
98 self.http
99 .request(method, self.url(path))
100 .bearer_auth(&self.key)
101 }
102
103 async fn send(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<Response> {
104 let resp = rb
105 .send()
106 .await
107 .with_context(|| format!("could not reach {}", self.origin()))?;
108 check(resp, what).await
109 }
110
111 async fn json<T: DeserializeOwned>(&self, rb: RequestBuilder, what: &str) -> anyhow::Result<T> {
112 let resp = self.send(rb, what).await?;
113 resp.json()
114 .await
115 .with_context(|| format!("reading the {what} response"))
116 }
117
118 pub async fn whoami(&self) -> anyhow::Result<WhoAmI> {
119 self.json(self.request(Method::GET, "/api/v1/whoami"), "whoami")
120 .await
121 }
122
123 pub async fn push(
124 &self,
125 id: Option<&str>,
126 body: Vec<u8>,
127 content_type: &str,
128 q: &PushQuery,
129 ) -> anyhow::Result<PushResponse> {
130 let rb = match id {
131 Some(id) => self.request(Method::PUT, &format!("/api/v1/docs/{id}")),
132 None => self.request(Method::POST, "/api/v1/docs"),
133 };
134 let rb = rb
135 .query(q)
136 .header(reqwest::header::CONTENT_TYPE, content_type)
137 .timeout(Duration::from_secs(300))
138 .body(body);
139 self.json(rb, "push").await
140 }
141
142 pub async fn doc(&self, id: &str) -> anyhow::Result<DocDetail> {
143 self.json(
144 self.request(Method::GET, &format!("/api/v1/docs/{id}")),
145 &format!("document {id}"),
146 )
147 .await
148 }
149
150 pub async fn list(&self, q: &ListQuery) -> anyhow::Result<ListResponse> {
151 self.json(self.request(Method::GET, "/api/v1/docs").query(q), "list")
152 .await
153 }
154
155 pub async fn raw(
156 &self,
157 id: &str,
158 path: Option<&str>,
159 rev: Option<i64>,
160 ) -> anyhow::Result<Vec<u8>> {
161 let p = match path {
162 Some(p) if !p.is_empty() => format!("/api/v1/docs/{id}/raw/{}", encode_path(p)),
163 _ => format!("/api/v1/docs/{id}/raw"),
164 };
165 let mut rb = self.request(Method::GET, &p);
166 if let Some(r) = rev {
167 rb = rb.query(&[("rev", r)]);
168 }
169 let resp = self.send(rb, &format!("document {id}")).await?;
170 Ok(resp.bytes().await?.to_vec())
171 }
172
173 pub async fn patch(&self, id: &str, p: &PatchDoc) -> anyhow::Result<DocSummary> {
174 self.json(
175 self.request(Method::PATCH, &format!("/api/v1/docs/{id}"))
176 .json(p),
177 &format!("document {id}"),
178 )
179 .await
180 }
181
182 pub async fn delete(&self, id: &str) -> anyhow::Result<()> {
183 self.send(
184 self.request(Method::DELETE, &format!("/api/v1/docs/{id}")),
185 &format!("document {id}"),
186 )
187 .await?;
188 Ok(())
189 }
190
191 pub async fn say(&self, id: &str, text: &str, reply_to: Option<i64>) -> anyhow::Result<SayOut> {
192 let body = SayRequest {
193 text: text.to_string(),
194 reply_to,
195 };
196 self.json(
197 self.request(Method::POST, &format!("/api/v1/docs/{id}/events"))
198 .json(&body),
199 &format!("document {id}"),
200 )
201 .await
202 }
203
204 pub async fn events(
205 &self,
206 id: &str,
207 after: Option<i64>,
208 wait: u64,
209 kinds: Option<&str>,
210 ) -> anyhow::Result<EventsResponse> {
211 let mut rb = self
212 .request(Method::GET, &format!("/api/v1/docs/{id}/events"))
213 .query(&[("wait", wait)])
214 .timeout(Duration::from_secs(wait + 30));
215 if let Some(a) = after {
216 rb = rb.query(&[("after", a)]);
217 }
218 if let Some(k) = kinds {
219 rb = rb.query(&[("kinds", k)]);
220 }
221 self.json(rb, &format!("document {id}")).await
222 }
223
224 pub async fn download(&self, url: &str) -> anyhow::Result<Vec<u8>> {
225 let rb = self.http.get(url).bearer_auth(&self.key);
226 let resp = self.send(rb, "attachment").await?;
227 Ok(resp.bytes().await?.to_vec())
228 }
229
230 pub async fn keys(&self) -> anyhow::Result<Vec<KeyInfo>> {
231 self.json(self.request(Method::GET, "/api/v1/keys"), "keys")
232 .await
233 }
234
235 pub async fn create_key(&self, req: &CreateKeyRequest) -> anyhow::Result<CreatedKey> {
236 self.json(self.request(Method::POST, "/api/v1/keys").json(req), "keys")
237 .await
238 }
239
240 pub async fn revoke_key(&self, id: i64) -> anyhow::Result<()> {
241 self.send(
242 self.request(Method::DELETE, &format!("/api/v1/keys/{id}")),
243 &format!("key {id}"),
244 )
245 .await?;
246 Ok(())
247 }
248}
249
250pub async fn check(resp: Response, what: &str) -> anyhow::Result<Response> {
252 let status = resp.status();
253 if status.is_success() {
254 return Ok(resp);
255 }
256 let text = resp.text().await.unwrap_or_default();
257 let message = serde_json::from_str::<ApiErrorBody>(&text)
258 .map(|b| b.message)
259 .unwrap_or_else(|_| text.trim().to_string());
260 let message = if message.is_empty() {
261 status.to_string()
262 } else {
263 message
264 };
265 Err(match status {
266 StatusCode::UNAUTHORIZED => exit::auth(format!(
267 "{message} (the API key was rejected; run `semora login` or check SEMORA_API_KEY)"
268 )),
269 StatusCode::FORBIDDEN => exit::auth(message),
270 StatusCode::NOT_FOUND => exit::not_found(format!("{what}: not found")),
271 _ => anyhow!("{what} failed ({status}): {message}"),
272 })
273}
274
275pub fn encode_path(path: &str) -> String {
276 let mut out = String::with_capacity(path.len());
277 for b in path.bytes() {
278 if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
279 out.push(b as char);
280 } else {
281 out.push_str(&format!("%{b:02X}"));
282 }
283 }
284 out
285}