initial commit
This commit is contained in:
63
src/lib.rs
Normal file
63
src/lib.rs
Normal 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
5
src/main.rs
Normal 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
29
src/route/health.rs
Normal 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
18
src/route/mod.rs
Normal 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
46
src/route/version.rs
Normal 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
246
src/settings.rs
Normal 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
142
src/startup.rs
Normal 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
28
src/telemetry.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user