Skip to main content

nil_server/
response.rs

1// Copyright (C) Call of Nil contributors
2// SPDX-License-Identifier: AGPL-3.0-only
3
4use crate::error::{CoreError, DatabaseError, Error};
5use axum::response::{IntoResponse, Response};
6use either::Either;
7use nil_server_database::error::DieselError;
8use std::ops::{ControlFlow, Try};
9
10pub type MaybeResponse<L> = Either<L, Response>;
11
12#[doc(hidden)]
13#[macro_export]
14macro_rules! res {
15  ($status:ident) => {{
16    use axum::body::Body;
17    use axum::http::StatusCode;
18    use axum::response::Response;
19
20    let status = StatusCode::$status;
21    let body = if (status.is_client_error() || status.is_server_error())
22      && let Some(reason) = status.canonical_reason()
23    {
24      Body::new(reason.to_string())
25    } else {
26      Body::empty()
27    };
28
29    Response::builder()
30      .status(status)
31      .body(body)
32      .unwrap()
33  }};
34  ($status:ident, $data:expr) => {{
35    use axum::http::StatusCode;
36    use axum::response::IntoResponse;
37
38    (StatusCode::$status, $data).into_response()
39  }};
40}
41
42impl From<Error> for Response {
43  fn from(err: Error) -> Self {
44    from_err(err)
45  }
46}
47
48impl IntoResponse for Error {
49  fn into_response(self) -> Response {
50    from_err(self)
51  }
52}
53
54pub(crate) fn from_err(err: impl Into<Error>) -> Response {
55  let err: Error = err.into();
56  tracing::error!(message = %err, error = ?err);
57  from_server_err(err)
58}
59
60#[expect(clippy::match_same_arms, clippy::needless_pass_by_value)]
61fn from_core_err(err: CoreError) -> Response {
62  use CoreError::*;
63
64  let text = err.to_string();
65  match err {
66    ArmyNotFound(..) => res!(NOT_FOUND, text),
67    ArmyNotIdle(..) => res!(BAD_REQUEST, text),
68    BotAlreadySpawned(..) => res!(CONFLICT, text),
69    BotNotFound(..) => res!(NOT_FOUND, text),
70    BuildingStatsNotFound(..) => res!(NOT_FOUND, text),
71    BuildingStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
72    CannotDecreaseBuildingLevel(..) => res!(BAD_REQUEST, text),
73    CannotIncreaseBuildingLevel(..) => res!(BAD_REQUEST, text),
74    CheatingNotAllowed => res!(BAD_REQUEST, text),
75    CityNotFound(..) => res!(NOT_FOUND, text),
76    FailedToDeserializeEvent => res!(INTERNAL_SERVER_ERROR, text),
77    FailedToReadSavedata => res!(INTERNAL_SERVER_ERROR, text),
78    FailedToSerializeEvent => res!(INTERNAL_SERVER_ERROR, text),
79    FailedToWriteSavedata => res!(INTERNAL_SERVER_ERROR, text),
80    FieldNotEmpty(..) => res!(BAD_REQUEST, text),
81    Forbidden => res!(FORBIDDEN, text),
82    IndexOutOfBounds(..) => res!(BAD_REQUEST, text),
83    InsufficientGold => res!(BAD_REQUEST, text),
84    InsufficientResources => res!(BAD_REQUEST, text),
85    InsufficientUnits => res!(BAD_REQUEST, text),
86    ManeuverIsDone(..) => res!(BAD_REQUEST, text),
87    ManeuverIsPending(..) => res!(BAD_REQUEST, text),
88    ManeuverIsReturning(..) => res!(BAD_REQUEST, text),
89    ManeuverNotFound(..) => res!(NOT_FOUND, text),
90    MineStatsNotFound(..) => res!(NOT_FOUND, text),
91    MineStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
92    NotEnoughResourcesInMarketVault => res!(BAD_REQUEST, text),
93    NotWaitingPlayer(..) => res!(BAD_REQUEST, text),
94    OriginIsDestination(..) => res!(BAD_REQUEST, text),
95    PlayerAlreadySpawned(..) => res!(CONFLICT, text),
96    PlayerNotFound(..) => res!(NOT_FOUND, text),
97    PrecursorNotFound(..) => res!(NOT_FOUND, text),
98    ReportNotFound(..) => res!(NOT_FOUND, text),
99    ResourceReceiverIsSender(..) => res!(BAD_REQUEST, text),
100    RoundAlreadyStarted => res!(CONFLICT, text),
101    RoundHasPendingPlayers => res!(BAD_REQUEST, text),
102    RoundNotStarted => res!(BAD_REQUEST, text),
103    StorageStatsNotFound(..) => res!(NOT_FOUND, text),
104    StorageStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
105    TooManyResources(..) => res!(BAD_REQUEST, text),
106    UnexpectedUnit(..) => res!(BAD_REQUEST, text),
107    WallStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
108    WorldIsFull => res!(FORBIDDEN, text),
109  }
110}
111
112#[expect(clippy::match_same_arms)]
113fn from_database_err(err: DatabaseError) -> Response {
114  use DatabaseError::*;
115
116  match err {
117    Core(err) => from_core_err(err),
118    Deadpool(..) => res!(INTERNAL_SERVER_ERROR),
119    DeadpoolBuild(..) => res!(INTERNAL_SERVER_ERROR),
120    Diesel(err) => from_diesel_err(&err),
121    DieselConnection(..) => res!(INTERNAL_SERVER_ERROR),
122    GameNotFound(..) => res!(NOT_FOUND, err.to_string()),
123    InvalidPassword => res!(BAD_REQUEST, err.to_string()),
124    InvalidUsername(..) => res!(BAD_REQUEST, err.to_string()),
125    Io(..) => res!(INTERNAL_SERVER_ERROR),
126    Jiff(..) => res!(INTERNAL_SERVER_ERROR),
127    MigrationFailed(..) => res!(INTERNAL_SERVER_ERROR),
128    UserAlreadyExists(..) => res!(CONFLICT, err.to_string()),
129    UserNotFound(..) => res!(NOT_FOUND, err.to_string()),
130    Unknown(..) => res!(INTERNAL_SERVER_ERROR),
131  }
132}
133
134fn from_diesel_err(err: &DieselError) -> Response {
135  if let DieselError::NotFound = &err {
136    res!(NOT_FOUND)
137  } else {
138    res!(INTERNAL_SERVER_ERROR)
139  }
140}
141
142#[expect(clippy::match_same_arms)]
143fn from_server_err(err: Error) -> Response {
144  use Error::*;
145
146  match err {
147    Core(err) => from_core_err(err),
148    Database(err) => from_database_err(err),
149    IncorrectUserCredentials => res!(UNAUTHORIZED, err.to_string()),
150    IncorrectWorldCredentials(..) => res!(UNAUTHORIZED, err.to_string()),
151    Io(..) => res!(INTERNAL_SERVER_ERROR),
152    MaxCharactersExceeded { .. } => res!(BAD_REQUEST, err.to_string()),
153    MissingPassword => res!(BAD_REQUEST, err.to_string()),
154    Unknown(..) => res!(INTERNAL_SERVER_ERROR),
155    WorldLimitReached => res!(FORBIDDEN, err.to_string()),
156    WorldNotFound(..) => res!(NOT_FOUND, err.to_string()),
157  }
158}
159
160pub trait EitherExt<L, R> {
161  fn try_map_left<T, E, F>(self, f: F) -> Either<Response, R>
162  where
163    Self: Sized,
164    L: Try<Output = T, Residual = E>,
165    E: Into<Error>,
166    F: FnOnce(T) -> Response;
167}
168
169impl<L, R> EitherExt<L, R> for Either<L, R> {
170  fn try_map_left<T, E, F>(self, f: F) -> Either<Response, R>
171  where
172    Self: Sized,
173    L: Try<Output = T, Residual = E>,
174    E: Into<Error>,
175    F: FnOnce(T) -> Response,
176  {
177    match self {
178      Self::Left(left) => {
179        match left.branch() {
180          ControlFlow::Continue(value) => Either::Left(f(value)),
181          ControlFlow::Break(err) => Either::Left(from_err(err)),
182        }
183      }
184      Self::Right(right) => Either::Right(right),
185    }
186  }
187}
188
189#[doc(hidden)]
190#[macro_export]
191macro_rules! bail_if_city_is_not_owned_by {
192  ($world:expr, $player:expr, $coord:expr) => {
193    if !$world
194      .city($coord)?
195      .is_owned_by_player_and(|id| $player == id)
196    {
197      return $crate::res!(FORBIDDEN);
198    }
199  };
200}
201
202#[doc(hidden)]
203#[macro_export]
204macro_rules! bail_if_max_chars_exceeded {
205  ($value:expr, $max:expr) => {
206    let current = $value.chars().count();
207    if current > $max {
208      use $crate::error::Error;
209      let err = Error::MaxCharactersExceeded { max: $max, current };
210      return $crate::response::from_err(err);
211    }
212  };
213}
214
215#[doc(hidden)]
216#[macro_export]
217macro_rules! bail_if_player_is_not_pending {
218  ($world:expr, $player:expr) => {
219    if !$world.round().is_waiting_player($player) {
220      use nil_core::error::Error;
221      let err = Error::NotWaitingPlayer($player.clone());
222      return $crate::response::from_err(err);
223    }
224  };
225}
226
227#[doc(hidden)]
228#[macro_export]
229macro_rules! bail_if_player_ne {
230  ($current_player:expr, $player:expr) => {
231    if $current_player != $player {
232      return $crate::res!(FORBIDDEN);
233    }
234  };
235}