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
.vscode
#.env
.gitlab-ci-local
.DS_Store
# Added by cargo
#
# already existing elements were commented out
#/target
.env

View File

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

View File

@ -1,57 +1,30 @@
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:
before_script:
- export CARGO_HOME="$CI_PROJECT_DIR/.cargo"
- export PATH="$CARGO_HOME/bin:$PATH"
- rustc --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:
- build
- test
build:
stage: build
script:
- cargo build
test-code:
stage: test
script:
- cargo test
- if ! [ -x "$(command -v cargo-tarpaulin)" ]; then cargo install cargo-tarpaulin; fi
- cargo install cargo-tarpaulin
- cargo tarpaulin --ignore-tests
lint-code:
stage: test
script:
- rustup component add rustfmt
- cargo fmt -- --check
- rustup component add clippy
- 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]
name = "mail_app"
version = "0.1.0"
authors = ["NickBland <nick.bland@nickbland.dev>"]
edition = "2021"
[lib]
@ -11,32 +12,27 @@ path = "src/main.rs"
name = "mail_app"
[dependencies]
actix-web = "4"
actix-web = "=4.0.0-beta.16"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde-aux = "4"
config = { version = "0.13", default-features = false, features = ["yaml"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
tracing = { version = "0.1", features = ["log"] }
serde = "1.0.115"
config = { version = "0.11", 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 = "0.8.1", features = ["v4"] }
chrono = "0.4.15"
tracing = "0.1.19"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = "0.3"
tracing-actix-web = "0.7"
tracing-log = "0.1"
tracing-bunyan-formatter = "0.3.1"
tracing-log = "0.1.1"
serde-aux = "3"
tracing-actix-web = "0.5.0-beta.7"
secrecy = { version = "0.8", features = ["serde"] }
[dependencies.sqlx]
version = "0.7"
default-features = false
features = [
"runtime-tokio-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
]
unicode-segmentation = "1"
validator = "0.14"
[dev-dependencies]
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.76.0 as chef
FROM lukemathwalker/cargo-chef:latest-rust-1.57.0 AS chef
WORKDIR /app
RUN apt update && apt install lld clang -y
##### Planner
FROM chef as planner
# Create lock file for project to be used in builder
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
##### Builder
# Builder prepares project dependancies, not the application.
# Build dependancies
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
# Now build the application itself.
RUN cargo build --release --bin mail_app
RUN cargo build --release
##### Runtime
FROM debian:bookworm-slim as runtime
FROM debian:bullseye-slim AS runtime
WORKDIR /app
RUN apt update && apt install -y --no-install-recommends openssl ca-certificates \
&& apt autoremove -y \
&& apt clean -y \
&& rm -rf /var/lib/apt/lists/*
# Install dependancies required
RUN apt update -y && apt install -y --no-install-recommends openssl && apt autoremove -y && apt clean -y && 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 configuration configuration
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./mail_app"]

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

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

@ -3,49 +3,50 @@ set -x
set -eo pipefail
if ! [ -x "$(command -v psql)" ]; then
echo >&2 "Error: psql is not installed."
echo >&2 "Error: `psql` is not installed."
echo >&2 "Use:"
echo >&2 " sudo apt update && sudo apt install postgresql-client"
echo >&2 "to install it."
exit 1
fi
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 " cargo install --version="~0.6" sqlx-cli"
echo >&2 " --no-default-features --feature rustls,postgres"
echo >&2 "to install."
echo >&2 " sudo apt install build-essential pkg-config libssl-dev"
echo >&2 " cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres"
echo >&2 "to install it."
exit 1
fi
DB_USER="${POSTGRES_USER:=postgres}"
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}"
# Allow to skip Docker installation if Postgres is already running
if [[ -z "${SKIP_DOCKER}" ]]
then
docker run \
--name postgres-db \
-e POSTGRES_USER=${DB_USER} \
-e POSTGRES_PASSWORD=${DB_PASSWORD} \
-e POSTGRES_DB=${DB_NAME} \
-p "${DB_PORT}":5432 \
--name "mailAppDB" \
-d postgres:alpine \
-d postgres \
postgres -N 1000
fi
# Ping until ready to accept commands
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"
sleep 1
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
export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
sqlx database create
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 sqlx::postgres::{PgConnectOptions, PgSslMode};
use sqlx::ConnectOptions;
#[derive(serde::Deserialize)]
pub struct Settings {
@ -8,17 +10,6 @@ pub struct Settings {
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)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
@ -26,62 +17,15 @@ pub struct ApplicationSettings {
pub host: String,
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
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>()
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: String,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
impl DatabaseSettings {
@ -92,14 +36,71 @@ impl DatabaseSettings {
PgSslMode::Prefer
};
PgConnectOptions::new()
.username(&self.username)
.password(self.password.expose_secret())
.host(&self.host)
.username(&self.username)
.password(&self.password)
.port(self.port)
.ssl_mode(ssl_mode)
}
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 @@
#![allow(clippy::toplevel_ref_arg)]
pub mod configuration;
pub mod routes;
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 sqlx::postgres::PgPoolOptions;
use mail_app::startup::run;
use mail_app::configuration::get_configuration;
use mail_app::telemetry::{get_subscriber, init_subscriber};
#[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);
init_subscriber(subscriber);
let configuration = get_configuration().expect("Failed to read configuration");
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
// Attempt to read from config
let configuration = get_configuration().expect("Failed to read configuration data.");
// Configure connection to database for our startup
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)?;
run(listener, connection_pool)?.await
run(listener, connection_pool)?.await?;
Ok(())
}

View File

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

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::{web, web::Data, App, HttpServer};
use sqlx::PgPool;
use actix_web::web::Data;
use std::net::TcpListener;
use sqlx::PgPool;
use tracing_actix_web::TracingLogger;
use crate::routes::{health_check, subscribe};
pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool);
let server = HttpServer::new(move || {

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_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>(
name: String,
env_filter: String,
sink: Sink,
) -> impl Subscriber + Send + Sync
) -> impl Subscriber + Sync + Send
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let formatting_layer = BunyanFormattingLayer::new(name, sink);
Registry::default()
.with(env_filter)
@ -20,7 +20,8 @@ where
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger.");
set_global_default(subscriber).expect("Failed to set subscriber.");
/// Register a subscriber as global default to process span data.
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");
}

View File

@ -1,10 +1,16 @@
use mail_app::configuration::{get_configuration, DatabaseSettings};
use mail_app::startup::run;
use mail_app::telemetry::{get_subscriber, init_subscriber};
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
use once_cell::sync::Lazy;
use mail_app::startup::run;
use mail_app::configuration::{get_configuration, DatabaseSettings};
use mail_app::telemetry::{get_subscriber, init_subscriber};
pub struct TestApp {
pub address: String,
pub db_pool: PgPool,
}
static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "info".to_string();
@ -18,26 +24,23 @@ static TRACING: Lazy<()> = Lazy::new(|| {
};
});
pub struct TestApp {
pub address: String,
pub db_pool: PgPool,
}
// Create new instance of the application on a random port and return address [`http://localhost:XXXX`]
async fn spawn_app() -> TestApp {
Lazy::force(&TRACING);
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port.");
let listener = TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind to random port");
let port = listener.local_addr().unwrap().port();
let address = format!("http://127.0.0.1:{}", port);
let mut configuration = get_configuration().expect("Failed to read configuration");
configuration.database.database_name = Uuid::new_v4().to_string();
let mut configuration = get_configuration()
.expect("Failed to read configuration.");
configuration.database.database_name = Uuid::new_v4().to_string(); // Adjust database string to be random!
let connection_pool = configure_database(&configuration.database).await;
let server = run(listener, connection_pool.clone()).expect("Failed to bind address");
// Launch in background
let server = run(listener, connection_pool.clone())
.expect("Failed to bind address");
let _ = tokio::spawn(server);
TestApp {
address,
db_pool: connection_pool,
@ -45,25 +48,24 @@ async fn spawn_app() -> TestApp {
}
pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
// Create Database
// Create database
let mut connection = PgConnection::connect_with(&config.without_db())
.await
.expect("Failed to connect to Postgres.");
.expect("Failed to connect to Postgres");
connection
.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
.execute(&*format!(r#"CREATE DATABASE "{}";"#, config.database_name))
.await
.expect("Failed to create database.");
// Migrate Database
// Migrate database
let connection_pool = PgPool::connect_with(config.with_db())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations")
.run(&connection_pool)
.await
.expect("Failed to migrate the database.");
.expect("Failed to migrate the database");
// Return connection pool
connection_pool
}
@ -78,21 +80,21 @@ async fn health_check_works() {
.get(&format!("{}/health_check", &app.address))
.send()
.await
.expect("Failed to execute request");
.expect("Failed to execute request.");
// Assert
// 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() {
async fn subscribe_returns_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
// Act
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
@ -101,7 +103,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() {
.await
.expect("Failed to execute request.");
// Assert
// Assert test
assert_eq!(200, response.status().as_u16());
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
@ -114,14 +116,14 @@ async fn subscribe_returns_a_200_for_valid_form_data() {
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
async fn subscribe_returns_400_for_missing_form_data() {
//Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=le%20guin", "missing email"),
("email=ursula_le_guin%40gmail.com", "missing name"),
("", "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 {
@ -138,8 +140,37 @@ async fn subscribe_returns_a_400_when_data_is_missing() {
assert_eq!(
400,
response.status().as_u16(),
"The API id not fail wth 400 Bad Request when the payload was {}.",
error_message
// 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
);
}
}