main
rs 413 lines 13.8 KB
Raw
1 //! A small client for the Onde Inference account API at ondeinference.com.
2 //!
3 //! It signs a user in, finds or creates their app, activates it and returns the Onde Cloud
4 //! API key (`app_id:app_secret`) an agent authenticates with. Every call goes through
5 //! onde-web's own API, which adds the smbCloud client credentials on the server, so no
6 //! Onde secret ships in whatever embeds this crate.
7 //!
8 //! ```no_run
9 //! # async fn run() -> Result<(), ed_onde_account::Error> {
10 //! use ed_onde_account::{OndeAccount, SignIn};
11 //!
12 //! let account = OndeAccount::new();
13 //! if let SignIn::Ready(token) = account.sign_in("me@example.com", "password").await? {
14 //! let key = account.ensure_key(&token, "My Agent").await?;
15 //! // key.as_str() is "app_id:app_secret"
16 //! }
17 //! # Ok(())
18 //! # }
19 //! ```
20
21 use std::fmt;
22
23 use reqwest::{Method, RequestBuilder, StatusCode, Url};
24 use serde::{Deserialize, Deserializer};
25 use serde_json::{Value, json};
26
27 /// The production API host.
28 pub const DEFAULT_BASE_URL: &str = "https://ondeinference.com";
29
30 pub type Result<T, E = Error> = std::result::Result<T, E>;
31
32 #[derive(Debug, thiserror::Error)]
33 pub enum Error {
34 /// The access token is missing, expired or revoked. Sign in again.
35 #[error("not signed in, or the session expired")]
36 Unauthorized,
37 /// Sign-in was refused: the email and password don't match.
38 #[error("incorrect email or password")]
39 InvalidCredentials,
40 /// Signed in, but this account may not do that.
41 #[error("this Onde account is not allowed to do that")]
42 Forbidden,
43 /// The user's app with this name exists but is suspended, so it cannot get a key.
44 #[error("the app \"{0}\" is suspended")]
45 Suspended(String),
46 /// The API answered with another failure status.
47 #[error("Onde API error {status}: {message}")]
48 Http { status: u16, message: String },
49 /// The API answered, but not with the shape this client expects.
50 #[error("unexpected response from the Onde API: {0}")]
51 Unexpected(String),
52 /// The request never completed.
53 #[error("could not reach the Onde API: {0}")]
54 Transport(#[from] reqwest::Error),
55 }
56
57 /// Which Onde backend to talk to. Production unless a host is testing against the dev stack.
58 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59 pub enum OndeEnv {
60 #[default]
61 Production,
62 Development,
63 }
64
65 impl OndeEnv {
66 fn as_str(self) -> &'static str {
67 match self {
68 Self::Production => "production",
69 Self::Development => "development",
70 }
71 }
72 }
73
74 /// The smbCloud access token for a signed-in user. Debug output is redacted.
75 #[derive(Clone, PartialEq, Eq)]
76 pub struct AccessToken(String);
77
78 impl AccessToken {
79 /// Wrap a token, stripping surrounding whitespace and any `Bearer ` prefixes.
80 pub fn new(token: impl AsRef<str>) -> Self {
81 let mut token = token.as_ref().trim();
82 while let Some(prefix) = token.get(..7) {
83 if !prefix.eq_ignore_ascii_case("bearer ") {
84 break;
85 }
86 token = token[7..].trim_start();
87 }
88 Self(token.to_owned())
89 }
90
91 pub fn as_str(&self) -> &str {
92 &self.0
93 }
94 }
95
96 impl fmt::Debug for AccessToken {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str("AccessToken(<redacted>)")
99 }
100 }
101
102 /// An Onde Cloud API key, `app_id:app_secret`. Debug output is redacted.
103 #[derive(Clone, PartialEq, Eq)]
104 pub struct ApiKey(String);
105
106 impl ApiKey {
107 pub fn new(app_id: &str, app_secret: &str) -> Self {
108 Self(format!("{app_id}:{app_secret}"))
109 }
110
111 pub fn as_str(&self) -> &str {
112 &self.0
113 }
114 }
115
116 impl fmt::Debug for ApiKey {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str("ApiKey(<redacted>)")
119 }
120 }
121
122 /// How a sign-in attempt ended.
123 #[derive(Debug, Clone, PartialEq, Eq)]
124 pub enum SignIn {
125 Ready(AccessToken),
126 /// No account for this email.
127 NotFound,
128 /// The account exists but cannot sign in yet, for instance an unconfirmed email.
129 Incomplete {
130 error_code: Option<i64>,
131 },
132 }
133
134 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
135 #[serde(rename_all = "lowercase")]
136 pub enum AppStatus {
137 Provisioning,
138 Active,
139 Suspended,
140 #[serde(other)]
141 Unknown,
142 }
143
144 /// An Onde app, the thing an API key belongs to.
145 #[derive(Clone, Deserialize)]
146 pub struct OndeApp {
147 #[serde(deserialize_with = "string_or_number")]
148 pub id: String,
149 pub name: String,
150 pub status: AppStatus,
151 #[serde(default, deserialize_with = "opt_string_or_number")]
152 pub app_id: Option<String>,
153 pub app_secret: Option<String>,
154 }
155
156 impl fmt::Debug for OndeApp {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.debug_struct("OndeApp")
159 .field("id", &self.id)
160 .field("name", &self.name)
161 .field("status", &self.status)
162 .field(
163 "app_secret",
164 &self.app_secret.as_ref().map(|_| "<redacted>"),
165 )
166 .finish()
167 }
168 }
169
170 impl OndeApp {
171 /// The key for this app, if the secret is present.
172 pub fn api_key(&self) -> Option<ApiKey> {
173 let secret = self.app_secret.as_deref().filter(|s| !s.is_empty())?;
174 Some(ApiKey::new(
175 self.app_id.as_deref().unwrap_or(&self.id),
176 secret,
177 ))
178 }
179 }
180
181 /// The signed-in user.
182 #[derive(Debug, Clone, PartialEq, Eq)]
183 pub struct Profile {
184 pub email: String,
185 pub name: Option<String>,
186 /// Whether the account may create apps (and so keys).
187 pub can_create: bool,
188 }
189
190 #[derive(Debug, Clone)]
191 pub struct OndeAccount {
192 http: reqwest::Client,
193 base_url: Url,
194 env: OndeEnv,
195 }
196
197 impl Default for OndeAccount {
198 fn default() -> Self {
199 Self::new()
200 }
201 }
202
203 impl OndeAccount {
204 /// Production at ondeinference.com.
205 pub fn new() -> Self {
206 Self::with_base_url(DEFAULT_BASE_URL).expect("the default base URL is valid")
207 }
208
209 /// Point at another host, such as a local onde-web or a test server.
210 pub fn with_base_url(base_url: &str) -> Result<Self> {
211 let base_url = Url::parse(base_url)
212 .map_err(|e| Error::Unexpected(format!("invalid base URL: {e}")))?;
213 Ok(Self {
214 http: reqwest::Client::new(),
215 base_url,
216 env: OndeEnv::default(),
217 })
218 }
219
220 pub fn with_env(mut self, env: OndeEnv) -> Self {
221 self.env = env;
222 self
223 }
224
225 pub async fn sign_in(&self, email: &str, password: &str) -> Result<SignIn> {
226 let body = json!({ "email": email.trim(), "password": password });
227 let value = self
228 .send(self.request(Method::POST, "/api/auth/sign-in").json(&body))
229 .await
230 // There is no token to expire yet, so a 401 here is a wrong password.
231 .map_err(|e| match e {
232 Error::Unauthorized => Error::InvalidCredentials,
233 e => e,
234 })?;
235 match value.get("status").and_then(Value::as_str) {
236 Some("ready") => match value.get("accessToken").and_then(Value::as_str) {
237 Some(token) if !AccessToken::new(token).as_str().is_empty() => {
238 Ok(SignIn::Ready(AccessToken::new(token)))
239 }
240 _ => Err(Error::Unexpected(
241 "sign-in was ready without a token".into(),
242 )),
243 },
244 Some("notFound") => Ok(SignIn::NotFound),
245 Some("incomplete") => Ok(SignIn::Incomplete {
246 error_code: value.get("errorCode").and_then(Value::as_i64),
247 }),
248 _ => Err(Error::Unexpected("unknown sign-in status".into())),
249 }
250 }
251
252 pub async fn me(&self, token: &AccessToken) -> Result<Profile> {
253 let value = self
254 .send(
255 self.request(Method::GET, "/api/auth/me")
256 .bearer_auth(token.as_str()),
257 )
258 .await?;
259 let flag = |key: &str| value.get(key).and_then(Value::as_bool);
260 Ok(Profile {
261 email: value
262 .get("email")
263 .and_then(Value::as_str)
264 .unwrap_or_default()
265 .to_owned(),
266 name: value.get("name").and_then(Value::as_str).map(str::to_owned),
267 can_create: flag("can_create_gresiq_apps")
268 .or_else(|| flag("can_manage_all_gresiq_apps"))
269 .unwrap_or(false),
270 })
271 }
272
273 pub async fn apps(&self, token: &AccessToken) -> Result<Vec<OndeApp>> {
274 let value = self
275 .send(
276 self.request(Method::GET, "/api/gresiq/apps")
277 .bearer_auth(token.as_str()),
278 )
279 .await?;
280 decode(value)
281 }
282
283 pub async fn create_app(&self, token: &AccessToken, name: &str) -> Result<OndeApp> {
284 let value = self
285 .send(
286 self.request(Method::POST, "/api/gresiq/apps")
287 .bearer_auth(token.as_str())
288 .json(&json!({ "gresiq_app": { "name": name } })),
289 )
290 .await?;
291 decode(value)
292 }
293
294 /// Move a provisioning app to `active`. Onde Cloud refuses its key until then.
295 pub async fn activate(&self, token: &AccessToken, id: &str) -> Result<OndeApp> {
296 let value = self
297 .send(
298 self.request(Method::PATCH, &format!("/api/gresiq/apps/{id}"))
299 .bearer_auth(token.as_str())
300 .json(&json!({ "gresiq_app": { "status": "active" } })),
301 )
302 .await?;
303 decode(value)
304 }
305
306 /// Reuse the user's app named `name` (or create it), activate it if needed and return
307 /// its key. Calls made one after another reuse the app. Nothing makes list-then-create
308 /// atomic, so two concurrent first calls can each create one; both then list again and
309 /// settle on the same (oldest) app, so they return the same key. The extra app stays in
310 /// the account, because this client has no delete call.
311 pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result<ApiKey> {
312 let apps = self.apps(token).await?;
313 let mut app = match pick(&apps, name) {
314 Some(app) => app.clone(),
315 // Whatever is left under this name is suspended.
316 None if apps.iter().any(|a| a.name == name) => {
317 return Err(Error::Suspended(name.to_owned()));
318 }
319 None => {
320 let created = self.create_app(token, name).await?;
321 // Another caller may have created one too: settle on the same app they do.
322 let apps = self.apps(token).await?;
323 pick(&apps, name).cloned().unwrap_or(created)
324 }
325 };
326 if app.status != AppStatus::Active {
327 app = self.activate(token, &app.id).await?;
328 }
329 app.api_key()
330 .ok_or_else(|| Error::Unexpected("the app came back without a secret".into()))
331 }
332
333 fn request(&self, method: Method, path: &str) -> RequestBuilder {
334 let mut url = self.base_url.clone();
335 url.set_path(path);
336 url.query_pairs_mut().append_pair("env", self.env.as_str());
337 self.http.request(method, url)
338 }
339
340 async fn send(&self, request: RequestBuilder) -> Result<Value> {
341 let response = request.send().await?;
342 let status = response.status();
343 let text = response.text().await?;
344 let body: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
345 if status.is_success() {
346 return Ok(body);
347 }
348 Err(match status {
349 StatusCode::UNAUTHORIZED => Error::Unauthorized,
350 StatusCode::FORBIDDEN => Error::Forbidden,
351 _ => Error::Http {
352 status: status.as_u16(),
353 message: ["message", "error"]
354 .iter()
355 .find_map(|k| body.get(*k).and_then(Value::as_str))
356 .map(str::to_owned)
357 .or_else(|| {
358 body.get("errors")
359 .and_then(Value::as_array)
360 .map(|e| {
361 e.iter()
362 .filter_map(Value::as_str)
363 .collect::<Vec<_>>()
364 .join("; ")
365 })
366 .filter(|m| !m.is_empty())
367 })
368 .unwrap_or_else(|| "request failed".to_owned()),
369 },
370 })
371 }
372 }
373
374 fn decode<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T> {
375 serde_json::from_value(value).map_err(|e| Error::Unexpected(e.to_string()))
376 }
377
378 fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
379 match Value::deserialize(d)? {
380 Value::String(s) => Ok(s),
381 Value::Number(n) => Ok(n.to_string()),
382 other => Err(serde::de::Error::custom(format!(
383 "expected an id, got {other}"
384 ))),
385 }
386 }
387
388 fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
389 match Option::<Value>::deserialize(d)? {
390 None | Some(Value::Null) => Ok(None),
391 Some(Value::String(s)) => Ok(Some(s)),
392 Some(Value::Number(n)) => Ok(Some(n.to_string())),
393 Some(other) => Err(serde::de::Error::custom(format!(
394 "expected an id, got {other}"
395 ))),
396 }
397 }
398
399 /// The app every caller agrees on among those named `name`: an active one before a
400 /// provisioning one, then the lowest id (numeric when it parses). Suspended apps never win.
401 fn pick<'a>(apps: &'a [OndeApp], name: &str) -> Option<&'a OndeApp> {
402 apps.iter()
403 .filter(|a| a.name == name && a.status != AppStatus::Suspended)
404 .min_by_key(|a| {
405 let numeric = a.id.parse::<u64>().ok();
406 (
407 a.status != AppStatus::Active,
408 numeric.is_none(),
409 numeric,
410 a.id.as_str(),
411 )
412 })
413 }