Skip to main content

monster_service/
routes.rs

1use std::sync::Arc;
2
3use axum::{
4    Extension, Json, Router,
5    extract::{Path, Query, State},
6    http::{StatusCode, header},
7    response::IntoResponse,
8    routing::get,
9};
10use edda_auth::{protocol::Claims, server::Authenticated};
11use edda_server::Limits;
12use edda_types::{
13    monster::{Monster, MonsterSheet, MonsterSummary},
14    rules_text::{ConditionText, Entry, RulesText, SpellText},
15};
16use serde::Deserialize;
17use uuid::Uuid;
18
19use edda_server::openapi::ErrorBody;
20
21use crate::{MonsterStore, error::ApiError};
22
23/// The routes.
24pub fn router() -> Router<MonsterStore> {
25    Router::new()
26        .route("/health", get(edda_server::health))
27        .route("/monsters", get(list).post(create))
28        .route("/monsters/{id}", get(fetch).put(replace).delete(remove))
29        .merge(texts::<SpellText>())
30        .merge(texts::<ConditionText>())
31}
32
33/// `/spells` or `/conditions`, like `/monsters` but whole entries in the
34/// list (they're short).
35fn texts<T: RulesText>() -> Router<MonsterStore> {
36    Router::new()
37        .route(
38            &format!("/{}", T::KIND),
39            get(list_texts::<T>).post(create_text::<T>),
40        )
41        .route(
42            &format!("/{}/{{id}}", T::KIND),
43            get(fetch_text::<T>)
44                .put(replace_text::<T>)
45                .delete(remove_text::<T>),
46        )
47}
48
49async fn list_texts<T: RulesText>(
50    State(store): State<MonsterStore>,
51    Authenticated(claims): Authenticated,
52    Query(query): Query<Search>,
53) -> Result<Json<Vec<Entry<T>>>, ApiError> {
54    let wanted = query.search.to_lowercase();
55    let mut texts = store.texts::<T>((&claims).into()).await?;
56    texts.retain(|t| t.text.name().to_lowercase().contains(&wanted));
57    Ok(Json(texts))
58}
59
60async fn fetch_text<T: RulesText>(
61    State(store): State<MonsterStore>,
62    Authenticated(claims): Authenticated,
63    Path(id): Path<Uuid>,
64) -> Result<Json<Entry<T>>, ApiError> {
65    Ok(Json(store.text::<T>((&claims).into(), id).await?))
66}
67
68async fn create_text<T: RulesText>(
69    State(store): State<MonsterStore>,
70    Authenticated(claims): Authenticated,
71    Extension(limits): Extension<Arc<Limits>>,
72    Json(text): Json<T>,
73) -> Result<impl IntoResponse, ApiError> {
74    dm_only(&claims)?;
75    let kept = store.count_own_texts::<T>(claims.campaign).await?;
76    let limits = limits.with(&claims.settings.quotas);
77    limits
78        .room_for(kept, 1, limits.rules_texts, T::KIND)
79        .map_err(ApiError::Limit)?;
80    let entry = store.create_text((&claims).into(), text).await?;
81    let location = format!("/{}/{}", T::KIND, entry.id);
82    Ok((
83        StatusCode::CREATED,
84        [(header::LOCATION, location)],
85        Json(entry),
86    ))
87}
88
89async fn replace_text<T: RulesText>(
90    State(store): State<MonsterStore>,
91    Authenticated(claims): Authenticated,
92    Path(id): Path<Uuid>,
93    Json(text): Json<T>,
94) -> Result<Json<Entry<T>>, ApiError> {
95    dm_only(&claims)?;
96    Ok(Json(store.replace_text((&claims).into(), id, text).await?))
97}
98
99async fn remove_text<T: RulesText>(
100    State(store): State<MonsterStore>,
101    Authenticated(claims): Authenticated,
102    Path(id): Path<Uuid>,
103) -> Result<StatusCode, ApiError> {
104    dm_only(&claims)?;
105    store.delete_text::<T>((&claims).into(), id).await?;
106    Ok(StatusCode::NO_CONTENT)
107}
108
109#[derive(Deserialize, utoipa::IntoParams)]
110pub(crate) struct Search {
111    /// Only those whose name contains this (any case).
112    #[serde(default)]
113    search: String,
114}
115
116#[utoipa::path(
117    get,
118    path = "/monsters",
119    tag = "monsters",
120    params(Search),
121    responses((status = 200, description = "The monsters, by name.", body = Vec<MonsterSummary>))
122)]
123/// The shared monsters of the campaign's edition and its own, by name.
124async fn list(
125    State(store): State<MonsterStore>,
126    Authenticated(claims): Authenticated,
127    Query(query): Query<Search>,
128) -> Result<Json<Vec<MonsterSummary>>, ApiError> {
129    let wanted = query.search.to_lowercase();
130    let monsters = store.list((&claims).into()).await?;
131    Ok(Json(
132        monsters
133            .iter()
134            .filter(|m| m.sheet.name.to_lowercase().contains(&wanted))
135            .map(MonsterSummary::from)
136            .collect(),
137    ))
138}
139
140#[utoipa::path(
141    get,
142    path = "/monsters/{id}",
143    tag = "monsters",
144    params(("id" = Uuid, Path, description = "The monster's id.")),
145    responses((status = 200, description = "The stat block.", body = Monster), (status = 404, description = "No such monster, shared or the campaign's.", body = ErrorBody))
146)]
147async fn fetch(
148    State(store): State<MonsterStore>,
149    Authenticated(claims): Authenticated,
150    Path(id): Path<Uuid>,
151) -> Result<Json<Monster>, ApiError> {
152    Ok(Json(store.get((&claims).into(), id).await?))
153}
154
155#[utoipa::path(
156    post,
157    path = "/monsters",
158    tag = "monsters",
159    request_body = MonsterSheet,
160    responses(
161        (status = 201, description = "Added.", body = Monster, headers(("Location" = String, description = "Where it is: `/monsters/{id}`."))),
162        (status = 403, description = "Not a DM device, or the campaign has as many monsters as it may keep.", body = ErrorBody),
163        (status = 422, description = "Not a valid stat block.", body = ErrorBody),
164    )
165)]
166/// Adds a monster to the campaign's own.
167async fn create(
168    State(store): State<MonsterStore>,
169    Authenticated(claims): Authenticated,
170    Extension(limits): Extension<Arc<Limits>>,
171    Json(sheet): Json<MonsterSheet>,
172) -> Result<impl IntoResponse, ApiError> {
173    dm_only(&claims)?;
174    let kept = store.count_own(claims.campaign).await?;
175    let limits = limits.with(&claims.settings.quotas);
176    limits
177        .room_for(kept, 1, limits.monsters, "monsters")
178        .map_err(ApiError::Limit)?;
179    let monster = store.create((&claims).into(), sheet).await?;
180    let location = format!("/monsters/{}", monster.id);
181    Ok((
182        StatusCode::CREATED,
183        [(header::LOCATION, location)],
184        Json(monster),
185    ))
186}
187
188/// Replaces one of the campaign's own.
189#[utoipa::path(
190    put,
191    path = "/monsters/{id}",
192    tag = "monsters",
193    params(("id" = Uuid, Path, description = "The monster's id.")),
194    request_body = MonsterSheet,
195    responses(
196        (status = 200, description = "Replaced.", body = Monster),
197        (status = 403, description = "Not a DM device, or a shared monster.", body = ErrorBody),
198        (status = 404, description = "No such monster, shared or the campaign's.", body = ErrorBody),
199        (status = 422, description = "Not a valid stat block.", body = ErrorBody),
200    )
201)]
202async fn replace(
203    State(store): State<MonsterStore>,
204    Authenticated(claims): Authenticated,
205    Path(id): Path<Uuid>,
206    Json(sheet): Json<MonsterSheet>,
207) -> Result<Json<Monster>, ApiError> {
208    dm_only(&claims)?;
209    Ok(Json(store.replace((&claims).into(), id, sheet).await?))
210}
211
212/// Deletes one of the campaign's own.
213#[utoipa::path(
214    delete,
215    path = "/monsters/{id}",
216    tag = "monsters",
217    params(("id" = Uuid, Path, description = "The monster's id.")),
218    responses(
219        (status = 204, description = "Deleted."),
220        (status = 403, description = "Not a DM device, or a shared monster.", body = ErrorBody),
221        (status = 404, description = "No such monster, shared or the campaign's.", body = ErrorBody),
222    )
223)]
224async fn remove(
225    State(store): State<MonsterStore>,
226    Authenticated(claims): Authenticated,
227    Path(id): Path<Uuid>,
228) -> Result<StatusCode, ApiError> {
229    dm_only(&claims)?;
230    store.delete((&claims).into(), id).await?;
231    Ok(StatusCode::NO_CONTENT)
232}
233
234/// Players' devices read; only the DM's change anything.
235fn dm_only(claims: &Claims) -> Result<(), ApiError> {
236    if claims.is_dm() {
237        Ok(())
238    } else {
239        Err(ApiError::DmOnly)
240    }
241}
242
243/// `/spells` for the `OpenAPI` description, as the handlers are generic:
244/// these are only described, never called.
245#[allow(dead_code)]
246pub(crate) mod spells {
247    use super::Search;
248    use edda_server::openapi::ErrorBody;
249    use edda_types::rules_text::{Entry, SpellText};
250
251    #[utoipa::path(
252        get,
253        path = "/spells",
254        tag = "spells",
255        params(Search),
256        responses((status = 200, description = "The shared ones of the campaign's edition and its own, by name.", body = [Entry<SpellText>]))
257    )]
258    pub fn list() {}
259
260    #[utoipa::path(
261        get,
262        path = "/spells/{id}",
263        tag = "spells",
264        params(("id" = Uuid, Path)),
265        responses(
266            (status = 200, description = "One, shared or the campaign's own.", body = Entry<SpellText>),
267            (status = 404, description = "No such one.", body = ErrorBody),
268        )
269    )]
270    pub fn fetch() {}
271
272    #[utoipa::path(
273        post,
274        path = "/spells",
275        tag = "spells",
276        request_body = SpellText,
277        responses(
278            (status = 201, description = "Added to the campaign's own.", body = Entry<SpellText>, headers(("Location" = String))),
279            (status = 403, description = "Not a DM device, or the campaign has as many as it may keep.", body = ErrorBody),
280            (status = 422, description = "Not valid.", body = ErrorBody),
281        )
282    )]
283    pub fn create() {}
284
285    #[utoipa::path(
286        put,
287        path = "/spells/{id}",
288        tag = "spells",
289        params(("id" = Uuid, Path)),
290        request_body = SpellText,
291        responses(
292            (status = 200, description = "Replaced.", body = Entry<SpellText>),
293            (status = 403, description = "Not a DM device, or a shared one.", body = ErrorBody),
294            (status = 404, description = "No such one.", body = ErrorBody),
295            (status = 422, description = "Not valid.", body = ErrorBody),
296        )
297    )]
298    pub fn replace() {}
299
300    #[utoipa::path(
301        delete,
302        path = "/spells/{id}",
303        tag = "spells",
304        params(("id" = Uuid, Path)),
305        responses(
306            (status = 204, description = "Deleted."),
307            (status = 403, description = "Not a DM device, or a shared one.", body = ErrorBody),
308            (status = 404, description = "No such one.", body = ErrorBody),
309        )
310    )]
311    pub fn remove() {}
312}
313
314/// `/conditions` for the `OpenAPI` description, as the handlers are generic:
315/// these are only described, never called.
316#[allow(dead_code)]
317pub(crate) mod conditions {
318    use super::Search;
319    use edda_server::openapi::ErrorBody;
320    use edda_types::rules_text::{ConditionText, Entry};
321
322    #[utoipa::path(
323        get,
324        path = "/conditions",
325        tag = "conditions",
326        params(Search),
327        responses((status = 200, description = "The shared ones of the campaign's edition and its own, by name.", body = [Entry<ConditionText>]))
328    )]
329    pub fn list() {}
330
331    #[utoipa::path(
332        get,
333        path = "/conditions/{id}",
334        tag = "conditions",
335        params(("id" = Uuid, Path)),
336        responses(
337            (status = 200, description = "One, shared or the campaign's own.", body = Entry<ConditionText>),
338            (status = 404, description = "No such one.", body = ErrorBody),
339        )
340    )]
341    pub fn fetch() {}
342
343    #[utoipa::path(
344        post,
345        path = "/conditions",
346        tag = "conditions",
347        request_body = ConditionText,
348        responses(
349            (status = 201, description = "Added to the campaign's own.", body = Entry<ConditionText>, headers(("Location" = String))),
350            (status = 403, description = "Not a DM device, or the campaign has as many as it may keep.", body = ErrorBody),
351            (status = 422, description = "Not valid.", body = ErrorBody),
352        )
353    )]
354    pub fn create() {}
355
356    #[utoipa::path(
357        put,
358        path = "/conditions/{id}",
359        tag = "conditions",
360        params(("id" = Uuid, Path)),
361        request_body = ConditionText,
362        responses(
363            (status = 200, description = "Replaced.", body = Entry<ConditionText>),
364            (status = 403, description = "Not a DM device, or a shared one.", body = ErrorBody),
365            (status = 404, description = "No such one.", body = ErrorBody),
366            (status = 422, description = "Not valid.", body = ErrorBody),
367        )
368    )]
369    pub fn replace() {}
370
371    #[utoipa::path(
372        delete,
373        path = "/conditions/{id}",
374        tag = "conditions",
375        params(("id" = Uuid, Path)),
376        responses(
377            (status = 204, description = "Deleted."),
378            (status = 403, description = "Not a DM device, or a shared one.", body = ErrorBody),
379            (status = 404, description = "No such one.", body = ErrorBody),
380        )
381    )]
382    pub fn remove() {}
383}