speedrun_api/api/
runs.rs

1//! # Runs
2//!
3//! Endpoints available for runs.
4use std::{
5    borrow::Cow,
6    collections::{BTreeSet, HashMap},
7    fmt::Display,
8};
9
10use http::Method;
11use serde::{Deserialize, Serialize};
12
13use super::{
14    categories::CategoryId,
15    endpoint::Endpoint,
16    error::BodyError,
17    games::GameId,
18    levels::LevelId,
19    platforms::PlatformId,
20    query_params::QueryParams,
21    regions::RegionId,
22    users::UserId,
23    variables::{ValueId, VariableId},
24    Direction, Pageable,
25};
26
27/// Embeds available for runs.
28///
29/// ## NOTE
30/// Embeds can be nested. That is not handled by this API.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
32pub enum RunEmbeds {
33    /// Embeds the full game resource.
34    Game,
35    /// Embeds the category resource for the run.
36    Category,
37    /// Embeds the level for the run. This can be empty if it is a full-game
38    /// run.
39    Level,
40    /// Embeds the full user/guest resource in place of the `players` field.
41    Players,
42    /// Embeds the full region resource. Can be empty if no region was set.
43    Region,
44    /// Embeds the full platform resource. Can be empty if no platform was set.
45    Platform,
46}
47
48/// Verification status for the run.
49#[derive(Debug, Serialize, Clone)]
50#[serde(rename_all = "kebab-case")]
51pub enum RunStatus {
52    /// Not yet reviewed.
53    New,
54    /// Run has been verified by a moderator.
55    Verified,
56    /// Run has been rejected by a moderator.
57    Rejected,
58}
59
60/// Sorting options for runs
61#[derive(Debug, Serialize, Clone, Copy)]
62#[serde(rename_all = "kebab-case")]
63pub enum RunsSorting {
64    /// Sorts by the game the run was done in (default)
65    Game,
66    /// Sorts by the run category
67    Category,
68    /// Sorts by the run level
69    Level,
70    /// Sorts by the platform used for the run
71    Platform,
72    /// Sorts by the console region used for the run
73    Region,
74    /// Sorts by whether an emulator was used for the run
75    Emulated,
76    /// Sorts by the date of the run
77    Date,
78    /// Sorts by the date when the run was submitted to speedrun.com
79    Submitted,
80    /// Sorts by verification status
81    Status,
82    /// Sorts by the date the run was verified
83    VerifyDate,
84}
85
86/// Identifies a player (either a user or a guest).
87#[derive(Debug, Clone, PartialEq, Serialize)]
88#[serde(rename_all = "kebab-case")]
89#[serde(tag = "rel")]
90pub enum Player<'a> {
91    /// A user.
92    User {
93        /// `ID` of the user.
94        id: UserId<'a>,
95    },
96    /// A guest.
97    Guest {
98        /// Name of the guest player.
99        name: Cow<'a, str>,
100    },
101}
102
103/// Represents a [splits.io](https://splits.io) `ID` or URL.
104#[derive(Debug, Serialize, Clone)]
105#[serde(rename_all = "kebab-case")]
106#[serde(untagged)]
107pub enum SplitsIo {
108    /// Splits.io ID
109    Id(String),
110    /// Splits.io URL
111    Url(url::Url),
112}
113
114// Does this belong here?
115/// Type of the variable value.
116#[derive(Debug, Serialize, Clone)]
117#[serde(rename_all = "kebab-case")]
118pub enum ValueType<'a> {
119    /// Pre-defined variable
120    PreDefined {
121        /// Value ID
122        value: ValueId<'a>,
123    },
124    /// User defined variable
125    UserDefined {
126        /// Value ID
127        value: ValueId<'a>,
128    },
129}
130
131/// Updated status for a run.
132#[derive(Debug, Serialize, Clone)]
133#[serde(rename_all = "kebab-case")]
134#[serde(tag = "status")]
135pub enum NewStatus {
136    /// Run has been verified.
137    Verified,
138    /// Run has been rejected.
139    Rejected {
140        /// The reason the run was rejected (required).
141        reason: String,
142    },
143}
144
145/// Represents a run ID.
146#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash)]
147pub struct RunId<'a>(Cow<'a, str>);
148
149impl<'a> RunId<'a> {
150    /// Create a new [`RunId`].
151    pub fn new<T>(id: T) -> Self
152    where
153        T: Into<Cow<'a, str>>,
154    {
155        Self(id.into())
156    }
157}
158
159impl<'a, T> From<T> for RunId<'a>
160where
161    T: Into<Cow<'a, str>>,
162{
163    fn from(value: T) -> Self {
164        Self::new(value)
165    }
166}
167
168impl Display for RunId<'_> {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        write!(f, "{}", &self.0)
171    }
172}
173
174/// Returns a list of all runs.
175#[derive(Default, Debug, Builder, Serialize, Clone)]
176#[builder(default, setter(into, strip_option))]
177#[serde(rename_all = "kebab-case")]
178pub struct Runs<'a> {
179    #[doc = r"Return only runs done by `user`."]
180    user: Option<UserId<'a>>,
181    #[doc = r"Return only runs done by `guest`."]
182    guest: Option<Cow<'a, str>>,
183    #[doc = r"Return only runs examined by `examiner`."]
184    examiner: Option<UserId<'a>>,
185    #[doc = r"Restrict results to `game`."]
186    game: Option<GameId<'a>>,
187    #[doc = r"Restrict results to `level`."]
188    level: Option<LevelId<'a>>,
189    #[doc = r"Restrict results to `category`."]
190    category: Option<CategoryId<'a>>,
191    #[doc = r"Restrict results to `platform`."]
192    platform: Option<PlatformId<'a>>,
193    #[doc = r"Restrict results to `region`."]
194    region: Option<RegionId<'a>>,
195    #[doc = r"Only return games run on an emulator when `true`."]
196    emulated: Option<bool>,
197    #[doc = r"Filter runs based on status."]
198    status: Option<RunStatus>,
199    #[doc = r"Sorting options for results."]
200    orderby: Option<RunsSorting>,
201    #[doc = r"Sort direction"]
202    direction: Option<Direction>,
203    #[builder(setter(name = "_embed"), private)]
204    #[serde(serialize_with = "super::utils::serialize_as_csv")]
205    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
206    embed: BTreeSet<RunEmbeds>,
207}
208
209/// Retrieves a single run.
210#[derive(Debug, Builder, Serialize, Clone)]
211#[builder(setter(into, strip_option))]
212#[serde(rename_all = "kebab-case")]
213pub struct Run<'a> {
214    #[doc = r"`ID` of the run."]
215    id: RunId<'a>,
216}
217
218/// Submit a new run.
219///
220/// This endpoint requires a valid API key.
221#[derive(Debug, Builder, Serialize, Clone)]
222#[builder(setter(into, strip_option), build_fn(validate = "Self::validate"))]
223#[serde(rename_all = "kebab-case")]
224pub struct CreateRun<'a> {
225    #[doc = r"Category ID for the run."]
226    category: CategoryId<'a>,
227    #[doc = r"Level ID for individual level runs."]
228    #[builder(default)]
229    level: Option<LevelId<'a>>,
230    #[doc = r"Optional date the run was performed (defaults to the current date)."]
231    #[builder(default)]
232    date: Option<Cow<'a, str>>,
233    #[doc = r"Optional region for the run. Some games require a region to be submitted."]
234    #[builder(default)]
235    region: Option<RegionId<'a>>,
236    #[doc = r"Optional platform for the run. Some games require a platform to be submitted."]
237    #[builder(default)]
238    platform: Option<PlatformId<'a>>,
239    #[doc = r"If the run has been verified by a moderator. Can only be set if the submitting user is a moderator of the game."]
240    #[builder(default)]
241    verified: Option<bool>,
242    #[builder(setter(name = "_times"), private, default)]
243    times: Times,
244    #[builder(setter(name = "_players"), private, default)]
245    #[serde(skip_serializing_if = "Vec::is_empty")]
246    players: Vec<Player<'a>>,
247    #[doc = r"When `true` the run was performed on an emulator (default: false)."]
248    emulated: Option<bool>,
249    #[doc = r"A valid video URL. Optional, but some games require a video to be included."]
250    #[builder(default)]
251    video: Option<url::Url>,
252    #[doc = r"Optional comment on the run. Can include additional video URLs."]
253    #[builder(default)]
254    comment: Option<String>,
255    #[doc = r"Splits.io ID or URL for the splits for the run."]
256    #[builder(default)]
257    splitsio: Option<SplitsIo>,
258    #[doc = r"Variable values for the new run. Some games have mandatory variables."]
259    #[builder(default)]
260    #[serde(skip_serializing_if = "HashMap::is_empty")]
261    variables: HashMap<VariableId<'a>, ValueType<'a>>,
262}
263
264#[derive(Default, Debug, Serialize, Clone)]
265#[serde(rename_all = "snake_case")]
266struct Times {
267    realtime: Option<f64>,
268    realtime_noloads: Option<f64>,
269    ingame: Option<f64>,
270}
271
272/// Update the verification status for the run.
273///
274/// Requires a valid API key for an authenticated user. The authenticated user
275/// must have sufficient permissions (global moderator or game moderator) to
276/// change the verification status of a run.
277#[derive(Debug, Builder, Serialize, Clone)]
278#[builder(setter(into, strip_option))]
279#[serde(rename_all = "kebab-case")]
280pub struct UpdateRunStatus<'a> {
281    #[doc = r"`ID` of the run."]
282    #[serde(skip)]
283    id: RunId<'a>,
284    #[doc = r"Updated status for the run."]
285    status: NewStatus,
286}
287
288/// Change the list of players that participated in a run.
289///
290/// The updated list must contain at least one player or guest.
291///
292/// The submitted list of players will replace the old list completely. i.e. you
293/// cannot just add a player without also submitting the existing players.
294///
295/// Requires a valid API key for an authenticated user. The authenticated user
296/// must have sufficient permissions (global moderator or game moderator) to
297/// change the verification status of a run.
298#[derive(Debug, Builder, Serialize, Clone)]
299#[builder(setter(into, strip_option))]
300#[serde(rename_all = "kebab-case")]
301pub struct UpdateRunPlayers<'a> {
302    #[doc = r"`ID` of the run."]
303    #[serde(skip)]
304    id: RunId<'a>,
305    #[builder(setter(name = "_players"), private)]
306    players: Vec<Player<'a>>,
307}
308
309/// Delete a run.
310///
311/// Requires a valid API key for an authenticated user. Regular users can only
312/// delete their own runs. Moderators can delete runs by other users also.
313#[derive(Debug, Builder, Serialize, Clone)]
314#[builder(setter(into, strip_option))]
315#[serde(rename_all = "kebab-case")]
316pub struct DeleteRun<'a> {
317    #[doc = r"`ID` of the run."]
318    id: RunId<'a>,
319}
320
321impl Runs<'_> {
322    /// Create a builder for this endpoint.
323    pub fn builder<'a>() -> RunsBuilder<'a> {
324        RunsBuilder::default()
325    }
326}
327
328impl RunsBuilder<'_> {
329    /// Add an embedded resource to this result
330    pub fn embed(&mut self, embed: RunEmbeds) -> &mut Self {
331        self.embed.get_or_insert_with(BTreeSet::new).insert(embed);
332        self
333    }
334
335    /// Add multiple embedded resources to this result
336    pub fn embeds<I>(&mut self, iter: I) -> &mut Self
337    where
338        I: Iterator<Item = RunEmbeds>,
339    {
340        self.embed.get_or_insert_with(BTreeSet::new).extend(iter);
341        self
342    }
343}
344
345impl Run<'_> {
346    /// Create a builder for this endpoint
347    pub fn builder<'a>() -> RunBuilder<'a> {
348        RunBuilder::default()
349    }
350}
351
352impl CreateRun<'_> {
353    /// Create a builder for this endpoint
354    pub fn buider<'a>() -> CreateRunBuilder<'a> {
355        CreateRunBuilder::default()
356    }
357}
358
359impl<'a> CreateRunBuilder<'a> {
360    /// Real-world time of the run
361    pub fn realtime<T: Into<f64>>(&mut self, value: T) -> &mut Self {
362        self.times.get_or_insert_with(Times::default).realtime = Some(value.into());
363        self
364    }
365
366    /// Real-world time of the run, excluding the loading times
367    pub fn realtime_noloads<T: Into<f64>>(&mut self, value: T) -> &mut Self {
368        self.times
369            .get_or_insert_with(Times::default)
370            .realtime_noloads = Some(value.into());
371        self
372    }
373
374    /// Time measured by the game
375    pub fn ingame<T: Into<f64>>(&mut self, value: T) -> &mut Self {
376        self.times.get_or_insert_with(Times::default).ingame = Some(value.into());
377        self
378    }
379
380    /// Add a player to this run.
381    pub fn player(&mut self, player: Player<'a>) -> &mut Self {
382        self.players.get_or_insert_with(Vec::new).push(player);
383        self
384    }
385
386    /// Add multiple players to this run.
387    pub fn players<I>(&mut self, iter: I) -> &mut Self
388    where
389        I: Iterator<Item = Player<'a>>,
390    {
391        self.players.get_or_insert_with(Vec::new).extend(iter);
392        self
393    }
394
395    fn validate(&self) -> Result<(), String> {
396        if let Some(times) = &self.times {
397            if times.realtime.is_none()
398                && times.realtime_noloads.is_none()
399                && times.ingame.is_none()
400            {
401                return Err("At least one time must be set. Set one of `realtime`, \
402                            `realtime_noloads`, or `ingame`."
403                    .into());
404            }
405        }
406        Ok(())
407    }
408}
409
410impl UpdateRunStatus<'_> {
411    /// Create a builder for this endpoint
412    pub fn builder<'a>() -> UpdateRunStatusBuilder<'a> {
413        UpdateRunStatusBuilder::default()
414    }
415}
416
417impl UpdateRunPlayers<'_> {
418    /// Create a builder for this endpoint
419    pub fn builder<'a>() -> UpdateRunPlayersBuilder<'a> {
420        UpdateRunPlayersBuilder::default()
421    }
422}
423
424impl<'a> UpdateRunPlayersBuilder<'a> {
425    /// Add a single user/guest to the updated list of players.
426    pub fn player(&mut self, player: Player<'a>) -> &mut Self {
427        self.players.get_or_insert_with(Vec::new).push(player);
428        self
429    }
430
431    /// Add multiple users/guests to the updated list of players.
432    pub fn players<I>(&mut self, iter: I) -> &mut Self
433    where
434        I: Iterator<Item = Player<'a>>,
435    {
436        self.players.get_or_insert_with(Vec::new).extend(iter);
437        self
438    }
439}
440
441impl DeleteRun<'_> {
442    /// Create a builder for this endpoint
443    pub fn builder<'a>() -> DeleteRunBuilder<'a> {
444        DeleteRunBuilder::default()
445    }
446}
447
448impl RunEmbeds {
449    fn as_str(&self) -> &'static str {
450        match self {
451            RunEmbeds::Game => "game",
452            RunEmbeds::Category => "category",
453            RunEmbeds::Level => "level",
454            RunEmbeds::Players => "players",
455            RunEmbeds::Region => "region",
456            RunEmbeds::Platform => "platform",
457        }
458    }
459}
460
461impl Default for RunsSorting {
462    fn default() -> Self {
463        Self::Game
464    }
465}
466
467impl Endpoint for Runs<'_> {
468    fn endpoint(&self) -> Cow<'static, str> {
469        "/runs".into()
470    }
471
472    fn query_parameters(&self) -> Result<QueryParams<'_>, BodyError> {
473        QueryParams::with(self)
474    }
475}
476
477impl Endpoint for Run<'_> {
478    fn endpoint(&self) -> Cow<'static, str> {
479        format!("/runs/{}", self.id).into()
480    }
481}
482
483impl Endpoint for CreateRun<'_> {
484    fn method(&self) -> Method {
485        Method::POST
486    }
487
488    fn endpoint(&self) -> Cow<'static, str> {
489        "/runs".into()
490    }
491
492    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, super::error::BodyError> {
493        Ok(serde_json::to_vec(self).map(|body| Some(("application/json", body)))?)
494    }
495
496    fn requires_authentication(&self) -> bool {
497        true
498    }
499}
500
501impl Endpoint for UpdateRunStatus<'_> {
502    fn method(&self) -> Method {
503        Method::PUT
504    }
505
506    fn endpoint(&self) -> Cow<'static, str> {
507        format!("/runs/{}/status", self.id).into()
508    }
509
510    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, super::error::BodyError> {
511        Ok(serde_json::to_vec(self).map(|body| Some(("application/json", body)))?)
512    }
513
514    fn requires_authentication(&self) -> bool {
515        true
516    }
517}
518
519impl Endpoint for UpdateRunPlayers<'_> {
520    fn method(&self) -> Method {
521        Method::PUT
522    }
523
524    fn endpoint(&self) -> Cow<'static, str> {
525        format!("/runs/{}/players", self.id).into()
526    }
527
528    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, super::error::BodyError> {
529        Ok(serde_json::to_vec(self).map(|body| Some(("application/json", body)))?)
530    }
531
532    fn requires_authentication(&self) -> bool {
533        true
534    }
535}
536
537impl Endpoint for DeleteRun<'_> {
538    fn method(&self) -> Method {
539        Method::DELETE
540    }
541
542    fn endpoint(&self) -> Cow<'static, str> {
543        format!("/runs/{}", self.id).into()
544    }
545
546    fn requires_authentication(&self) -> bool {
547        true
548    }
549}
550
551impl From<&RunEmbeds> for &'static str {
552    fn from(value: &RunEmbeds) -> Self {
553        value.as_str()
554    }
555}
556
557impl Pageable for Runs<'_> {}