1use axum::{
2 Json, Router,
3 extract::{Path, State},
4 http::{HeaderMap, StatusCode, header},
5 response::IntoResponse,
6 routing::{get, post, put},
7};
8use edda_auth::{PublicKey, server::Authenticated};
9use edda_types::{Character, CharacterAction, CharacterSheet, CharacterSummary};
10use uuid::Uuid;
11
12use edda_server::openapi::ErrorBody;
13
14use crate::{AppState, error::ApiError};
15
16pub fn router() -> Router<AppState> {
18 Router::new()
19 .route("/health", get(edda_server::health))
20 .route("/characters", get(list).post(create))
21 .route("/characters/{id}", get(fetch).put(replace).delete(remove))
22 .route("/characters/{id}/actions", post(apply_action))
23 .route("/characters/{id}/owners", put(set_owners))
24}
25
26#[derive(utoipa::ToSchema)]
28pub struct Stale {
29 pub error: String,
30 pub current: Character,
31}
32
33pub fn etag(character: &Character) -> String {
35 format!("\"{}\"", character.version)
36}
37
38fn tagged(character: Character) -> impl IntoResponse {
41 ([(header::ETAG, etag(&character))], Json(character))
42}
43
44fn expected_version(headers: &HeaderMap) -> Result<Option<u64>, ApiError> {
47 let value = headers
48 .get(header::IF_MATCH)
49 .ok_or(ApiError::PreconditionRequired)?
50 .to_str()
51 .map_err(|_| ApiError::BadRequest("If-Match is not valid text".into()))?
52 .trim();
53 if value == "*" {
54 return Ok(None);
55 }
56 value
57 .strip_prefix('"')
58 .and_then(|v| v.strip_suffix('"'))
59 .and_then(|v| v.parse().ok())
60 .map(Some)
61 .ok_or_else(|| {
62 ApiError::BadRequest(format!(
63 "If-Match must be a single ETag like \"3\" or *, got {value}"
64 ))
65 })
66}
67
68#[utoipa::path(
69 get,
70 path = "/characters",
71 tag = "characters",
72 responses((status = 200, description = "The campaign's characters.", body = Vec<CharacterSummary>))
73)]
74async fn list(
75 State(state): State<AppState>,
76 Authenticated(claims): Authenticated,
77) -> Result<Json<Vec<CharacterSummary>>, ApiError> {
78 let characters = state.characters.list((&claims).into()).await?;
79 Ok(Json(
80 characters.iter().map(CharacterSummary::from).collect(),
81 ))
82}
83
84#[utoipa::path(
85 post,
86 path = "/characters",
87 tag = "characters",
88 request_body = CharacterSheet,
89 responses(
90 (status = 201, description = "Created.", body = Character, headers(
91 ("Location" = String, description = "Where it is: `/characters/{id}`."),
92 ("ETag" = String, description = "Its version, quoted."),
93 )),
94 (status = 403, description = "The campaign has as many characters as it may keep.", body = ErrorBody),
95 (status = 422, description = "Not a valid sheet.", body = ErrorBody),
96 )
97)]
98async fn create(
99 State(state): State<AppState>,
100 Authenticated(claims): Authenticated,
101 Json(sheet): Json<CharacterSheet>,
102) -> Result<impl IntoResponse, ApiError> {
103 let kept = state.characters.count(claims.campaign).await?;
104 let limits = state.limits.with(&claims.settings.quotas);
105 limits
106 .room_for(kept, 1, limits.characters, "characters")
107 .map_err(ApiError::Forbidden)?;
108 let character = state.characters.create((&claims).into(), sheet).await?;
109 let location = format!("/characters/{}", character.id);
110 Ok((
111 StatusCode::CREATED,
112 [(header::LOCATION, location)],
113 tagged(character),
114 ))
115}
116
117#[utoipa::path(
118 get,
119 path = "/characters/{id}",
120 tag = "characters",
121 params(("id" = Uuid, Path, description = "The character's id.")),
122 responses(
123 (status = 200, description = "The character.", body = Character, headers(("ETag" = String, description = "The character's version, quoted: `\"3\"`."))),
124 (status = 404, description = "No such character in the campaign.", body = ErrorBody),
125 )
126)]
127async fn fetch(
128 State(state): State<AppState>,
129 Authenticated(claims): Authenticated,
130 Path(id): Path<Uuid>,
131) -> Result<impl IntoResponse, ApiError> {
132 Ok(tagged(state.characters.get((&claims).into(), id).await?))
133}
134
135#[utoipa::path(
136 put,
137 path = "/characters/{id}",
138 tag = "characters",
139 params(
140 ("id" = Uuid, Path, description = "The character's id."),
141 ("If-Match" = String, Header, description = "The version the new sheet is based on, quoted (`\"3\"`), or `*` to overwrite whatever is there."),
142 ),
143 request_body = CharacterSheet,
144 responses(
145 (status = 200, description = "Replaced.", body = Character, headers(("ETag" = String, description = "The character's version, quoted: `\"3\"`."))),
146 (status = 400, description = "`If-Match` isn't one version or `*`.", body = ErrorBody),
147 (status = 403, description = "Not a character this device plays.", body = ErrorBody),
148 (status = 404, description = "No such character in the campaign.", body = ErrorBody),
149 (status = 412, description = "Changed since that version: re-apply the change to `current` and try again.", body = Stale),
150 (status = 422, description = "Not a valid sheet.", body = ErrorBody),
151 (status = 428, description = "No `If-Match`.", body = ErrorBody),
152 )
153)]
154async fn replace(
155 State(state): State<AppState>,
156 Authenticated(claims): Authenticated,
157 Path(id): Path<Uuid>,
158 headers: HeaderMap,
159 Json(sheet): Json<CharacterSheet>,
160) -> Result<impl IntoResponse, ApiError> {
161 let expected = expected_version(&headers)?;
162 let saved = state
163 .characters
164 .replace((&claims).into(), id, expected, sheet)
165 .await?;
166 Ok(tagged(saved))
167}
168
169#[utoipa::path(
170 delete,
171 path = "/characters/{id}",
172 tag = "characters",
173 params(("id" = Uuid, Path, description = "The character's id.")),
174 responses(
175 (status = 204, description = "Deleted."),
176 (status = 403, description = "Not a character this device plays.", body = ErrorBody),
177 (status = 404, description = "No such character in the campaign.", body = ErrorBody),
178 )
179)]
180async fn remove(
181 State(state): State<AppState>,
182 Authenticated(claims): Authenticated,
183 Path(id): Path<Uuid>,
184) -> Result<StatusCode, ApiError> {
185 state.characters.delete((&claims).into(), id).await?;
186 Ok(StatusCode::NO_CONTENT)
187}
188
189#[utoipa::path(
190 post,
191 path = "/characters/{id}/actions",
192 tag = "characters",
193 params(
194 ("id" = Uuid, Path, description = "The character's id."),
195 ("Idempotency-Key" = Option<String>, Header, description = "Applies the action once per key: sent again, the character comes back unchanged."),
196 ),
197 request_body = CharacterAction,
198 responses(
199 (status = 200, description = "Applied.", body = Character, headers(("ETag" = String, description = "The character's version, quoted: `\"3\"`."))),
200 (status = 403, description = "Not a character this device plays.", body = ErrorBody),
201 (status = 404, description = "No such character in the campaign.", body = ErrorBody),
202 (status = 409, description = "No slots or uses left.", body = ErrorBody),
203 (status = 422, description = "Not an action this character can take.", body = ErrorBody),
204 )
205)]
206async fn apply_action(
208 State(state): State<AppState>,
209 Authenticated(claims): Authenticated,
210 Path(id): Path<Uuid>,
211 headers: HeaderMap,
212 Json(action): Json<CharacterAction>,
213) -> Result<impl IntoResponse, ApiError> {
214 let key = headers
215 .get("idempotency-key")
216 .map(|v| {
217 v.to_str()
218 .map_err(|_| ApiError::BadRequest("Idempotency-Key is not valid text".into()))
219 })
220 .transpose()?;
221 Ok(tagged(
222 state
223 .characters
224 .apply((&claims).into(), id, &action, key)
225 .await?,
226 ))
227}
228
229#[utoipa::path(
230 put,
231 path = "/characters/{id}/owners",
232 tag = "characters",
233 params(("id" = Uuid, Path, description = "The character's id.")),
234 request_body(content = Vec<String>, description = "The device keys of the player devices that play it."),
235 responses(
236 (status = 200, description = "Set.", body = Character, headers(("ETag" = String, description = "The character's version, quoted: `\"3\"`."))),
237 (status = 400, description = "Not a device key.", body = ErrorBody),
238 (status = 403, description = "Not a DM device.", body = ErrorBody),
239 (status = 404, description = "No such character in the campaign.", body = ErrorBody),
240 )
241)]
242async fn set_owners(
245 State(state): State<AppState>,
246 Authenticated(claims): Authenticated,
247 Path(id): Path<Uuid>,
248 Json(owners): Json<Vec<String>>,
249) -> Result<impl IntoResponse, ApiError> {
250 if !claims.is_dm() {
251 return Err(ApiError::Forbidden(
252 "only a DM device chooses who plays a character".into(),
253 ));
254 }
255 for key in &owners {
256 key.parse::<PublicKey>()
257 .map_err(|_| ApiError::BadRequest(format!("{key} isn't a device key")))?;
258 }
259 Ok(tagged(
260 state
261 .characters
262 .set_owners((&claims).into(), id, &owners)
263 .await?,
264 ))
265}