initial commit

This commit is contained in:
2024-08-09 09:05:42 +02:00
commit d6d29b6568
33 changed files with 2499 additions and 0 deletions

63
src/lib.rs Normal file
View File

@@ -0,0 +1,63 @@
#![deny(clippy::all)]
#![deny(clippy::pedantic)]
#![deny(clippy::nursery)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::unused_async)]
#![allow(clippy::useless_let_if_seq)] // Reason: prevents some OpenApi structs from compiling
pub mod route;
pub mod settings;
pub mod startup;
pub mod telemetry;
type MaybeListener = Option<poem::listener::TcpListener<String>>;
async fn prepare(listener: MaybeListener, test_db: Option<sqlx::PgPool>) -> startup::Application {
dotenvy::dotenv().ok();
let settings = settings::Settings::new().expect("Failed to read settings");
if !cfg!(test) {
let subscriber = telemetry::get_subscriber(settings.clone().debug);
telemetry::init_subscriber(subscriber);
}
tracing::event!(
target: "${REPO_NAME_KEBAB}",
tracing::Level::DEBUG,
"Using these settings: {:?}",
settings.clone()
);
let application = startup::Application::build(settings.clone(), test_db, listener).await;
tracing::event!(
target: "${REPO_NAME_KEBAB}",
tracing::Level::INFO,
"Listening on http://127.0.0.1:{}/",
application.port()
);
application
}
/// # Errors
///
/// May return an error if the server encounters an error it cannot
/// recover from.
#[cfg(not(tarpaulin_include))]
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
let application = prepare(listener, None).await;
application.make_app().run().await
}
#[cfg(test)]
async fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener");
let port = tcp_listener.local_addr().unwrap().port();
poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"))
}
#[cfg(test)]
async fn get_test_app(test_db: Option<sqlx::PgPool>) -> startup::App {
let tcp_listener = crate::make_random_tcp_listener().await;
crate::prepare(Some(tcp_listener), test_db)
.await
.make_app()
.into()
}

5
src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#[cfg(not(tarpaulin_include))]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
${REPO_NAME_SNAKE}::run(None).await
}

29
src/route/health.rs Normal file
View File

@@ -0,0 +1,29 @@
use poem_openapi::{ApiResponse, OpenApi};
use super::ApiCategory;
#[derive(ApiResponse)]
enum HealthResponse {
#[oai(status = 200)]
Ok,
}
pub struct HealthApi;
#[OpenApi(prefix_path = "/v1/health-check", tag = "ApiCategory::Health")]
impl HealthApi {
#[oai(path = "/", method = "get")]
async fn health_check(&self) -> HealthResponse {
tracing::event!(target: "${REPO_NAME_KEBAB}", tracing::Level::DEBUG, "Accessing health-check endpoint.");
HealthResponse::Ok
}
}
#[tokio::test]
async fn health_check_works() {
let app = crate::get_test_app(None).await;
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/v1/health-check").send().await;
resp.assert_status_is_ok();
resp.assert_text("").await;
}

18
src/route/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
use poem_openapi::{OpenApi, Tags};
mod health;
pub use health::HealthApi;
mod version;
pub use version::VersionApi;
#[derive(Tags)]
enum ApiCategory {
Health,
Version,
}
pub(crate) struct Api;
#[OpenApi]
impl Api {}

46
src/route/version.rs Normal file
View File

@@ -0,0 +1,46 @@
use poem::Result;
use poem_openapi::{payload::Json, ApiResponse, Object, OpenApi};
use crate::settings::Settings;
use super::ApiCategory;
#[derive(Object, Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Meta {
version: String,
}
impl From<poem::web::Data<&Settings>> for Meta {
fn from(value: poem::web::Data<&Settings>) -> Self {
let version = value.application.version.clone();
Self { version }
}
}
#[derive(ApiResponse)]
enum VersionResponse {
#[oai(status = 200)]
Version(Json<Meta>),
}
pub struct VersionApi;
#[OpenApi(prefix_path = "/v1/version", tag = "ApiCategory::Version")]
impl VersionApi {
#[oai(path = "/", method = "get")]
async fn version(&self, settings: poem::web::Data<&Settings>) -> Result<VersionResponse> {
tracing::event!(target: "${REPO_NAME_KEBAB}", tracing::Level::DEBUG, "Accessing version endpoint.");
Ok(VersionResponse::Version(Json(settings.into())))
}
}
#[tokio::test]
async fn version_works() {
let app = crate::get_test_app(None).await;
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/v1/version").send().await;
resp.assert_status_is_ok();
let json = resp.json().await;
let json_value = json.value();
json_value.object().get("version").assert_not_null();
}

246
src/settings.rs Normal file
View File

@@ -0,0 +1,246 @@
use sqlx::ConnectOptions;
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Settings {
pub application: ApplicationSettings,
pub database: Database,
pub debug: bool,
pub email: EmailSettings,
pub frontend_url: String,
}
impl Settings {
#[must_use]
pub fn web_address(&self) -> String {
if self.debug {
format!(
"{}:{}",
self.application.base_url.clone(),
self.application.port
)
} else {
self.application.base_url.clone()
}
}
/// Multipurpose function that helps detect the current
/// environment the application is running in using the
/// `APP_ENVIRONMENT` environment variable.
///
/// ```text
/// APP_ENVIRONMENT = development | dev | production | prod
/// ```
///
/// After detection, it loads the appropriate `.yaml` file. It
/// then loads the environment variables that overrides whatever
/// is set in the `.yaml` files. For this to work, the environment
/// variable MUST be in uppercase and start with `APP`, a `_`
/// separator, then the category of settings, followed by a `__`
/// separator, and finally the variable itself. For instance,
/// `APP__APPLICATION_PORT=3001` for `port` to be set as `3001`.
///
/// # Errors
///
/// Function may return an error if it fails to parse its config
/// files.
///
/// # Panics
///
/// Panics if the program fails to detect the directory it is
/// running in. Can also panic if it fails to parse the
/// environment variable `APP_ENVIRONMENT` and it fails to fall
/// back to its default value.
pub fn new() -> Result<Self, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let settings_directory = base_path.join("settings");
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "development".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let environment_filename = format!("{environment}.yaml");
let settings = config::Config::builder()
.add_source(config::File::from(settings_directory.join("base.yaml")))
.add_source(config::File::from(
settings_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Self>()
}
}
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct ApplicationSettings {
pub name: String,
pub version: String,
pub port: u16,
pub host: String,
pub base_url: String,
pub protocol: String,
}
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Database {
pub host: String,
pub port: u16,
pub name: String,
pub user: String,
pub password: String,
pub require_ssl: bool,
}
impl Database {
#[must_use]
pub fn get_connect_options(&self) -> sqlx::postgres::PgConnectOptions {
let ssl_mode = if self.require_ssl {
sqlx::postgres::PgSslMode::Require
} else {
sqlx::postgres::PgSslMode::Prefer
};
sqlx::postgres::PgConnectOptions::new()
.host(&self.host)
.username(&self.user)
.password(&self.password)
.port(self.port)
.ssl_mode(ssl_mode)
.database(&self.name)
.log_statements(tracing::log::LevelFilter::Trace)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Environment {
Development,
Production,
}
impl Default for Environment {
fn default() -> Self {
Self::Development
}
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Development => "development",
Self::Production => "production",
};
write!(f, "{self_str}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"production" | "prod" => Ok(Self::Production),
other => Err(format!(
"{other} is not a supported environment. Use either `development` or `production`"
)),
}
}
}
impl TryFrom<&str> for Environment {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::try_from(value.to_string())
}
}
#[derive(serde::Deserialize, Clone, Debug, Default)]
pub struct EmailSettings {
pub host: String,
pub user: String,
pub password: String,
pub from: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_environment_works() {
let default_environment = Environment::default();
assert_eq!(Environment::Development, default_environment);
}
#[test]
fn display_environment_works() {
let expected_prod = "production".to_string();
let expected_dev = "development".to_string();
let prod = Environment::Production.to_string();
let dev = Environment::Development.to_string();
assert_eq!(expected_prod, prod);
assert_eq!(expected_dev, dev);
}
#[test]
fn try_from_works() {
[
"DEVELOPMENT",
"DEVEloPmENT",
"Development",
"DEV",
"Dev",
"dev",
]
.iter()
.map(|v| (*v).to_string())
.for_each(|v| {
let environment = Environment::try_from(v);
assert!(environment.is_ok());
assert_eq!(Environment::Development, environment.unwrap());
});
[
"PRODUCTION",
"Production",
"PRODuction",
"production",
"PROD",
"Prod",
"prod",
]
.iter()
.map(|v| (*v).to_string())
.for_each(|v| {
let environment = Environment::try_from(v);
assert!(environment.is_ok());
assert_eq!(Environment::Production, environment.unwrap());
});
let environment = Environment::try_from("invalid");
assert!(environment.is_err());
assert_eq!(
"invalid is not a supported environment. Use either `development` or `production`"
.to_string(),
environment.err().unwrap()
);
}
#[test]
fn web_address_works() {
let mut settings = Settings {
debug: false,
application: ApplicationSettings {
base_url: "127.0.0.1".to_string(),
port: 3000,
..Default::default()
},
..Default::default()
};
let expected_no_debug = "127.0.0.1".to_string();
let expected_debug = "127.0.0.1:3000".to_string();
assert_eq!(expected_no_debug, settings.web_address());
settings.debug = true;
assert_eq!(expected_debug, settings.web_address());
}
}

142
src/startup.rs Normal file
View File

@@ -0,0 +1,142 @@
use poem::middleware::Cors;
use poem::middleware::{AddDataEndpoint, CorsEndpoint};
use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService;
use crate::{
route::{Api, HealthApi, VersionApi},
settings::Settings,
};
#[must_use]
pub fn get_connection_pool(settings: &crate::settings::Database) -> sqlx::postgres::PgPool {
tracing::event!(
target: "startup",
tracing::Level::INFO,
"connecting to database with configuration {:?}",
settings.clone()
);
sqlx::postgres::PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(settings.get_connect_options())
}
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
pub type App = AddDataEndpoint<AddDataEndpoint<CorsEndpoint<Route>, sqlx::PgPool>, Settings>;
pub struct Application {
server: Server,
app: poem::Route,
port: u16,
database: sqlx::postgres::PgPool,
settings: Settings,
}
pub struct RunnableApplication {
server: Server,
app: App,
}
impl RunnableApplication {
/// Runs the application until it decides to stop by itself.
///
/// # Errors
///
/// If the server encounters an internal error it cannot recover
/// from, it will forward it to this function which will forward
/// it to its caller.
pub async fn run(self) -> Result<(), std::io::Error> {
self.server.run(self.app).await
}
}
impl From<RunnableApplication> for App {
fn from(value: RunnableApplication) -> Self {
value.app
}
}
impl From<Application> for RunnableApplication {
fn from(val: Application) -> Self {
let app = val
.app
.with(Cors::new())
.data(val.database)
.data(val.settings);
let server = val.server;
Self { server, app }
}
}
impl Application {
async fn setup_db(
settings: &Settings,
test_pool: Option<sqlx::postgres::PgPool>,
) -> sqlx::postgres::PgPool {
let database_pool =
test_pool.map_or_else(|| get_connection_pool(&settings.database), |pool| pool);
if !cfg!(test) {
migrate_database(&database_pool).await;
}
database_pool
}
fn setup_app(settings: &Settings) -> poem::Route {
let api_service = OpenApiService::new(
(Api, HealthApi, VersionApi),
settings.application.clone().name,
settings.application.clone().version,
);
let ui = api_service.swagger_ui();
poem::Route::new().nest("/", api_service).nest("/docs", ui)
}
fn setup_server(
settings: &Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Server {
let tcp_listener = tcp_listener.unwrap_or_else(|| {
let address = format!(
"{}:{}",
settings.application.host, settings.application.port
);
poem::listener::TcpListener::bind(address)
});
poem::Server::new(tcp_listener)
}
pub async fn build(
settings: Settings,
test_pool: Option<sqlx::postgres::PgPool>,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self {
let database_pool = Self::setup_db(&settings, test_pool).await;
let port = settings.application.port;
let app = Self::setup_app(&settings);
let server = Self::setup_server(&settings, tcp_listener);
Self {
server,
app,
port,
database: database_pool,
settings,
}
}
/// Make the app runnable.
#[must_use]
pub fn make_app(self) -> RunnableApplication {
self.into()
}
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
}
async fn migrate_database(pool: &sqlx::postgres::PgPool) {
sqlx::migrate!()
.run(pool)
.await
.expect("Failed to migrate the database");
}

28
src/telemetry.rs Normal file
View File

@@ -0,0 +1,28 @@
use tracing_subscriber::layer::SubscriberExt;
#[must_use]
pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug { "debug" } else { "info" }.to_string();
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty();
let subscriber = tracing_subscriber::Registry::default()
.with(env_filter)
.with(stdout_log);
let json_log = if debug {
None
} else {
Some(tracing_subscriber::fmt::layer().json())
};
subscriber.with(json_log)
}
/// Initialize the global tracing subscriber.
///
/// # Panics
///
/// May panic if the function fails to set `subscriber` as the default
/// global subscriber.
pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
}