1pub 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#[derive(Debug, thiserror::Error)]
17pub enum ClientError {
18 #[error("not found")]
19 NotFound,
20 #[error("character was changed by someone else (now at version {})", .0.version)]
22 Stale(Box<Character>),
23 #[error("{0}")]
25 Exhausted(String),
26 #[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 #[error("unexpected response from server")]
36 InvalidResponse(#[source] reqwest::Error),
37 #[error("not authorized: {0}")]
39 Unauthorized(String),
40 #[error("forbidden: {0}")]
42 Forbidden(String),
43 #[error("conflict: {0}")]
46 Conflict(String),
47 #[error("websocket error: {0}")]
48 WebSocket(String),
49}
50
51#[derive(Debug, Clone)]
53pub struct Client {
54 base_url: String,
55 http: reqwest::Client,
56}
57
58impl Client {
59 pub const MAX_RETRIES: usize = 5;
62
63 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 #[must_use]
73 pub fn with_token(mut self, token: &str) -> Self {
74 self.http = http_client(Some(token));
75 self
76 }
77
78 pub fn base_url(&self) -> &str {
80 &self.base_url
81 }
82
83 pub async fn list(&self) -> Result<Vec<CharacterSummary>, ClientError> {
85 send(self.http.get(self.url("/characters"))).await
86 }
87
88 pub async fn get(&self, id: Uuid) -> Result<Character, ClientError> {
90 send(self.http.get(self.character_url(id))).await
91 }
92
93 pub async fn create(&self, sheet: &CharacterSheet) -> Result<Character, ClientError> {
95 send(self.http.post(self.url("/characters")).json(sheet)).await
96 }
97
98 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 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 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 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 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 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
190pub(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
210pub(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}