lacoctelera/routes/health.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
// Copyright 2024 Felipe Torres González
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//! Module that implements an endopint for health checks.
//!
//! # Description
//!
//! Two endpoints are available:
//! - [echo] for a basic ping support with public access.
//! - [health_check] for a detailed health report with restricted access.
//!
//! The number of requests within a time frame to both endpoints are limited by the API to every client. This is
//! a mechanism to prevent DoS attacks to the server. Every response includes the header *Retry-After* to inform the
//! client when it is allowed to send a new request to the API.
use crate::{datetime_object_type, AuthData};
use actix_web::{get, options, web, HttpRequest, HttpResponse, Responder};
use chrono::{DateTime, Days, Local};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tracing::instrument;
use utoipa::{
openapi::{
example::ExampleBuilder,
schema::{Object, ObjectBuilder},
ContentBuilder, Header, RefOr, Response, ResponseBuilder, ResponsesBuilder, SchemaType,
},
IntoResponses, ToSchema,
};
/// Enum that identifies the status of the server.
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub enum ServerStatus {
/// The server is running smoothly.
Ok,
/// The server is overloaded. Expect longer service times.
Overloaded,
/// Scheduled maintenance.
#[schema(value_type = String, example = "2025-09-11T08:58:56.121331664+02:00")]
MaintenanceScheduled(DateTime<Local>),
/// Server under maintenance. The given timestamp forecasts when the server is expected to be online again.
#[schema(value_type = String, example = "2025-09-11T08:58:56.121331664+02:00")]
OnMaintenance(DateTime<Local>),
/// The connection with the DB server is lost.
DbDown,
/// The server is not able to accept new requests.
Down,
/// API token expired. Proceed to renew the token to continue using the restricted endpoints.
TokenExpired,
}
/// Struct that holds status information of the running instance of the application.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HealthResponse {
/// Current server status, see [ServerStatus].
pub server_status: ServerStatus,
/// Expire date of the used API token.
#[schema(schema_with = datetime_object_type)]
pub api_expire_time: DateTime<Local>,
}
impl HealthResponse {
/// A simple example of the struct's fields when the server is running Ok.
pub fn example_ok() -> HealthResponse {
HealthResponse {
server_status: ServerStatus::Ok,
api_expire_time: Local::now().checked_add_days(Days::new(1)).unwrap(),
}
}
/// A simple example of the struct's fields when the server has a scheduled maintenance.
pub fn example_maintenance_scheduled() -> HealthResponse {
let ts = Local::now().checked_add_days(Days::new(1)).unwrap();
HealthResponse {
server_status: ServerStatus::MaintenanceScheduled(ts),
api_expire_time: ts,
}
}
}
impl IntoResponses for HealthResponse {
fn responses() -> BTreeMap<String, RefOr<Response>> {
let mut cache_control_header = Header::new(Object::with_type(SchemaType::String));
cache_control_header.description = Some(String::from(
"Set to *no-cache* to avoid caching maintenance information.",
));
let mut retry_after_header = Header::new(
ObjectBuilder::new()
.default(Some(serde_json::Value::String(String::from("algo"))))
.schema_type(SchemaType::String)
.build(),
);
retry_after_header.description = Some(String::from(
"How many seconds the client shall wait before issuing a new request.",
));
ResponsesBuilder::new()
.response(
"200",
ResponseBuilder::new()
.description("**Ok**")
.header("Cache-Control", cache_control_header.clone())
.header("Retry-After", retry_after_header.clone())
.content(
"application/json",
ContentBuilder::new()
.schema(HealthResponse::schema().1)
.examples_from_iter(BTreeMap::from([
(
"Ok example",
ExampleBuilder::new()
.summary("An example response of the server running smoothly.")
.value(Some(
serde_json::to_value(HealthResponse::example_ok()).unwrap(),
))
.build(),
),
(
"Scheduled maintenance example",
ExampleBuilder::new()
.summary("An example response of a scheduled maintenance of the server.")
.value(Some(
serde_json::to_value(HealthResponse::example_maintenance_scheduled()).unwrap(),
))
.build(),
)
]))
.build(),
),
)
.response(
"429",
ResponseBuilder::default()
.description("**Too many requests.**")
.header("Cache-Control", cache_control_header.clone())
.header("Retry-After", retry_after_header.clone()),
)
.response("401",
ResponseBuilder::default()
.description("**Unauthorised access to a restricted endpoint.**")
.header("Cache-Control", cache_control_header)
.header("Retry-After", retry_after_header),
)
.build()
.into()
}
}
/// Ping endpoint for the API (Public).
///
/// # Description
///
/// This public endpoint shall be used by clients of the API to check whether the server is alive and ready to accept
/// new requests or not.
///
/// The number of allowed requests by a single client is limited to 1 per minute. If this value is reached by a client,
/// the client is banned for an amount of time, which is specified by the header *Retry-After*. The ban time increases
/// exponentially when a client reaches the threshold multiple times.
#[utoipa::path(
get,
tag = "Maintenance",
responses(
(
status = 200, description = "**Ok**",
headers(
("Cache-Control", description = "Cache control is set to *no-cache*."),
("Retry-After", description = "Amount of time between requests (seconds).")
)
),
(
status = 429, description = "**Too many requests.**",
headers(
("Cache-Control", description = "Cache control is set to *no-cache*."),
("Retry-After", description = "Amount of time between requests (seconds).")
)
)
)
)]
#[instrument()]
#[get("/echo")]
pub async fn echo() -> impl Responder {
HttpResponse::NotImplemented()
// Avoid caching this endpoint.
.append_header(("Cache-Control", "no-cache"))
.append_header(("Retry-After", "60"))
.finish()
}
/// Options method for the /echo endpoint.
#[utoipa::path(
options,
tag = "Maintenance",
responses(
(
status = 204,
description = "Supported requests to the /echo endpoint",
headers(
("access-control-allow-origin", description = "*"),
("access-control-allow-methods", description = "GET, OPTIONS"),
("cache-control", description = "public, max-age=604800")
)
)
)
)]
#[options("/echo")]
pub async fn options_echo() -> impl Responder {
HttpResponse::NotImplemented()
.append_header(("access-control-allow-origin", "*"))
.append_header(("cache-control", "public, max-age=604800"))
.append_header(("access-control-allow-methods", "GET, OPTIONS"))
.finish()
}
/// Health status endpoint for the API (Restricted).
///
/// # Description
///
/// This restricted endpoint allows authorized clients to retrieve a health report of the server.
///
/// The number of allowed requests by a single client is limited to 2 per minute. If this value is reached by a client,
/// the client is banned for an amount of time, which is specified by the header *Retry-After*. The ban time increases
/// exponentially when a client reaches the threshold multiple times.
#[utoipa::path(
get,
tag = "Maintenance",
responses(HealthResponse),
security(
("api_key" = [])
),
)]
#[instrument(skip(req))]
#[get("/health")]
pub async fn health_check(req: web::Query<AuthData>) -> impl Responder {
if !req.api_key.expose_secret().is_empty() {
HttpResponse::NotImplemented()
.append_header(("Access-Control-Allow-Origin", "*"))
.append_header(("access-control-allow-headers", "content-type"))
// Avoid caching this endpoint.
.append_header(("Cache-Control", "no-cache"))
.append_header(("Retry-After", "60"))
.finish()
} else {
HttpResponse::Unauthorized()
.append_header(("Access-Control-Allow-Origin", "*"))
.append_header(("access-control-allow-headers", "content-type"))
// Avoid caching this endpoint.
.append_header(("Cache-Control", "no-cache"))
.append_header(("Retry-After", "60"))
.finish()
}
}
/// Options method for the /health endpoint.
#[utoipa::path(
options,
path = "/health",
tag = "Maintenance",
responses(
(
status = 204,
description = "Supported requests to the /health endpoint",
headers(
("access-control-allow-origin", description = "*"),
("access-control-allow-methods", description = "GET, OPTIONS"),
("cache-control", description = "public, max-age=604800")
)
)
)
)]
#[instrument(skip(_req))]
#[options("/health")]
pub async fn options_health(_req: HttpRequest) -> HttpResponse {
HttpResponse::NoContent()
.append_header(("access-control-allow-origin", "*"))
.append_header(("access-control-allow-methods", "GET, OPTIONS"))
.append_header(("cache-control", "public, max-age=604800"))
.finish()
}