Compare commits

..

No commits in common. "2023-02-19" and "master" have entirely different histories.

33 changed files with 1684 additions and 1757 deletions

8
.Dockerignore Normal file
View File

@ -0,0 +1,8 @@
target/
.vscode
tests/
.git
.gitignore
.env
Dockerfile
migrations/

View File

@ -1,25 +0,0 @@
# On Windows
# ```
# cargo install -f cargo-binutils
# rustup component add llvm-tools-preview
# ```
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# On Linux:
# - Ubuntu, `sudo apt-get install lld clang`
# - Arch, `sudo pacman -S lld clang`
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"]
# On MacOS, `brew install michaeleisel/zld/zld`
# [target.x86_64-apple-darwin]
# rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/zld"]
[target.aarch64-apple-darwin]
rustflags = [
"-C",
"link-arg=-fuse-ld=/opt/homebrew/Cellar/llvm/17.0.6_1/bin/ld64.lld",
]

View File

@ -1,6 +0,0 @@
.env
target/
tests/
Dockerfile
scripts/
migrations/

48
.drone.yml Normal file
View File

@ -0,0 +1,48 @@
kind: pipeline
type: docker
name: mailApp
trigger:
branch:
- master
event:
- push
steps:
- name: postgresDBTest # Test that the service is ready to be acted upon for cargo tests
image: postgres:12
environment:
PGPASSWORD: password
DATABASE_URL: postgres://postgres:password@postgres:5432/newsletter
commands:
- sleep 35
- "psql -U postgres -d newsletter -h postgres"
- name: sqlxMigrate
image: rust:1.57
environment:
DATABASE_URL: postgres://postgres:password@postgres:5432/newsletter
SKIP_DOCKER:
from_secret: SKIP_DOCKER
commands:
- apt update && apt install -y build-essential pkg-config libssl-dev # Dependancies for sqlx
- cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres # Install sqlx
- sqlx database create
- sqlx migrate run
- name: test
image: rust:1.57
environment:
APP_ENVIRONMENT: drone
commands:
- apt update && apt install -y build-essential pkg-config libssl-dev # Dependancies for tarpaulin
- cargo install cargo-tarpaulin
- cargo tarpaulin -v --all-features --timeout 120 --color always # RUN THOSE TESTS
services:
- name: postgres
image: postgres:12
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: newsletter

10
.gitignore vendored
View File

@ -1,11 +1,3 @@
/target /target
.vscode .vscode
#.env .env
.gitlab-ci-local
.DS_Store
# Added by cargo
#
# already existing elements were commented out
#/target

View File

@ -1 +0,0 @@
PRIVILEGED=true

View File

@ -1,57 +1,30 @@
image: "rust:latest" image: "rust:latest"
services:
- postgres:latest
variables:
POSTGRES_DB: newsletter
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_HOST: postgres
DB_PORT: 5432
DATABASE_URL: "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$DB_PORT/$POSTGRES_DB"
APP_DATABASE__HOST: $POSTGRES_HOST
cache: # Caches build artifacts so we don't build from scratch in both build and test
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cargo/bin
- .cargo/registry/index
- .cargo/registry/cache
- target/debug/deps
- target/debug/build
policy: pull-push
default: default:
before_script: before_script:
- export CARGO_HOME="$CI_PROJECT_DIR/.cargo" - rustc --version
- export PATH="$CARGO_HOME/bin:$PATH"
- rustc --version
- cargo --version - cargo --version
- apt update -yq && apt-get install -yq postgresql-client lld clang
- if ! [ -x "$(command -v cargo-sqlx)" ]; then cargo install --version='~0.7' sqlx-cli --no-default-features --features rustls,postgres; fi
- SKIP_DOCKER=true ./scripts/init_db.sh
# This is to ensure that the database is reachable and give it some time to initialize.
- until psql "dbname=$POSTGRES_DB user=$POSTGRES_USER password=$POSTGRES_PASSWORD host=postgres" -c '\l'; do sleep 3; done
stages: stages:
- build
- test - test
build:
stage: build
script:
- cargo build
test-code: test-code:
stage: test stage: test
script: script:
- cargo test - cargo test
- if ! [ -x "$(command -v cargo-tarpaulin)" ]; then cargo install cargo-tarpaulin; fi - cargo install cargo-tarpaulin
- cargo tarpaulin --ignore-tests - cargo tarpaulin --ignore-tests
lint-code: lint-code:
stage: test stage: test
script: script:
- rustup component add rustfmt
- cargo fmt -- --check
- rustup component add clippy - rustup component add clippy
- cargo clippy -- -D warnings - cargo clippy -- -D warnings
audit-code:
stage: test
script:
- cargo install cargo-audit
- cargo audit

View File

@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "bcfcfebc6f5e8ffbf97d97c5a209be78b46d703924482cf8b43842705fcb7714"
}

2344
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
[package] [package]
name = "mail_app" name = "mail_app"
version = "0.1.0" version = "0.1.0"
authors = ["NickBland <nick.bland@nickbland.dev>"]
edition = "2021" edition = "2021"
[lib] [lib]
@ -11,32 +12,27 @@ path = "src/main.rs"
name = "mail_app" name = "mail_app"
[dependencies] [dependencies]
actix-web = "4" actix-web = "=4.0.0-beta.16"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] } serde = "1.0.115"
serde-aux = "4" config = { version = "0.11", default-features = false, features = ["yaml"] }
config = { version = "0.13", default-features = false, features = ["yaml"] } sqlx = { version = "0.5.5", default-features = false, features = [ "runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate", "offline"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "0.8.1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false, features = ["clock"] } chrono = "0.4.15"
tracing = { version = "0.1", features = ["log"] } tracing = "0.1.19"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = "0.3" tracing-bunyan-formatter = "0.3.1"
tracing-actix-web = "0.7" tracing-log = "0.1.1"
tracing-log = "0.1" serde-aux = "3"
tracing-actix-web = "0.5.0-beta.7"
secrecy = { version = "0.8", features = ["serde"] } secrecy = { version = "0.8", features = ["serde"] }
unicode-segmentation = "1"
[dependencies.sqlx] validator = "0.14"
version = "0.7"
default-features = false
features = [
"runtime-tokio-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
]
[dev-dependencies] [dev-dependencies]
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
once_cell = "1" once_cell = "1.7.2"
claim = "0.5"
fake = "~2.3"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"

View File

@ -1,32 +1,30 @@
##### Chef FROM lukemathwalker/cargo-chef:latest-rust-1.57.0 AS chef
FROM lukemathwalker/cargo-chef:latest-rust-1.76.0 as chef
WORKDIR /app WORKDIR /app
RUN apt update && apt install lld clang -y
##### Planner # Create lock file for project to be used in builder
FROM chef as planner FROM chef AS planner
COPY . . COPY . .
RUN cargo chef prepare --recipe-path recipe.json RUN cargo chef prepare --recipe-path recipe.json
##### Builder # Build dependancies
# Builder prepares project dependancies, not the application.
FROM chef as builder FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json RUN cargo chef cook --release --recipe-path recipe.json
COPY . . COPY . .
ENV SQLX_OFFLINE true ENV SQLX_OFFLINE true
# Now build the application itself. RUN cargo build --release
RUN cargo build --release --bin mail_app
##### Runtime FROM debian:bullseye-slim AS runtime
FROM debian:bookworm-slim as runtime
WORKDIR /app WORKDIR /app
RUN apt update && apt install -y --no-install-recommends openssl ca-certificates \
&& apt autoremove -y \ # Install dependancies required
&& apt clean -y \ RUN apt update -y && apt install -y --no-install-recommends openssl && apt autoremove -y && apt clean -y && rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/*
# Copy the fully built binary and configuration to the image
COPY --from=builder /app/target/release/mail_app mail_app COPY --from=builder /app/target/release/mail_app mail_app
COPY configuration configuration COPY configuration configuration
ENV APP_ENVIRONMENT production ENV APP_ENVIRONMENT production
ENTRYPOINT ["./mail_app"]
ENTRYPOINT ["./mail_app"]

View File

@ -5,4 +5,4 @@ database:
port: 5432 port: 5432
username: "postgres" username: "postgres"
password: "password" password: "password"
database_name: "newsletter" database_name: "newsletter"

5
configuration/drone.yaml Normal file
View File

@ -0,0 +1,5 @@
application:
host: 0.0.0.0
database:
host: "postgres"
require_ssl: false

View File

@ -1,4 +1,4 @@
application: application:
host: 127.0.0.1 host: 127.0.0.1
database: database:
require_ssl: false require_ssl: false

View File

@ -1,4 +1,4 @@
application: application:
host: 0.0.0.0 host: 0.0.0.0
database: database:
require_ssl: true require_ssl: true

View File

@ -1,6 +1,5 @@
-- migrations/{timestamp}_create_subscriptions_table.sql
-- Create Subscriptions Table -- Create Subscriptions Table
CREATE TABLE subscriptions( CREATE TABLE Subscriptions(
id uuid NOT NULL, id uuid NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,

53
scripts/init_db.sh Executable file → Normal file
View File

@ -3,49 +3,50 @@ set -x
set -eo pipefail set -eo pipefail
if ! [ -x "$(command -v psql)" ]; then if ! [ -x "$(command -v psql)" ]; then
echo >&2 "Error: psql is not installed." echo >&2 "Error: `psql` is not installed."
exit 1 echo >&2 "Use:"
echo >&2 " sudo apt update && sudo apt install postgresql-client"
echo >&2 "to install it."
exit 1
fi fi
if ! [ -x "$(command -v sqlx)" ]; then if ! [ -x "$(command -v sqlx)" ]; then
echo >&2 "Error: sqlx is not installed." echo >&2 "Error: `sqlx` is not installed."
echo >&2 "Use:" echo >&2 "Use:"
echo >&2 " cargo install --version="~0.6" sqlx-cli" echo >&2 " sudo apt install build-essential pkg-config libssl-dev"
echo >&2 " --no-default-features --feature rustls,postgres" echo >&2 " cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres"
echo >&2 "to install." echo >&2 "to install it."
exit 1 exit 1
fi fi
DB_USER="${POSTGRES_USER:=postgres}" DB_USER=${POSTGRES_USER:=postgres}
DB_PASSWORD="${POSTGRES_PASSWORD:=password}" DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=newsletter}" DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}" DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
# Allow to skip Docker installation if Postgres is already running
if [[ -z "${SKIP_DOCKER}" ]] if [[ -z "${SKIP_DOCKER}" ]]
then then
docker run \ docker run \
-e POSTGRES_USER=${DB_USER} \ --name postgres-db \
-e POSTGRES_PASSWORD=${DB_PASSWORD} \ -e POSTGRES_USER=${DB_USER} \
-e POSTGRES_DB=${DB_NAME} \ -e POSTGRES_PASSWORD=${DB_PASSWORD} \
-p "${DB_PORT}":5432 \ -e POSTGRES_DB=${DB_NAME} \
--name "mailAppDB" \ -p "${DB_PORT}":5432 \
-d postgres:alpine \ -d postgres \
postgres -N 1000 postgres -N 1000
fi fi
# Ping until ready to accept commands
export PGPASSWORD="${DB_PASSWORD}" export PGPASSWORD="${DB_PASSWORD}"
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
>&2 echo "Postgres is still unavailable - sleeping" >&2 echo "Postgres is still unavailable - sleeping"
sleep 1 sleep 1
done done
>&2 echo "Postgres is running on port ${DB_PORT}, and ready to accept commands!" >&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations."
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
export DATABASE_URL
sqlx database create sqlx database create
sqlx migrate run sqlx migrate run
>&2 echo "Postgres has been migrated, ready for queries!" >&2 echo "Postgres has been migrated. Jobs Complete."

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -x
set -eo pipefail
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}"
for dbname in $(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "copy (select datname from pg_database where datname like '%-%-%-%-%') to stdout") ; do
echo "dropping database $dbname"
dropdb -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" "$dbname"
done

41
spec.yaml Normal file
View File

@ -0,0 +1,41 @@
#! spec.yaml
name: rust-project
region: sgp1
services:
- name: rust-project
dockerfile_path: Dockerfile
source_dir: .
github:
repo: NickBland/mailApp
branch: master
deploy_on_push: true
health_check:
http_path: /health_check
http_port: 8000
instance_count: 1
instance_size_slug: basic-xxs
routes:
- path: /
envs:
- key: APP_DATABASE__USERNAME
scope: RUN_TIME
value: ${newsletter.USERNAME}
- key: APP_DATABASE__PASSWORD
scope: RUN_TIME
value: ${newsletter.PASSWORD}
- key: APP_DATABASE__HOST
scope: RUN_TIME
value: ${newsletter.HOSTNAME}
- key: APP_DATABASE__PORT
scope: RUN_TIME
value: ${newsletter.PORT}
- key: APP_DATABASE__DATABASE_NAME
scope: RUN_TIME
value: ${newsletter.DATABASE}
databases:
- engine: PG
name: newsletter
num_nodes: 1
size: deb-s-dev-database
version: "12"

18
sqlx-data.json Normal file
View File

@ -0,0 +1,18 @@
{
"db": "PostgreSQL",
"793f0df728d217c204123f12e4eafd6439db2d49d0cb506618ae9e780c7e0558": {
"query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
}
}
}

View File

@ -1,6 +1,8 @@
use secrecy::{ExposeSecret, Secret}; use std::convert::{TryFrom, TryInto};
use serde_aux::field_attributes::deserialize_number_from_string; use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::{PgConnectOptions, PgSslMode}; use sqlx::postgres::{PgConnectOptions, PgSslMode};
use sqlx::ConnectOptions;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct Settings { pub struct Settings {
@ -8,17 +10,6 @@ pub struct Settings {
pub application: ApplicationSettings, pub application: ApplicationSettings,
} }
#[derive(serde::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(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct ApplicationSettings { pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")] #[serde(deserialize_with = "deserialize_number_from_string")]
@ -26,62 +17,15 @@ pub struct ApplicationSettings {
pub host: String, pub host: String,
} }
pub enum Environment { #[derive(serde::Deserialize)]
Local, pub struct DatabaseSettings {
Production, pub username: String,
} pub password: String,
#[serde(deserialize_with = "deserialize_number_from_string")]
impl Environment { pub port: u16,
pub fn as_str(&self) -> &'static str { pub host: String,
match self { pub database_name: String,
Environment::Local => "local", pub require_ssl: bool,
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Environment::Local),
"production" => Ok(Environment::Production),
e => Err(format!(
"{} is not a supported environment. Use `local` or `production`",
e
)),
}
}
}
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let configuration_directory = base_path.join("configuration");
// Detect current environment, default to LOCAL
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let environment_filename = format!("{}.yaml", environment.as_str());
// initialise config reader
let settings = config::Config::builder()
.add_source(config::File::from(
configuration_directory.join("base.yaml"),
))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
} }
impl DatabaseSettings { impl DatabaseSettings {
@ -92,14 +36,71 @@ impl DatabaseSettings {
PgSslMode::Prefer PgSslMode::Prefer
}; };
PgConnectOptions::new() PgConnectOptions::new()
.username(&self.username)
.password(self.password.expose_secret())
.host(&self.host) .host(&self.host)
.username(&self.username)
.password(&self.password)
.port(self.port) .port(self.port)
.ssl_mode(ssl_mode) .ssl_mode(ssl_mode)
} }
pub fn with_db(&self) -> PgConnectOptions { pub fn with_db(&self) -> PgConnectOptions {
self.without_db().database(&self.database_name) let mut options = self.without_db().database(&self.database_name);
options.log_statements(tracing::log::LevelFilter::Trace);
options
} }
} }
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
// Initialise configuration reader
let mut settings = config::Config::default();
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let configuration_directory = base_path.join("configuration");
// Read default config file
settings.merge(config::File::from(configuration_directory.join("base")).required(true))?;
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT.");
settings.merge(
config::File::from(configuration_directory.join(environment.as_str())).required(true),
)?;
settings.merge(config::Environment::with_prefix("app").separator("__"))?;
// Try convert into Settings type
settings.try_into()
}
pub enum Environment {
Local,
Production,
Drone
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
Environment::Drone => "drone",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
"drone" => Ok(Self::Drone),
other => Err(format!(
"{} is nto a supported environment. Use either `local`, `production` or `drone`.", other
)),
}
}
}

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

@ -0,0 +1,7 @@
mod subscriber_name;
mod subscriber_email;
mod new_subscriber;
pub use subscriber_name::SubscriberName;
pub use new_subscriber::NewSubscriber;
pub use subscriber_email::SubscriberEmail;

View File

@ -0,0 +1,7 @@
use crate::domain::SubscriberName;
use crate::domain::SubscriberEmail;
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View File

@ -0,0 +1,61 @@
use validator::validate_email;
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if validate_email(&s) {
Ok(Self(s))
} else {
Err(format!("{} is not a valid email address", s))
}
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use claim::assert_err;
use fake::faker::internet::en::SafeEmail;
use fake::Fake;
#[test]
fn empty_string_is_rejected() {
let email = "".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_at_symbol_is_rejected() {
let email = "ursuladomain.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_subject_is_rejected() {
let email = "@domain.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[derive(Debug, Clone)]
struct ValidEmailFixture(pub String);
impl quickcheck::Arbitrary for ValidEmailFixture {
fn arbitrary<G: quickcheck::Gen>(g: &mut G) -> Self {
let email = SafeEmail().fake_with_rng(g);
Self(email)
}
}
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::parse(valid_email.0).is_ok()
}
}

View File

@ -0,0 +1,64 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = s.trim().is_empty(); // Remove trailing whitespaces and check if containsd any characters
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g)); // Iterate to check if name contains any of the forbidden characters
// Return `false` if any conditions are violated
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
Err(format!("{} is not a valid subscriber name", s))
} else {
Ok(Self(s))
}
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use crate::domain::SubscriberName;
use claim::{assert_err, assert_ok};
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
let name = "a".repeat(257);
assert_err!(SubscriberName::parse(name));
}
#[test]
fn whitespace_only_names_are_rejected() {
let name = " ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn names_containing_invalid_characters_are_rejected() {
for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Ursula Le Guin".to_string();
assert_ok!(SubscriberName::parse(name));
}
}

View File

@ -1,4 +1,6 @@
pub mod configuration; #![allow(clippy::toplevel_ref_arg)]
pub mod routes; pub mod configuration;
pub mod startup; pub mod routes;
pub mod telemetry; pub mod startup;
pub mod telemetry;
pub mod domain;

View File

@ -1,20 +1,26 @@
use mail_app::configuration::get_configuration;
use mail_app::startup::run;
use mail_app::telemetry::{get_subscriber, init_subscriber};
use sqlx::postgres::PgPoolOptions;
use std::net::TcpListener; use std::net::TcpListener;
use sqlx::postgres::PgPoolOptions;
use mail_app::startup::run;
use mail_app::configuration::get_configuration;
use mail_app::telemetry::{get_subscriber, init_subscriber};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), std::io::Error> { async fn main() -> std::io::Result<()> {
let subscriber = get_subscriber("mail_app".into(), "info".into(), std::io::stdout); let subscriber = get_subscriber("mail_app".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber); init_subscriber(subscriber);
let configuration = get_configuration().expect("Failed to read configuration"); // Attempt to read from config
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); let configuration = get_configuration().expect("Failed to read configuration data.");
let address = format!(
"{}:{}", // Configure connection to database for our startup
configuration.application.host, configuration.application.port let connection_pool = PgPoolOptions::new()
); .connect_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(configuration.database.with_db());
// Take port from settings file
let address = format!("{}:{}", configuration.application.host, configuration.application.port);
let listener = TcpListener::bind(address)?; let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await run(listener, connection_pool)?.await?;
} Ok(())
}

View File

@ -2,4 +2,4 @@ use actix_web::HttpResponse;
pub async fn health_check() -> HttpResponse { pub async fn health_check() -> HttpResponse {
HttpResponse::Ok().finish() HttpResponse::Ok().finish()
} }

View File

@ -2,4 +2,4 @@ mod health_check;
mod subscriptions; mod subscriptions;
pub use health_check::*; pub use health_check::*;
pub use subscriptions::*; pub use subscriptions::*;

View File

@ -1,14 +1,27 @@
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use chrono::Utc;
use sqlx::PgPool; use sqlx::PgPool;
use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
use crate::domain::{NewSubscriber, SubscriberName, SubscriberEmail};
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct FormData { pub struct FormData {
email: String, email: String,
name: String, name: String
} }
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let name = SubscriberName::parse(value.name)?;
let email = SubscriberEmail::parse(value.email)?;
Ok(Self { email, name })
}
}
#[allow(clippy::async_yields_async)]
#[tracing::instrument( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(form, pool), skip(form, pool),
@ -17,9 +30,12 @@ pub struct FormData {
subscriber_name = %form.name subscriber_name = %form.name
) )
)] )]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>,) -> HttpResponse {
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse { let new_subscriber = match form.0.try_into() {
match insert_subscriber(&pool, &form).await { Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(), Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(), Err(_) => HttpResponse::InternalServerError().finish(),
} }
@ -27,18 +43,17 @@ pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> Ht
#[tracing::instrument( #[tracing::instrument(
name = "Saving new subscriber details in the database", name = "Saving new subscriber details in the database",
skip(form, pool) skip(new_subscriber, pool)
)] )]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sqlx::Error> {
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at) INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
"#, "#,
Uuid::new_v4(), Uuid::new_v4(),
form.email, new_subscriber.email.as_ref(),
form.name, new_subscriber.name.as_ref(),
Utc::now() Utc::now()
) )
.execute(pool) .execute(pool)
@ -48,4 +63,4 @@ pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sql
e e
})?; })?;
Ok(()) Ok(())
} }

View File

@ -1,10 +1,12 @@
use crate::routes::{health_check, subscribe}; use actix_web::{web, App, HttpServer};
use actix_web::dev::Server; use actix_web::dev::Server;
use actix_web::{web, web::Data, App, HttpServer}; use actix_web::web::Data;
use sqlx::PgPool;
use std::net::TcpListener; use std::net::TcpListener;
use sqlx::PgPool;
use tracing_actix_web::TracingLogger; use tracing_actix_web::TracingLogger;
use crate::routes::{health_check, subscribe};
pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> { pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool); let db_pool = Data::new(db_pool);
let server = HttpServer::new(move || { let server = HttpServer::new(move || {
@ -17,4 +19,4 @@ pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Er
.listen(listener)? .listen(listener)?
.run(); .run();
Ok(server) Ok(server)
} }

View File

@ -1,18 +1,18 @@
use tracing::{subscriber::set_global_default, Subscriber}; use tracing::{Subscriber, subscriber::set_global_default};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer; use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry}; use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry, fmt::MakeWriter};
/// Compose multiple layers into a tracing compatible subscriber
pub fn get_subscriber<Sink>( pub fn get_subscriber<Sink>(
name: String, name: String,
env_filter: String, env_filter: String,
sink: Sink, sink: Sink,
) -> impl Subscriber + Send + Sync ) -> impl Subscriber + Sync + Send
where where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static, Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{ {
let env_filter = let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let formatting_layer = BunyanFormattingLayer::new(name, sink); let formatting_layer = BunyanFormattingLayer::new(name, sink);
Registry::default() Registry::default()
.with(env_filter) .with(env_filter)
@ -20,7 +20,8 @@ where
.with(formatting_layer) .with(formatting_layer)
} }
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) { /// Register a subscriber as global default to process span data.
LogTracer::init().expect("Failed to set logger."); pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
set_global_default(subscriber).expect("Failed to set subscriber."); LogTracer::init().expect("Failed to set logger");
} set_global_default(subscriber).expect("Failed to set subscriber");
}

View File

@ -1,145 +1,176 @@
use mail_app::configuration::{get_configuration, DatabaseSettings}; use std::net::TcpListener;
use mail_app::startup::run; use sqlx::{Connection, Executor, PgConnection, PgPool};
use mail_app::telemetry::{get_subscriber, init_subscriber}; use uuid::Uuid;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener; use mail_app::startup::run;
use uuid::Uuid; use mail_app::configuration::{get_configuration, DatabaseSettings};
use mail_app::telemetry::{get_subscriber, init_subscriber};
static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "info".to_string(); pub struct TestApp {
let subscriber_name = "test".to_string(); pub address: String,
if std::env::var("TEST_LOG").is_ok() { pub db_pool: PgPool,
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); }
init_subscriber(subscriber);
} else { static TRACING: Lazy<()> = Lazy::new(|| {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); let default_filter_level = "info".to_string();
init_subscriber(subscriber); let subscriber_name = "test".to_string();
}; if std::env::var("TEST_LOG").is_ok() {
}); let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
init_subscriber(subscriber);
pub struct TestApp { } else {
pub address: String, let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
pub db_pool: PgPool, init_subscriber(subscriber);
} };
});
async fn spawn_app() -> TestApp {
Lazy::force(&TRACING); // Create new instance of the application on a random port and return address [`http://localhost:XXXX`]
async fn spawn_app() -> TestApp {
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port."); Lazy::force(&TRACING);
let port = listener.local_addr().unwrap().port();
let address = format!("http://127.0.0.1:{}", port); let listener = TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind to random port");
let mut configuration = get_configuration().expect("Failed to read configuration"); let port = listener.local_addr().unwrap().port();
configuration.database.database_name = Uuid::new_v4().to_string(); let address = format!("http://127.0.0.1:{}", port);
let connection_pool = configure_database(&configuration.database).await;
let mut configuration = get_configuration()
let server = run(listener, connection_pool.clone()).expect("Failed to bind address"); .expect("Failed to read configuration.");
// Launch in background configuration.database.database_name = Uuid::new_v4().to_string(); // Adjust database string to be random!
let _ = tokio::spawn(server); let connection_pool = configure_database(&configuration.database).await;
TestApp { let server = run(listener, connection_pool.clone())
address, .expect("Failed to bind address");
db_pool: connection_pool, let _ = tokio::spawn(server);
} TestApp {
} address,
db_pool: connection_pool,
pub async fn configure_database(config: &DatabaseSettings) -> PgPool { }
// Create Database }
let mut connection = PgConnection::connect_with(&config.without_db())
.await pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
.expect("Failed to connect to Postgres."); // Create database
connection let mut connection = PgConnection::connect_with(&config.without_db())
.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) .await
.await .expect("Failed to connect to Postgres");
.expect("Failed to create database."); connection
.execute(&*format!(r#"CREATE DATABASE "{}";"#, config.database_name))
// Migrate Database .await
let connection_pool = PgPool::connect_with(config.with_db()) .expect("Failed to create database.");
.await
.expect("Failed to connect to Postgres."); // Migrate database
sqlx::migrate!("./migrations") let connection_pool = PgPool::connect_with(config.with_db())
.run(&connection_pool) .await
.await .expect("Failed to connect to Postgres.");
.expect("Failed to migrate the database."); sqlx::migrate!("./migrations")
.run(&connection_pool)
// Return connection pool .await
connection_pool .expect("Failed to migrate the database");
}
connection_pool
#[tokio::test] }
async fn health_check_works() {
// Arrange #[tokio::test]
let app = spawn_app().await; async fn health_check_works() {
let client = reqwest::Client::new(); // Arrange
let app = spawn_app().await;
// Act let client = reqwest::Client::new();
let response = client
.get(&format!("{}/health_check", &app.address)) // Act
.send() let response = client
.await .get(&format!("{}/health_check", &app.address))
.expect("Failed to execute request"); .send()
.await
// Assert .expect("Failed to execute request.");
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length()); // Assert our test
} assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
#[tokio::test] }
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange #[tokio::test]
let app = spawn_app().await; async fn subscribe_returns_200_for_valid_form_data() {
let client = reqwest::Client::new(); // Arrange
let app = spawn_app().await;
// Act let client = reqwest::Client::new();
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app.address)) // Act
.header("Content-Type", "application/x-www-form-urlencoded") let response = client
.body(body) .post(&format!("{}/subscriptions", &app.address))
.send() .header("Content-Type", "application/x-www-form-urlencoded")
.await .body(body)
.expect("Failed to execute request."); .send()
.await
// Assert .expect("Failed to execute request.");
assert_eq!(200, response.status().as_u16());
// Assert test
let saved = sqlx::query!("SELECT email, name FROM subscriptions",) assert_eq!(200, response.status().as_u16());
.fetch_one(&app.db_pool)
.await let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
.expect("Failed to fetch saved subscription."); .fetch_one(&app.db_pool)
.await
assert_eq!(saved.email, "ursula_le_guin@gmail.com"); .expect("Failed to fetch saved subscription.");
assert_eq!(saved.name, "le guin");
} assert_eq!(saved.email, "ursula_le_guin@gmail.com");
assert_eq!(saved.name, "le guin");
#[tokio::test] }
async fn subscribe_returns_a_400_when_data_is_missing() {
// Arrange #[tokio::test]
let app = spawn_app().await; async fn subscribe_returns_400_for_missing_form_data() {
let client = reqwest::Client::new(); //Arrange
let test_cases = vec![ let app = spawn_app().await;
("name=le%20guin", "missing email"), let client = reqwest::Client::new();
("email=ursula_le_guin%40gmail.com", "missing name"), let test_cases = vec![
("", "missing both name and email"), ("name=le%20guin", "missing the email"),
]; ("email=ursula_le_guin%40gmail.com", "missing the name"),
("", "missing both name and email")
for (invalid_body, error_message) in test_cases { ];
// Act
let response = client for (invalid_body, error_message) in test_cases {
.post(&format!("{}/subscriptions", &app.address)) // Act
.header("Content-Type", "application/x-www-form-urlencoded") let response = client
.body(invalid_body) .post(&format!("{}/subscriptions", &app.address))
.send() .header("Content-Type", "application/x-www-form-urlencoded")
.await .body(invalid_body)
.expect("Failed to execute request."); .send()
.await
// Assert .expect("Failed to execute request.");
assert_eq!(
400, // Assert
response.status().as_u16(), assert_eq!(
"The API id not fail wth 400 Bad Request when the payload was {}.", 400,
error_message response.status().as_u16(),
); // Customised error message on test failure
} "The API did not fail with 400 Bad Request when the payload was {}.", error_message
} );
}
}
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_empty() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
for (body, description) in test_cases {
// Act
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.", description
);
}
}