Skip to main content

edda_client/
lib.rs

1//! Typed HTTP client for the Edda services. Shared by every
2//! frontend so none of them need to know about URLs, headers or status codes.
3
4pub mod admin;
5pub mod auth;
6pub mod combat;
7pub mod monsters;
8pub mod pairing;
9
10use edda_types::{Character, CharacterAction, CharacterSheet, CharacterSummary};
11use reqwest::{RequestBuilder, Response, StatusCode, header};
12use serde::{Deserialize, de::DeserializeOwned};
13use uuid::Uuid;
14
15/// What can go wrong talking to a service.
16#[derive(Debug, thiserror::Error)]
17pub enum ClientError {
18    #[error("not found")]
19    NotFound,
20    /// The character changed on the server since this copy was fetched.
21    #[error("character was changed by someone else (now at version {})", .0.version)]
22    Stale(Box<Character>),
23    /// The character is out of the slot/uses the action needed.
24    #[error("{0}")]
25    Exhausted(String),
26    /// The server refused the request as invalid.
27    #[error("{0}")]
28    Rejected(String),
29    #[error("server error ({status}): {message}")]
30    Server { status: StatusCode, message: String },
31    #[error("could not reach server")]
32    Http(#[from] reqwest::Error),
33    /// The server answered, but not with what this client expected; usually
34    /// a client/server version mismatch.
35    #[error("unexpected response from server")]
36    InvalidResponse(#[source] reqwest::Error),
37    /// No valid credentials: not logged in, or the token expired.
38    #[error("not authorized: {0}")]
39    Unauthorized(String),
40    /// Logged in, but not allowed to do this.
41    #[error("forbidden: {0}")]
42    Forbidden(String),
43    /// Something's in the way: from the admin API, a campaign's data where
44    /// an import would put it.
45    #[error("conflict: {0}")]
46    Conflict(String),
47    #[error("websocket error: {0}")]
48    WebSocket(String),
49}
50
51/// The character service's client.
52#[derive(Debug, Clone)]
53pub struct Client {
54    base_url: String,
55    http: reqwest::Client,
56}
57
58impl Client {
59    /// How many times [`Client::modify`] re-applies a change after losing a
60    /// race with another writer before giving up.
61    pub const MAX_RETRIES: usize = 5;
62
63    /// A client for the character service at `base_url`, without a token.
64    pub fn new(base_url: impl Into<String>) -> Self {
65        Self {
66            base_url: base_url.into().trim_end_matches('/').to_string(),
67            http: http_client(None),
68        }
69    }
70
71    /// Sends `token` (from [`auth::AuthClient::login`]) with every request.
72    #[must_use]
73    pub fn with_token(mut self, token: &str) -> Self {
74        self.http = http_client(Some(token));
75        self
76    }
77
78    /// Where the service is.
79    pub fn base_url(&self) -> &str {
80        &self.base_url
81    }
82
83    /// The campaign's characters.
84    pub async fn list(&self) -> Result<Vec<CharacterSummary>, ClientError> {
85        send(self.http.get(self.url("/characters"))).await
86    }
87
88    /// One character.
89    pub async fn get(&self, id: Uuid) -> Result<Character, ClientError> {
90        send(self.http.get(self.character_url(id))).await
91    }
92
93    /// Creates a character from `sheet`.
94    pub async fn create(&self, sheet: &CharacterSheet) -> Result<Character, ClientError> {
95        send(self.http.post(self.url("/characters")).json(sheet)).await
96    }
97
98    /// Replaces the sheet, failing with [`ClientError::Stale`] if the server's
99    /// copy is no longer at `version`.
100    pub async fn replace(
101        &self,
102        id: Uuid,
103        version: u64,
104        sheet: &CharacterSheet,
105    ) -> Result<Character, ClientError> {
106        let request = self
107            .http
108            .put(self.character_url(id))
109            .header(header::IF_MATCH, format!("\"{version}\""))
110            .json(sheet);
111        send(request).await
112    }
113
114    /// Fetches the character, applies `change` and saves it. If someone else
115    /// saved in between, `change` is re-applied to their newer copy, so
116    /// edits merge instead of overwriting each other. `change` may therefore
117    /// run more than once and should only express the caller's intent
118    /// ("set max HP to 50"), not depend on the previous value being fixed.
119    pub async fn modify<E>(
120        &self,
121        id: Uuid,
122        mut change: impl FnMut(&mut CharacterSheet) -> Result<(), E>,
123    ) -> Result<Character, E>
124    where
125        E: From<ClientError>,
126    {
127        let mut current = self.get(id).await?;
128        for _ in 0..Self::MAX_RETRIES {
129            let mut sheet = current.sheet.clone();
130            change(&mut sheet)?;
131            match self.replace(id, current.version, &sheet).await {
132                Err(ClientError::Stale(latest)) => current = *latest,
133                result => return Ok(result?),
134            }
135        }
136        Err(ClientError::Stale(Box::new(current)).into())
137    }
138
139    /// Deletes a character.
140    pub async fn delete(&self, id: Uuid) -> Result<(), ClientError> {
141        let response = self.http.delete(self.character_url(id)).send().await?;
142        check(response).await.map(drop)
143    }
144
145    /// Sets which player devices play the character, by key (DM devices
146    /// only).
147    pub async fn set_owners(&self, id: Uuid, owners: &[String]) -> Result<Character, ClientError> {
148        let url = format!("{}/owners", self.character_url(id));
149        send(self.http.put(url).json(owners)).await
150    }
151
152    /// Applies an action to a character.
153    pub async fn act(&self, id: Uuid, action: &CharacterAction) -> Result<Character, ClientError> {
154        let url = format!("{}/actions", self.character_url(id));
155        send(self.http.post(url).json(action)).await
156    }
157
158    /// Like [`Client::act`], but only once per `key`: sending it again
159    /// with the same key changes nothing, so it's safe to retry.
160    pub async fn act_once(
161        &self,
162        id: Uuid,
163        action: &CharacterAction,
164        key: Uuid,
165    ) -> Result<Character, ClientError> {
166        let url = format!("{}/actions", self.character_url(id));
167        let request = self
168            .http
169            .post(url)
170            .header("Idempotency-Key", key.to_string())
171            .json(action);
172        send(request).await
173    }
174
175    fn url(&self, path: &str) -> String {
176        format!("{}{path}", self.base_url)
177    }
178
179    fn character_url(&self, id: Uuid) -> String {
180        self.url(&format!("/characters/{id}"))
181    }
182}
183
184#[derive(Deserialize)]
185struct ErrorBody {
186    error: String,
187    current: Option<Box<Character>>,
188}
189
190/// An HTTP client that sends `token` as a bearer token on every request.
191pub(crate) fn http_client(token: Option<&str>) -> reqwest::Client {
192    let mut headers = header::HeaderMap::new();
193    if let Some(token) = token {
194        let mut value = header::HeaderValue::from_str(&format!("Bearer {token}"))
195            .expect("tokens are plain ASCII");
196        value.set_sensitive(true);
197        headers.insert(header::AUTHORIZATION, value);
198    }
199    reqwest::Client::builder()
200        .default_headers(headers)
201        .build()
202        .expect("HTTP client builds")
203}
204
205pub(crate) async fn send<T: DeserializeOwned>(request: RequestBuilder) -> Result<T, ClientError> {
206    let response = check(request.send().await?).await?;
207    response.json().await.map_err(ClientError::InvalidResponse)
208}
209
210/// Turns non-success responses into the matching [`ClientError`].
211pub(crate) async fn check(response: Response) -> Result<Response, ClientError> {
212    let status = response.status();
213    if status.is_success() {
214        return Ok(response);
215    }
216    let body = response.text().await.unwrap_or_default();
217    let (message, current) = match serde_json::from_str::<ErrorBody>(&body) {
218        Ok(e) => (e.error, e.current),
219        Err(_) => (body, None),
220    };
221    Err(match status {
222        StatusCode::NOT_FOUND => ClientError::NotFound,
223        StatusCode::UNAUTHORIZED => ClientError::Unauthorized(message),
224        StatusCode::FORBIDDEN => ClientError::Forbidden(message),
225        StatusCode::PRECONDITION_FAILED => match current {
226            Some(current) => ClientError::Stale(current),
227            None => ClientError::Server { status, message },
228        },
229        StatusCode::CONFLICT => ClientError::Exhausted(message),
230        s if s.is_client_error() => ClientError::Rejected(message),
231        _ => ClientError::Server { status, message },
232    })
233}