Fault-tolerant delivery system
This commit is contained in:
26
.sqlx/query-06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a.json
generated
Normal file
26
.sqlx/query-06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT newsletter_issue_id, subscriber_email\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "newsletter_issue_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "subscriber_email",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a"
|
||||||
|
}
|
||||||
41
.sqlx/query-0851bf5e8d147f0ace037c6f434bcc4e04d330e3c4259ef8c8097e61f77b64e2.json
generated
Normal file
41
.sqlx/query-0851bf5e8d147f0ace037c6f434bcc4e04d330e3c4259ef8c8097e61f77b64e2.json
generated
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE idempotency\n SET\n response_status_code = $3,\n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Int2",
|
||||||
|
{
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair[]",
|
||||||
|
"kind": {
|
||||||
|
"Array": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair",
|
||||||
|
"kind": {
|
||||||
|
"Composite": [
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"Text"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"value",
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0851bf5e8d147f0ace037c6f434bcc4e04d330e3c4259ef8c8097e61f77b64e2"
|
||||||
|
}
|
||||||
34
.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json
generated
Normal file
34
.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json
generated
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT title, text_content, html_content\n FROM newsletter_issues\n WHERE newsletter_issue_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "text_content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "html_content",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46"
|
||||||
|
}
|
||||||
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal file
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO newsletter_issues (\n newsletter_issue_id, title, text_content, html_content, published_at\n )\n VALUES ($1, $2, $3, $4, now())\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2"
|
||||||
|
}
|
||||||
14
.sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json
generated
Normal file
14
.sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email\n )\n SELECT $1, email\n FROM subscriptions\n WHERE status = 'confirmed'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d"
|
||||||
|
}
|
||||||
28
.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json
generated
Normal file
28
.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58"
|
||||||
|
}
|
||||||
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal file
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM issue_delivery_queue\n WHERE\n newsletter_issue_id = $1\n AND subscriber_email = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249"
|
||||||
|
}
|
||||||
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal file
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users SET password_hash = $1 WHERE user_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b"
|
||||||
|
}
|
||||||
58
.sqlx/query-ed9f14ed1476ef5a9dc8b7aabf38fd31e127e2a6246d5a14f4ef624f0302eac8.json
generated
Normal file
58
.sqlx/query-ed9f14ed1476ef5a9dc8b7aabf38fd31e127e2a6246d5a14f4ef624f0302eac8.json
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "response_status_code!",
|
||||||
|
"type_info": "Int2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "response_headers!: Vec<HeaderPairRecord>",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair[]",
|
||||||
|
"kind": {
|
||||||
|
"Array": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "header_pair",
|
||||||
|
"kind": {
|
||||||
|
"Composite": [
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"Text"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"value",
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "response_body!",
|
||||||
|
"type_info": "Bytea"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ed9f14ed1476ef5a9dc8b7aabf38fd31e127e2a6246d5a14f4ef624f0302eac8"
|
||||||
|
}
|
||||||
15
.sqlx/query-f007c2d5d9ae67a2412c6a70a2228390c5bd4835fcf71fd17a00fe521b43415d.json
generated
Normal file
15
.sqlx/query-f007c2d5d9ae67a2412c6a70a2228390c5bd4835fcf71fd17a00fe521b43415d.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f007c2d5d9ae67a2412c6a70a2228390c5bd4835fcf71fd17a00fe521b43415d"
|
||||||
|
}
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3818,6 +3818,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sha3",
|
"sha3",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -60,4 +60,5 @@ once_cell = "1.21.3"
|
|||||||
quickcheck = "1.0.3"
|
quickcheck = "1.0.3"
|
||||||
quickcheck_macros = "1.1.0"
|
quickcheck_macros = "1.1.0"
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
|
serde_urlencoded = "0.7.1"
|
||||||
wiremock = "0.6.4"
|
wiremock = "0.6.4"
|
||||||
|
|||||||
@@ -3,7 +3,3 @@ application:
|
|||||||
base_url: "http://127.0.0.1:8000"
|
base_url: "http://127.0.0.1:8000"
|
||||||
database:
|
database:
|
||||||
require_ssl: false
|
require_ssl: false
|
||||||
email_client:
|
|
||||||
base_url: "https://api.mailersend.com"
|
|
||||||
sender_email: "MS_PTrumQ@test-r6ke4n1mmzvgon12.mlsender.net"
|
|
||||||
authorization_token: "mlsn.9ea7aaeaa328b4d2eac74dc823deecf25e6bae1933bfe5c3d0304b1b3f2bc36c"
|
|
||||||
|
|||||||
14
migrations/20250901135528_create_idempotency_table.sql
Normal file
14
migrations/20250901135528_create_idempotency_table.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TYPE header_pair AS (
|
||||||
|
name TEXT,
|
||||||
|
value BYTEA
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE idempotency (
|
||||||
|
user_id UUID NOT NULL REFERENCES users (user_id),
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
response_status_code SMALLINT NOT NULL,
|
||||||
|
response_headers header_pair[] NOT NULL,
|
||||||
|
response_body BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, idempotency_key)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE idempotency ALTER COLUMN response_status_code DROP NOT NULL;
|
||||||
|
ALTER TABLE idempotency ALTER COLUMN response_body DROP NOT NULL;
|
||||||
|
ALTER TABLE idempotency ALTER COLUMN response_headers DROP NOT NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE newsletter_issues (
|
||||||
|
newsletter_issue_id UUID NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
text_content TEXT NOT NULL,
|
||||||
|
html_content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (newsletter_issue_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE issue_delivery_queue (
|
||||||
|
newsletter_issue_id UUID NOT NULL
|
||||||
|
REFERENCES newsletter_issues (newsletter_issue_id),
|
||||||
|
subscriber_email TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (newsletter_issue_id, subscriber_email)
|
||||||
|
);
|
||||||
@@ -121,7 +121,7 @@ async fn get_stored_credentials(
|
|||||||
SELECT user_id, password_hash
|
SELECT user_id, password_hash
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
"#,
|
"#,
|
||||||
username,
|
username,
|
||||||
)
|
)
|
||||||
.fetch_optional(connection_pool)
|
.fetch_optional(connection_pool)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ impl TryFrom<String> for Environment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub application: ApplicationSettings,
|
pub application: ApplicationSettings,
|
||||||
pub database: DatabaseSettings,
|
pub database: DatabaseSettings,
|
||||||
@@ -63,7 +63,7 @@ pub struct Settings {
|
|||||||
pub redis_uri: SecretString,
|
pub redis_uri: SecretString,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct ApplicationSettings {
|
pub struct ApplicationSettings {
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -71,7 +71,7 @@ pub struct ApplicationSettings {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct EmailClientSettings {
|
pub struct EmailClientSettings {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
sender_email: String,
|
sender_email: String,
|
||||||
@@ -100,7 +100,7 @@ impl EmailClientSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct DatabaseSettings {
|
pub struct DatabaseSettings {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: SecretString,
|
pub password: SecretString,
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ pub struct EmailClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EmailClient {
|
impl EmailClient {
|
||||||
pub fn new(config: EmailClientSettings) -> Self {
|
pub fn build(config: EmailClientSettings) -> Result<Self, anyhow::Error> {
|
||||||
Self {
|
let client = Self {
|
||||||
http_client: Client::builder()
|
http_client: Client::builder()
|
||||||
.timeout(Duration::from_millis(config.timeout_milliseconds))
|
.timeout(Duration::from_millis(config.timeout_milliseconds))
|
||||||
.build()
|
.build()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
base_url: reqwest::Url::parse(&config.base_url).unwrap(),
|
base_url: reqwest::Url::parse(&config.base_url)?,
|
||||||
sender: config.sender().unwrap(),
|
sender: config.sender().map_err(|e| anyhow::anyhow!(e))?,
|
||||||
authorization_token: config.authorization_token,
|
authorization_token: config.authorization_token,
|
||||||
}
|
};
|
||||||
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_email(
|
pub async fn send_email(
|
||||||
@@ -125,7 +126,7 @@ mod tests {
|
|||||||
let sender_email = SafeEmail().fake();
|
let sender_email = SafeEmail().fake();
|
||||||
let token: String = Faker.fake();
|
let token: String = Faker.fake();
|
||||||
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
|
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
|
||||||
EmailClient::new(settings)
|
EmailClient::build(settings).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
5
src/idempotency.rs
Normal file
5
src/idempotency.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod key;
|
||||||
|
mod persistance;
|
||||||
|
|
||||||
|
pub use key::*;
|
||||||
|
pub use persistance::*;
|
||||||
28
src/idempotency/key.rs
Normal file
28
src/idempotency/key.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
pub struct IdempotencyKey(String);
|
||||||
|
|
||||||
|
impl TryFrom<String> for IdempotencyKey {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err("The idempotency key cannot be empty.".into());
|
||||||
|
}
|
||||||
|
let max_length = 50;
|
||||||
|
if value.len() >= max_length {
|
||||||
|
return Err("The idempotency key must be shorter than {max_length} characters.".into());
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IdempotencyKey> for String {
|
||||||
|
fn from(value: IdempotencyKey) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for IdempotencyKey {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/idempotency/persistance.rs
Normal file
128
src/idempotency/persistance.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use crate::idempotency::IdempotencyKey;
|
||||||
|
use axum::{
|
||||||
|
body::{self, Body},
|
||||||
|
http::{HeaderName, HeaderValue},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "header_pair")]
|
||||||
|
struct HeaderPairRecord {
|
||||||
|
name: String,
|
||||||
|
value: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_saved_response(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
idempotency_key: &IdempotencyKey,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<Response>, anyhow::Error> {
|
||||||
|
let saved_response = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
response_status_code as "response_status_code!",
|
||||||
|
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
||||||
|
response_body as "response_body!"
|
||||||
|
FROM idempotency
|
||||||
|
WHERE
|
||||||
|
user_id = $1
|
||||||
|
AND idempotency_key = $2
|
||||||
|
"#,
|
||||||
|
user_id,
|
||||||
|
idempotency_key.as_ref()
|
||||||
|
)
|
||||||
|
.fetch_optional(connection_pool)
|
||||||
|
.await?;
|
||||||
|
if let Some(r) = saved_response {
|
||||||
|
let status_code = StatusCode::from_u16(r.response_status_code.try_into()?)?;
|
||||||
|
let mut response = status_code.into_response();
|
||||||
|
for HeaderPairRecord { name, value } in r.response_headers {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_str(&name).unwrap(),
|
||||||
|
HeaderValue::from_bytes(&value).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*response.body_mut() = r.response_body.into();
|
||||||
|
Ok(Some(response))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_response(
|
||||||
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
|
idempotency_key: &IdempotencyKey,
|
||||||
|
user_id: Uuid,
|
||||||
|
response: Response<Body>,
|
||||||
|
) -> Result<Response<Body>, anyhow::Error> {
|
||||||
|
let status_code = response.status().as_u16() as i16;
|
||||||
|
let headers = response
|
||||||
|
.headers()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| HeaderPairRecord {
|
||||||
|
name: name.to_string(),
|
||||||
|
value: value.as_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let (response_head, body) = response.into_parts();
|
||||||
|
let body = body::to_bytes(body, usize::MAX).await?.to_vec();
|
||||||
|
|
||||||
|
let query = sqlx::query_unchecked!(
|
||||||
|
r#"
|
||||||
|
UPDATE idempotency
|
||||||
|
SET
|
||||||
|
response_status_code = $3,
|
||||||
|
response_headers = $4,
|
||||||
|
response_body = $5
|
||||||
|
WHERE
|
||||||
|
user_id = $1
|
||||||
|
AND idempotency_key = $2
|
||||||
|
"#,
|
||||||
|
user_id,
|
||||||
|
idempotency_key.as_ref(),
|
||||||
|
status_code,
|
||||||
|
headers,
|
||||||
|
&body,
|
||||||
|
);
|
||||||
|
transaction.execute(query).await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
let mut r = response_head.into_response();
|
||||||
|
*r.body_mut() = body.into();
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum NextAction {
|
||||||
|
StartProcessing(Transaction<'static, Postgres>),
|
||||||
|
ReturnSavedResponse(Response),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_processing(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
idempotency_key: &IdempotencyKey,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<NextAction, anyhow::Error> {
|
||||||
|
let mut transaction = connection_pool.begin().await?;
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO idempotency (user_id, idempotency_key, created_at)
|
||||||
|
VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"#,
|
||||||
|
user_id,
|
||||||
|
idempotency_key.as_ref()
|
||||||
|
);
|
||||||
|
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
||||||
|
if n_inserted_rows > 0 {
|
||||||
|
Ok(NextAction::StartProcessing(transaction))
|
||||||
|
} else {
|
||||||
|
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
||||||
|
Ok(NextAction::ReturnSavedResponse(saved_response))
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/issue_delivery_worker.rs
Normal file
151
src/issue_delivery_worker.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use crate::{configuration::Settings, domain::SubscriberEmail, email_client::EmailClient};
|
||||||
|
use sqlx::{Executor, PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{Span, field::display};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
|
||||||
|
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
||||||
|
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||||
|
worker_loop(connection_pool, email_client).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn worker_loop(
|
||||||
|
connection_pool: PgPool,
|
||||||
|
email_client: EmailClient,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
loop {
|
||||||
|
match try_execute_task(&connection_pool, &email_client).await {
|
||||||
|
Ok(ExecutionOutcome::EmptyQueue) => tokio::time::sleep(Duration::from_secs(10)).await,
|
||||||
|
Ok(ExecutionOutcome::TaskCompleted) => (),
|
||||||
|
Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ExecutionOutcome {
|
||||||
|
TaskCompleted,
|
||||||
|
EmptyQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
skip_all,
|
||||||
|
fields(
|
||||||
|
newsletter_issue_id=tracing::field::Empty,
|
||||||
|
subscriber_email=tracing::field::Empty
|
||||||
|
),
|
||||||
|
err
|
||||||
|
)]
|
||||||
|
pub async fn try_execute_task(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
email_client: &EmailClient,
|
||||||
|
) -> Result<ExecutionOutcome, anyhow::Error> {
|
||||||
|
let task = dequeue_task(connection_pool).await?;
|
||||||
|
if task.is_none() {
|
||||||
|
return Ok(ExecutionOutcome::EmptyQueue);
|
||||||
|
}
|
||||||
|
let (transaction, issue_id, email) = task.unwrap();
|
||||||
|
Span::current()
|
||||||
|
.record("newsletter_issue_id", display(issue_id))
|
||||||
|
.record("subscriber_email", display(&email));
|
||||||
|
match SubscriberEmail::parse(email.clone()) {
|
||||||
|
Ok(email) => {
|
||||||
|
let issue = get_issue(connection_pool, issue_id).await?;
|
||||||
|
if let Err(e) = email_client
|
||||||
|
.send_email(
|
||||||
|
&email,
|
||||||
|
&issue.title,
|
||||||
|
&issue.html_content,
|
||||||
|
&issue.text_content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
error.message = %e,
|
||||||
|
"Failed to deliver issue to confirmed subscriber. Skipping."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error.message = %e,
|
||||||
|
"Skipping a subscriber. Their stored contact details are invalid."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete_task(transaction, issue_id, &email).await?;
|
||||||
|
|
||||||
|
Ok(ExecutionOutcome::TaskCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewsletterIssue {
|
||||||
|
title: String,
|
||||||
|
text_content: String,
|
||||||
|
html_content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn get_issue(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
issue_id: Uuid,
|
||||||
|
) -> Result<NewsletterIssue, anyhow::Error> {
|
||||||
|
let issue = sqlx::query_as!(
|
||||||
|
NewsletterIssue,
|
||||||
|
r#"
|
||||||
|
SELECT title, text_content, html_content
|
||||||
|
FROM newsletter_issues
|
||||||
|
WHERE newsletter_issue_id = $1
|
||||||
|
"#,
|
||||||
|
issue_id
|
||||||
|
)
|
||||||
|
.fetch_one(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn dequeue_task(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
) -> Result<Option<(Transaction<'static, Postgres>, Uuid, String)>, anyhow::Error> {
|
||||||
|
let mut transaction = connection_pool.begin().await?;
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT newsletter_issue_id, subscriber_email
|
||||||
|
FROM issue_delivery_queue
|
||||||
|
FOR UPDATE
|
||||||
|
SKIP LOCKED
|
||||||
|
LIMIT 1
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let r = transaction.fetch_optional(query).await?;
|
||||||
|
if let Some(row) = r {
|
||||||
|
Ok(Some((
|
||||||
|
transaction,
|
||||||
|
row.get("newsletter_issue_id"),
|
||||||
|
row.get("subscriber_email"),
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn delete_task(
|
||||||
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
|
issue_id: Uuid,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM issue_delivery_queue
|
||||||
|
WHERE
|
||||||
|
newsletter_issue_id = $1
|
||||||
|
AND subscriber_email = $2
|
||||||
|
"#,
|
||||||
|
issue_id,
|
||||||
|
email
|
||||||
|
);
|
||||||
|
transaction.execute(query).await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ pub mod authentication;
|
|||||||
pub mod configuration;
|
pub mod configuration;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod email_client;
|
pub mod email_client;
|
||||||
|
pub mod idempotency;
|
||||||
|
pub mod issue_delivery_worker;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod session_state;
|
pub mod session_state;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
|
|||||||
15
src/main.rs
15
src/main.rs
@@ -1,5 +1,6 @@
|
|||||||
use zero2prod::{
|
use zero2prod::{
|
||||||
configuration::get_configuration, startup::Application, telemetry::init_subscriber,
|
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped,
|
||||||
|
startup::Application, telemetry::init_subscriber,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -7,7 +8,15 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
init_subscriber(std::io::stdout);
|
init_subscriber(std::io::stdout);
|
||||||
|
|
||||||
let configuration = get_configuration().expect("Failed to read configuration");
|
let configuration = get_configuration().expect("Failed to read configuration");
|
||||||
let application = Application::build(configuration).await?;
|
let application = Application::build(configuration.clone()).await?;
|
||||||
application.run_until_stopped().await?;
|
|
||||||
|
let application_task = tokio::spawn(application.run_until_stopped());
|
||||||
|
let worker_task = tokio::spawn(run_worker_until_stopped(configuration));
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = application_task => {},
|
||||||
|
_ = worker_task => {},
|
||||||
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ pub enum AdminError {
|
|||||||
#[error("Updating password failed.")]
|
#[error("Updating password failed.")]
|
||||||
ChangePassword,
|
ChangePassword,
|
||||||
#[error("Could not publish newsletter.")]
|
#[error("Could not publish newsletter.")]
|
||||||
Publish,
|
Publish(#[source] anyhow::Error),
|
||||||
|
#[error("The idempotency key was invalid.")]
|
||||||
|
Idempotency(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for AdminError {
|
impl std::fmt::Debug for AdminError {
|
||||||
@@ -50,7 +52,10 @@ impl IntoResponse for AdminError {
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
AdminError::NotAuthenticated => Redirect::to("/login").into_response(),
|
AdminError::NotAuthenticated => Redirect::to("/login").into_response(),
|
||||||
AdminError::ChangePassword => Redirect::to("/admin/password").into_response(),
|
AdminError::ChangePassword => Redirect::to("/admin/password").into_response(),
|
||||||
AdminError::Publish => Redirect::to("/admin/newsletters").into_response(),
|
AdminError::Publish(_) => Redirect::to("/admin/newsletters").into_response(),
|
||||||
|
AdminError::Idempotency(e) => {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<input type="text" name="title" placeholder="Subject" />
|
<input type="text" name="title" placeholder="Subject" />
|
||||||
<input type="text" name="html" placeholder="Content (HTML)" />
|
<input type="text" name="html" placeholder="Content (HTML)" />
|
||||||
<input type="text" name="text" placeholder="Content (text)" />
|
<input type="text" name="text" placeholder="Content (text)" />
|
||||||
|
<input hidden type="text" name="idempotency_key" value="{}" />
|
||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
{}
|
{}
|
||||||
|
|||||||
@@ -1,75 +1,136 @@
|
|||||||
use crate::{domain::SubscriberEmail, routes::AdminError, startup::AppState};
|
use crate::{
|
||||||
|
authentication::AuthenticatedUser,
|
||||||
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
|
routes::AdminError,
|
||||||
|
startup::AppState,
|
||||||
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Extension, Form,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_messages::Messages;
|
use axum_messages::Messages;
|
||||||
use sqlx::PgPool;
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct BodyData {
|
pub struct BodyData {
|
||||||
title: String,
|
title: String,
|
||||||
html: String,
|
html: String,
|
||||||
text: String,
|
text: String,
|
||||||
|
idempotency_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish_form(messages: Messages) -> Response {
|
pub async fn publish_newsletter_form(messages: Messages) -> Response {
|
||||||
let mut error_html = String::new();
|
let mut error_html = String::new();
|
||||||
for message in messages {
|
for message in messages {
|
||||||
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
|
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
|
||||||
}
|
}
|
||||||
|
let idempotency_key = Uuid::new_v4();
|
||||||
Html(format!(
|
Html(format!(
|
||||||
include_str!("html/send_newsletter_form.html"),
|
include_str!("html/send_newsletter_form.html"),
|
||||||
error_html
|
idempotency_key, error_html
|
||||||
))
|
))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub async fn insert_newsletter_issue(
|
||||||
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
|
title: &str,
|
||||||
|
text_content: &str,
|
||||||
|
html_content: &str,
|
||||||
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
|
let newsletter_issue_id = Uuid::new_v4();
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO newsletter_issues (
|
||||||
|
newsletter_issue_id, title, text_content, html_content, published_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
"#,
|
||||||
|
newsletter_issue_id,
|
||||||
|
title,
|
||||||
|
text_content,
|
||||||
|
html_content
|
||||||
|
);
|
||||||
|
transaction.execute(query).await?;
|
||||||
|
Ok(newsletter_issue_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn enqueue_delivery_tasks(
|
||||||
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
|
newsletter_issue_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO issue_delivery_queue (
|
||||||
|
newsletter_issue_id,
|
||||||
|
subscriber_email
|
||||||
|
)
|
||||||
|
SELECT $1, email
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'confirmed'
|
||||||
|
"#,
|
||||||
|
newsletter_issue_id,
|
||||||
|
);
|
||||||
|
transaction.execute(query).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "Publishing a newsletter",
|
name = "Publishing a newsletter",
|
||||||
skip(connection_pool, email_client, form)
|
skip(connection_pool, form, messages)
|
||||||
)]
|
)]
|
||||||
pub async fn publish(
|
pub async fn publish_newsletter(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool,
|
connection_pool, ..
|
||||||
email_client,
|
|
||||||
..
|
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
messages: Messages,
|
messages: Messages,
|
||||||
Form(form): Form<BodyData>,
|
Form(form): Form<BodyData>,
|
||||||
) -> Result<Response, AdminError> {
|
) -> Result<Response, AdminError> {
|
||||||
if let Err(e) = validate_form(&form) {
|
if let Err(e) = validate_form(&form) {
|
||||||
messages.error(e);
|
messages.error(e);
|
||||||
return Err(AdminError::Publish);
|
return Err(AdminError::Publish(anyhow::anyhow!(e)));
|
||||||
}
|
}
|
||||||
let subscribers = get_confirmed_subscribers(&connection_pool).await?;
|
|
||||||
for subscriber in subscribers {
|
let idempotency_key: IdempotencyKey = form
|
||||||
match subscriber {
|
.idempotency_key
|
||||||
Ok(ConfirmedSubscriber { name, email }) => {
|
.try_into()
|
||||||
let title = format!("{}, we have news for you! {}", name, form.title);
|
.map_err(AdminError::Idempotency)?;
|
||||||
email_client
|
|
||||||
.send_email(&email, &title, &form.html, &form.text)
|
let success_message = || {
|
||||||
.await
|
messages.success(format!(
|
||||||
.with_context(|| {
|
"The newsletter issue '{}' has been published!",
|
||||||
format!("Failed to send newsletter issue to {}", email.as_ref())
|
form.title
|
||||||
})?;
|
))
|
||||||
}
|
};
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
||||||
"Skipping a confirmed subscriber. Their stored contact details are invalid: {}",
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
e
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
)
|
success_message();
|
||||||
}
|
return Ok(response);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
messages.success(format!(
|
|
||||||
"The newsletter issue '{}' has been published!",
|
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
|
||||||
form.title,
|
.await
|
||||||
));
|
.context("Failed to store newsletter issue details")?;
|
||||||
Ok(Redirect::to("/admin/newsletters").into_response())
|
|
||||||
|
enqueue_delivery_tasks(&mut transaction, issue_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to enqueue delivery tasks")?;
|
||||||
|
|
||||||
|
let response = Redirect::to("/admin/newsletters").into_response();
|
||||||
|
success_message();
|
||||||
|
save_response(transaction, &idempotency_key, user_id, response)
|
||||||
|
.await
|
||||||
|
.map_err(AdminError::UnexpectedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
||||||
@@ -82,27 +143,27 @@ fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfirmedSubscriber {
|
// struct ConfirmedSubscriber {
|
||||||
name: String,
|
// name: String,
|
||||||
email: SubscriberEmail,
|
// email: SubscriberEmail,
|
||||||
}
|
// }
|
||||||
|
|
||||||
#[tracing::instrument(name = "Get confirmed subscribers", skip(connection_pool))]
|
// #[tracing::instrument(name = "Get confirmed subscribers", skip(connection_pool))]
|
||||||
async fn get_confirmed_subscribers(
|
// async fn get_confirmed_subscribers(
|
||||||
connection_pool: &PgPool,
|
// connection_pool: &PgPool,
|
||||||
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
|
// ) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
|
||||||
let rows = sqlx::query!("SELECT name, email FROM subscriptions WHERE status = 'confirmed'")
|
// let rows = sqlx::query!("SELECT name, email FROM subscriptions WHERE status = 'confirmed'")
|
||||||
.fetch_all(connection_pool)
|
// .fetch_all(connection_pool)
|
||||||
.await?;
|
// .await?;
|
||||||
let confirmed_subscribers = rows
|
// let confirmed_subscribers = rows
|
||||||
.into_iter()
|
// .into_iter()
|
||||||
.map(|r| match SubscriberEmail::parse(r.email) {
|
// .map(|r| match SubscriberEmail::parse(r.email) {
|
||||||
Ok(email) => Ok(ConfirmedSubscriber {
|
// Ok(email) => Ok(ConfirmedSubscriber {
|
||||||
name: r.name,
|
// name: r.name,
|
||||||
email,
|
// email,
|
||||||
}),
|
// }),
|
||||||
Err(e) => Err(anyhow::anyhow!(e)),
|
// Err(e) => Err(anyhow::anyhow!(e)),
|
||||||
})
|
// })
|
||||||
.collect();
|
// .collect();
|
||||||
Ok(confirmed_subscribers)
|
// Ok(confirmed_subscribers)
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ impl Application {
|
|||||||
let listener = TcpListener::bind(address).await?;
|
let listener = TcpListener::bind(address).await?;
|
||||||
let connection_pool =
|
let connection_pool =
|
||||||
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
||||||
let email_client = EmailClient::new(configuration.email_client);
|
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||||
let pool = Pool::new(
|
let pool = Pool::new(
|
||||||
Config::from_url(configuration.redis_uri.expose_secret())
|
Config::from_url(configuration.redis_uri.expose_secret())
|
||||||
.expect("Failed to parse Redis URL string"),
|
.expect("Failed to parse Redis URL string"),
|
||||||
@@ -88,7 +88,10 @@ pub fn app(
|
|||||||
let admin_routes = Router::new()
|
let admin_routes = Router::new()
|
||||||
.route("/dashboard", get(admin_dashboard))
|
.route("/dashboard", get(admin_dashboard))
|
||||||
.route("/password", get(change_password_form).post(change_password))
|
.route("/password", get(change_password_form).post(change_password))
|
||||||
.route("/newsletters", get(publish_form).post(publish))
|
.route(
|
||||||
|
"/newsletters",
|
||||||
|
get(publish_newsletter_form).post(publish_newsletter),
|
||||||
|
)
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use uuid::Uuid;
|
|||||||
use wiremock::MockServer;
|
use wiremock::MockServer;
|
||||||
use zero2prod::{
|
use zero2prod::{
|
||||||
configuration::{DatabaseSettings, get_configuration},
|
configuration::{DatabaseSettings, get_configuration},
|
||||||
|
email_client::EmailClient,
|
||||||
|
issue_delivery_worker::{ExecutionOutcome, try_execute_task},
|
||||||
startup::Application,
|
startup::Application,
|
||||||
telemetry::init_subscriber,
|
telemetry::init_subscriber,
|
||||||
};
|
};
|
||||||
@@ -70,6 +72,7 @@ pub struct TestApp {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub test_user: TestUser,
|
pub test_user: TestUser,
|
||||||
pub api_client: reqwest::Client,
|
pub api_client: reqwest::Client,
|
||||||
|
pub email_client: EmailClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestApp {
|
impl TestApp {
|
||||||
@@ -85,6 +88,7 @@ impl TestApp {
|
|||||||
c
|
c
|
||||||
};
|
};
|
||||||
let connection_pool = configure_database(&configuration.database).await;
|
let connection_pool = configure_database(&configuration.database).await;
|
||||||
|
let email_client = EmailClient::build(configuration.email_client.clone()).unwrap();
|
||||||
let application = Application::build(configuration)
|
let application = Application::build(configuration)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to build application");
|
.expect("Failed to build application");
|
||||||
@@ -110,6 +114,7 @@ impl TestApp {
|
|||||||
port,
|
port,
|
||||||
test_user,
|
test_user,
|
||||||
api_client,
|
api_client,
|
||||||
|
email_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(application.run_until_stopped());
|
tokio::spawn(application.run_until_stopped());
|
||||||
@@ -117,6 +122,18 @@ impl TestApp {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_all_pending_emails(&self) {
|
||||||
|
loop {
|
||||||
|
if let ExecutionOutcome::EmptyQueue =
|
||||||
|
try_execute_task(&self.connection_pool, &self.email_client)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
|
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
|
||||||
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
||||||
let get_link = |s: &str| {
|
let get_link = |s: &str| {
|
||||||
@@ -218,6 +235,14 @@ impl TestApp {
|
|||||||
.expect("failed to execute request")
|
.expect("failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn admin_login(&self) {
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": self.test_user.username,
|
||||||
|
"password": self.test_user.password
|
||||||
|
});
|
||||||
|
self.post_login(&login_body).await;
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_logout(&self) -> reqwest::Response {
|
pub async fn post_logout(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/logout", self.address))
|
.post(format!("{}/admin/logout", self.address))
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to};
|
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to};
|
||||||
|
use fake::{
|
||||||
|
Fake,
|
||||||
|
faker::{internet::en::SafeEmail, name::fr_fr::Name},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
use wiremock::{
|
use wiremock::{
|
||||||
Mock, ResponseTemplate,
|
Mock, MockBuilder, ResponseTemplate,
|
||||||
matchers::{any, method, path},
|
matchers::{method, path},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_unconfirmed_subscriber(&app).await;
|
create_unconfirmed_subscriber(&app).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
let login_body = serde_json::json!({
|
when_sending_an_email()
|
||||||
"username": app.test_user.username,
|
|
||||||
"password": app.test_user.password
|
|
||||||
});
|
|
||||||
app.post_login(&login_body).await;
|
|
||||||
|
|
||||||
Mock::given(any())
|
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
.expect(0)
|
.expect(0)
|
||||||
.mount(&app.email_server)
|
.mount(&app.email_server)
|
||||||
@@ -27,13 +28,15 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
|||||||
"html": "<p>Newsletter body as HTML</p>"
|
"html": "<p>Newsletter body as HTML</p>"
|
||||||
});
|
});
|
||||||
app.post_newsletters(&newsletter_request_body).await;
|
app.post_newsletters(&newsletter_request_body).await;
|
||||||
|
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn requests_without_authentication_are_redirected() {
|
async fn requests_without_authentication_are_redirected() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
|
|
||||||
Mock::given(any())
|
when_sending_an_email()
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
.expect(0)
|
.expect(0)
|
||||||
.mount(&app.email_server)
|
.mount(&app.email_server)
|
||||||
@@ -52,14 +55,9 @@ async fn requests_without_authentication_are_redirected() {
|
|||||||
async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_confirmed_subscriber(&app).await;
|
create_confirmed_subscriber(&app).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
let login_body = serde_json::json!({
|
when_sending_an_email()
|
||||||
"username": app.test_user.username,
|
|
||||||
"password": app.test_user.password
|
|
||||||
});
|
|
||||||
app.post_login(&login_body).await;
|
|
||||||
|
|
||||||
Mock::given(any())
|
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(&app.email_server)
|
.mount(&app.email_server)
|
||||||
@@ -69,7 +67,8 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
|||||||
let newsletter_request_body = serde_json::json!({
|
let newsletter_request_body = serde_json::json!({
|
||||||
"title": newsletter_title,
|
"title": newsletter_title,
|
||||||
"text": "Newsletter body as plain text",
|
"text": "Newsletter body as plain text",
|
||||||
"html": "<p>Newsletter body as HTML</p>"
|
"html": "<p>Newsletter body as HTML</p>",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = app.post_newsletters(&newsletter_request_body).await;
|
let response = app.post_newsletters(&newsletter_request_body).await;
|
||||||
@@ -80,19 +79,16 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
|||||||
"The newsletter issue '{}' has been published",
|
"The newsletter issue '{}' has been published",
|
||||||
newsletter_title
|
newsletter_title
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn form_shows_error_for_invalid_data() {
|
async fn form_shows_error_for_invalid_data() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
let login_body = serde_json::json!({
|
when_sending_an_email()
|
||||||
"username": app.test_user.username,
|
|
||||||
"password": app.test_user.password
|
|
||||||
});
|
|
||||||
app.post_login(&login_body).await;
|
|
||||||
|
|
||||||
Mock::given(any())
|
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
.expect(0)
|
.expect(0)
|
||||||
.mount(&app.email_server)
|
.mount(&app.email_server)
|
||||||
@@ -101,14 +97,20 @@ async fn form_shows_error_for_invalid_data() {
|
|||||||
let test_cases = [
|
let test_cases = [
|
||||||
(
|
(
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": "Newsletter body as plain text",
|
"text": "Newsletter body as plain text",
|
||||||
"html": "<p>Newsletter body as HTML</p>"
|
"html": "<p>Newsletter body as HTML</p>",
|
||||||
}),
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
|
}),
|
||||||
"The title was empty",
|
"The title was empty",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
serde_json::json!({ "title": "Newsletter", "text": "", "html": "" }),
|
serde_json::json!({
|
||||||
|
"title": "Newsletter",
|
||||||
|
"text": "",
|
||||||
|
"html": "",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
|
}),
|
||||||
"The content was empty",
|
"The content was empty",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -124,14 +126,9 @@ async fn form_shows_error_for_invalid_data() {
|
|||||||
async fn newsletter_creation_is_idempotent() {
|
async fn newsletter_creation_is_idempotent() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_confirmed_subscriber(&app).await;
|
create_confirmed_subscriber(&app).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
let login_body = serde_json::json!({
|
when_sending_an_email()
|
||||||
"username": app.test_user.username,
|
|
||||||
"password": app.test_user.password
|
|
||||||
});
|
|
||||||
app.post_login(&login_body).await;
|
|
||||||
|
|
||||||
Mock::given(any())
|
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(&app.email_server)
|
.mount(&app.email_server)
|
||||||
@@ -141,7 +138,8 @@ async fn newsletter_creation_is_idempotent() {
|
|||||||
let newsletter_request_body = serde_json::json!({
|
let newsletter_request_body = serde_json::json!({
|
||||||
"title": newsletter_title,
|
"title": newsletter_title,
|
||||||
"text": "Newsletter body as plain text",
|
"text": "Newsletter body as plain text",
|
||||||
"html": "<p>Newsletter body as HTML</p>"
|
"html": "<p>Newsletter body as HTML</p>",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = app.post_newsletters(&newsletter_request_body).await;
|
let response = app.post_newsletters(&newsletter_request_body).await;
|
||||||
@@ -161,10 +159,49 @@ async fn newsletter_creation_is_idempotent() {
|
|||||||
"The newsletter issue '{}' has been published",
|
"The newsletter issue '{}' has been published",
|
||||||
newsletter_title
|
newsletter_title
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_form_submission_is_handled_gracefully() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
create_confirmed_subscriber(&app).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let newsletter_request_body = serde_json::json!({
|
||||||
|
"title": "Newsletter title",
|
||||||
|
"text": "Newsletter body as plain text",
|
||||||
|
"html": "<p>Newsletter body as HTML</p>",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
|
});
|
||||||
|
let response1 = app.post_newsletters(&newsletter_request_body);
|
||||||
|
let response2 = app.post_newsletters(&newsletter_request_body);
|
||||||
|
let (response1, response2) = tokio::join!(response1, response2);
|
||||||
|
|
||||||
|
assert_eq!(response1.status(), response2.status());
|
||||||
|
assert_eq!(
|
||||||
|
response1.text().await.unwrap(),
|
||||||
|
response2.text().await.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
|
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
|
||||||
let body = "name=Alphonse&email=alphonse.paix%40outlook.com";
|
let name: String = Name().fake();
|
||||||
|
let email: String = SafeEmail().fake();
|
||||||
|
let body = serde_urlencoded::to_string(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"email": email
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let _mock_guard = Mock::given(path("/v1/email"))
|
let _mock_guard = Mock::given(path("/v1/email"))
|
||||||
.and(method("POST"))
|
.and(method("POST"))
|
||||||
@@ -173,7 +210,7 @@ async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
|
|||||||
.expect(1)
|
.expect(1)
|
||||||
.mount_as_scoped(&app.email_server)
|
.mount_as_scoped(&app.email_server)
|
||||||
.await;
|
.await;
|
||||||
app.post_subscriptions(body.into())
|
app.post_subscriptions(body)
|
||||||
.await
|
.await
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -196,3 +233,7 @@ async fn create_confirmed_subscriber(app: &TestApp) {
|
|||||||
.error_for_status()
|
.error_for_status()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn when_sending_an_email() -> MockBuilder {
|
||||||
|
Mock::given(path("/v1/email")).and(method("POST"))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user