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
23pub 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
33fn 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 #[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)]
123async 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)]
166async 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#[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#[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
234fn dm_only(claims: &Claims) -> Result<(), ApiError> {
236 if claims.is_dm() {
237 Ok(())
238 } else {
239 Err(ApiError::DmOnly)
240 }
241}
242
243#[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#[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}