Skip to main content

character_service/
routes.rs

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
16/// The routes.
17pub 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/// A `412`: the character as it is now, to re-apply a change to.
27#[derive(utoipa::ToSchema)]
28pub struct Stale {
29    pub error: String,
30    pub current: Character,
31}
32
33/// Strong `ETag` for a character: its version, quoted per RFC 9110.
34pub fn etag(character: &Character) -> String {
35    format!("\"{}\"", character.version)
36}
37
38/// A character response carrying its `ETag`, so clients can always pick up
39/// the version from the header as well as the body.
40fn tagged(character: Character) -> impl IntoResponse {
41    ([(header::ETAG, etag(&character))], Json(character))
42}
43
44/// Parses `If-Match` into the version the client expects, or `None` for `*`
45/// (overwrite whatever is there).
46fn 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)]
206/// Applies an action; with an `Idempotency-Key` header, once per key.
207async 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)]
242/// Sets which player devices play a character (DM devices only): a list of
243/// device keys, as `auth devices` shows them.
244async 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}