Initial commit

This commit is contained in:
Kristofers Solo 2024-08-20 21:11:29 +03:00
commit 62c61c4541
19 changed files with 3421 additions and 0 deletions

8
.dockerfile Normal file
View File

@ -0,0 +1,8 @@
.env
target/
tests/
Dockerfile
README.md
LICENSE
scripts/
migrations/

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

2907
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

62
Cargo.toml Normal file
View File

@ -0,0 +1,62 @@
[package]
name = "yoda-web"
version = "0.1.0"
edition = "2021"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "yoda-web"
[dependencies]
axum = "0.7"
chrono = { version = "0.4", features = ["serde", "clock"] }
config = { version = "0.14", features = ["toml"], default-features = false }
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
tokio = { version = "1.39", features = [
"rt",
"macros",
"tracing",
"rt-multi-thread",
] }
uuid = { version = "1.8", features = ["v4", "serde"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tower-http = { version = "0.5", features = ["trace"] }
tracing-bunyan-formatter = "0.3"
tracing-log = "0.2"
secrecy = { version = "0.8", features = ["serde"] }
serde-aux = "4"
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
[dev-dependencies]
once_cell = "1.19"
fake = "2.9"
quickcheck = "1.0"
quickcheck_macros = "1.0"
wiremock = "0.6"
serde_json = "1"
[package.metadata.clippy]
warn = [
"clippy::pedantic",
"clippy::nursery",
"clippy::unwrap_used",
"clippy::expect_used",
]

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM lukemathwalker/cargo-chef:latest-rust-slim AS chef
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install lld clang -y
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ENV SQLX_OFFLINE true
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# openssl - it is dynamically linked by some dependencies
# ca-certificates - it is needed to verify TLS certificates when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/yoda-web yoda-web
COPY config config
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./yoda-web"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kristofers Solo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# Axum Template
This repository contains templates for bootstrapping a Rust Web application with Axum.
## Getting Started
1. Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate#installation)
```shell
cargo install cargo-generate
```
2. Create a new app based on this repository:
```shell
cargo generate kristoferssolo/axum-template
```

9
config/base.toml Normal file
View File

@ -0,0 +1,9 @@
[application]
port = 8000
[database]
host = "127.0.0.1"
port = 5432
username = "postgres"
password = "password"
database_name = "yoda-web"

5
config/local.toml Normal file
View File

@ -0,0 +1,5 @@
[application]
host = "127.0.0.1"
[database]
require_ssl = false

5
config/production.toml Normal file
View File

@ -0,0 +1,5 @@
[application]
host = "0.0.0.0"
[database]
require_ssl = true

46
scripts/init_db Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -eo pipefail
if ! [ -x "$(command -v psql)" ]; then
echo >&2 "Error: psql is not installed."
exit 1
fi
if ! [ -x "$(command -v sqlx)" ]; then
echo >&2 "Error: sqlx is not installed."
echo >&2 "Use:"
echo >&2 " cargo install sqlx-cli --no-default-features --features rustls,postgres"
echo >&2 "to install it."
exit 1
fi
DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
if [[ -z "${SKIP_DOCKER}" ]]; then
docker run\
-e POSTGRES_USER=${DB_USER}\
-e POSTGRES_PASSWORD=${DB_PASSWORD}\
-e POSTGRES_DB=${DB_NAME}\
-p "${DB_PORT}":5432\
-d postgres\
postgres -N 1000
# Increase max number of connections for testing purposes
fi
# Keep pinging Postgres until it's ready to accept commands
export PGPASSWORD="${DB_PASSWORD}"
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
>&2 echo "Postgres is still unavailable - sleeping"
sleep 1
done
>&2 echo "Postgres is still up and running on port ${DB_PORT} - runing migrations now!"
DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
export DATABASE_URL
sqlx database create
sqlx migrate run

108
src/config.rs Normal file
View File

@ -0,0 +1,108 @@
use std::fmt::Display;
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
ConnectOptions,
};
#[derive(Debug, Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(Debug, Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
#[derive(Debug, Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}
#[derive(Debug)]
pub enum Environment {
Local,
Production,
}
pub fn get_config() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine current directory");
let config_directory = base_path.join("config");
let env: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let env_filename = format!("{}.toml", &env);
let settings = config::Config::builder()
.add_source(config::File::from(config_directory.join("base.toml")))
.add_source(config::File::from(config_directory.join(env_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
impl DatabaseSettings {
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Environment::Local => write!(f, "local"),
Environment::Production => write!(f, "production"),
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not supported environment. \
Use either `local` or `production`.",
other
)),
}
}
}

1
src/domain/mod.rs Normal file
View File

@ -0,0 +1 @@

4
src/lib.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod config;
pub mod domain;
pub mod routes;
pub mod telemetry;

21
src/main.rs Normal file
View File

@ -0,0 +1,21 @@
use yoda_web::{
config::get_config,
routes::route,
telemetry::{get_subscriber, init_subscriber},
};
use sqlx::postgres::PgPoolOptions;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber("yoda-web", "info", std::io::stdout);
init_subscriber(subscriber);
let config = get_config().expect("Failed to read configuation.");
let pool = PgPoolOptions::new().connect_lazy_with(config.database.with_db());
let addr = format!("{}:{}", config.application.host, config.application.port);
let listener = TcpListener::bind(addr)
.await
.expect("Failed to bind port 8000.");
axum::serve(listener, route(pool)).await
}

View File

@ -0,0 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse};
pub async fn health_check() -> impl IntoResponse {
StatusCode::OK
}

49
src/routes/mod.rs Normal file
View File

@ -0,0 +1,49 @@
mod health_check;
use std::time::Duration;
use axum::{
body::Bytes,
extract::MatchedPath,
http::{HeaderMap, Request},
response::Response,
routing::get,
Router,
};
pub use health_check::*;
use sqlx::PgPool;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
use tracing::{info_span, Span};
use uuid::Uuid;
pub fn route(pool: PgPool) -> Router {
Router::new()
.route("/health_check", get(health_check))
.with_state(pool)
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http-request",
method = ?request.method(),
matched_path,
some_other_field = tracing::field::Empty,
request_id=%Uuid::new_v4(),
)
})
.on_request(|_request: &Request<_>, _span: &Span| {})
.on_response(|_response: &Response<_>, _latency: Duration, _span: &Span| {})
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
.on_eos(
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {},
)
.on_failure(
|_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {},
),
)
}

25
src/telemetry.rs Normal file
View File

@ -0,0 +1,25 @@
use tracing::{subscriber::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber<Sink>(
name: &str,
env_filter: &str,
sink: Sink,
) -> impl Subscriber + Sync + Send
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| env_filter.into());
let formatting_layer = BunyanFormattingLayer::new(name.into(), sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).expect("Failed to set subscriber.");
}

95
tests/health_check.rs Normal file
View File

@ -0,0 +1,95 @@
use yoda_web::{
config::{get_config, DatabaseSettings},
routes::route,
telemetry::{get_subscriber, init_subscriber},
};
use once_cell::sync::Lazy;
use reqwest::Client;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use tokio::net::TcpListener;
use uuid::Uuid;
#[tokio::test]
async fn health_check() {
let app = spawn_app().await;
let url = format!("{}/health_check", &app.address);
let client = Client::new();
let response = client
.get(&url)
.send()
.await
.expect("Failed to execute request");
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}
static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "trace";
let subscriber_name = "test";
if std::env::var("TEST_LOG").is_ok() {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
init_subscriber(subscriber);
} else {
let subscriber = get_subscriber(default_filter_level, subscriber_name, std::io::sink);
init_subscriber(subscriber);
}
});
async fn spawn_app() -> TestApp {
Lazy::force(&TRACING);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind random port");
let port = listener.local_addr().unwrap().port();
let address = format!("http://127.0.0.1:{}", port);
let mut config = get_config().expect("Failed to read configuration.");
config.database.database_name = Uuid::new_v4().to_string();
let pool = configure_database(&config.database).await;
let pool_clone = pool.clone();
let _ = tokio::spawn(async move {
axum::serve(listener, route(pool_clone))
.await
.expect("Failed to bind address.")
});
TestApp { address, pool }
}
async fn configure_database(config: &DatabaseSettings) -> PgPool {
let mut connection = PgConnection::connect_with(&config.without_db())
.await
.expect("Failed to connect to Postgres.");
connection
.execute(
format!(
r#"
CREATE DATABASE "{}"
"#,
config.database_name
)
.as_str(),
)
.await
.expect("Failed to create database.");
let pool = PgPool::connect_with(config.with_db())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to migrate database");
pool
}
pub struct TestApp {
pub address: String,
pub pool: PgPool,
}