添加新的表
关于subscription_tokens
的添加,确实不需要三个步骤。过程可以更简单,主要分为两个阶段:
-
创建迁移并更新数据库模式 :首先,你可以创建一个新的迁移脚本,在这个脚本中定义
subscription_tokens
表结构,并将其应用到数据库中。在这一阶段,当前的应用程序代码仍然不会使用这个新的表。这样做的好处是可以在不影响现有功能的情况下安全地更新数据库结构。 -
部署新版本应用程序 :一旦数据库已经更新完毕,就可以部署一个新版本的应用程序,该版本会开始使用
subscription_tokens
表来实现订阅确认邮件等功能。sqlx migrate add create_subscription_tokens_table
sql
CREATE TABLE subscription_tokens(
subscription_token TEXT NOT NULL,
subscriber_id uuid NOT NULL REFERENCES subscriptions (id),
PRIMARY KEY (subscription_token)
);
构建确认邮件功能
既然数据库已经准备就绪,可以开始构建确认邮件功能了。采用测试驱动开发(TDD)的方法是一个很好的选择,它能够确保代码的质量和可靠性。
准备工作
-
设置环境:确保你的开发环境中安装了必要的工具,比如邮件发送服务(如Action Mailer在Rails中),以及用于模拟邮件发送的库。
-
创建测试用例:为新的功能编写初始测试案例,这些测试应该覆盖你期望的功能行为,即使此时它们可能会失败(即"红色"状态)。这有助于定义明确的目标,并且让开发者知道他们需要实现什么。
rust
//!tests/api/helpers.rs
pub struct TestApp {
pub address: String,
pub port: u16,
pub db_pool: PgPool,
pub emial_server: MockServer,
}
pub async fn spawn_app() -> TestApp {
LazyLock::force(&TRACING);
let email_server = MockServer::start().await;
let configuration = {
let mut c = get_configuration().expect("Failed to read configuration.");
c.database.database_name = Uuid::new_v4().to_string();
c.application.port = 0;
c.email_client.base_url = email_server.uri();
c
};
configure_database(&configuration.database).await;
let application = Application::build(configuration.clone())
.await
.expect("Failed to build application.");
let application_port = application.port();
let address = format!("http://localhost:{}", application.port());
let _ = tokio::spawn(application.run_util_stoped());
TestApp {
address,
port: application_port,
db_pool: get_connection_pool(&configuration.database),
email_server,
}
}
写一个新的test
rust
//! tests/api/subscriptions.rs
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_for_valid_data() {
// Arrange
let app = spawn_app().await;
let body = "name=zhangsan&email=zhangsan@126.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
// Mock asserts on drop
}
验证subscriptions_confirm.rs
rust
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// Act
let response = reqwest::get(confirmation_links.html).await.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 200);
}
rust
//! src/routes/subscriptions.rs
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
base_url: &str,
subscription_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url, subscription_token
);
let plain_body = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
let html_body = format!(
"Welcome to our newsletter!<br />Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
email_client
.send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body)
.await
}
代码重构
提取和处理邮件内容中的确认链接逻辑如果在多个测试中重复出现,确实应该将其提取到一个独立的辅助函数中。这样做不仅可以减少代码重复,还可以提高测试的可维护性和清晰度。
rust
//! tests/api/helpers.rs
/// Confirmation links embedded in the request to the email API.
pub struct ConfirmationLinks {
pub html: reqwest::Url,
pub plain_text: reqwest::Url,
}
impl TestApp {
//[..]
/// Extract the confirmation links embedded in the request to the email API.
pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();
// Extract the link from one of the request fields.
let get_link = |s: &str| {
let links: Vec<_> = linkify::LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str().to_owned();
let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap();
// Let's make sure we don't call random APIs on the web
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};
let html = get_link(body["HtmlBody"].as_str().unwrap());
let plain_text = get_link(body["TextBody"].as_str().unwrap());
ConfirmationLinks { html, plain_text }
}
}
我们将提取确认链接的逻辑作为一个方法添加到 TestApp
类中,以便能够访问应用程序端口,这是我们需要注入到链接中的信息。 这个逻辑也可以是一个独立的函数,接收 wiremock::Request
和 TestApp
(或端口号 u16
)作为参数------这纯粹是风格选择的问题。 现在我们可以大大简化我们的两个测试用例:
rust
//! tests/api/subscriptions.rs
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_with_a_link() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// The two links should be identical
assert_eq!(confirmation_links.html, confirmation_links.plain_text);
}
rust
//! tests/api/subscriptions_confirm.rs
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// Act
let response = reqwest::get(confirmation_links.html).await.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 200);
}
现在我们已经准备好处理订阅令牌的生成了。我们将通过编写一个新的测试用例来开始这项工作,该测试用例将基于我们刚刚完成的工作:而不是断言返回的状态码,我们将检查数据库中存储的订阅者的状态。
准备工作
确保你已经有了一个可以正常工作的邮件发送机制,并且 subscription_tokens
表已经在数据库中创建并准备就绪。
编写失败测试(Red)
rust
#[tokio::test]
async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// Act
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
// Assert
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "ursula_le_guin@gmail.com");
assert_eq!(saved.name, "le guin");
assert_eq!(saved.status, "confirmed");
}
实现最简功能使测试通过(Green)
rust
//! src/routes/subscriptions.rs
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
base_url: &str,
subscription_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url, subscription_token
);
let plain_body = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
let html_body = format!(
"Welcome to our newsletter!<br />Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
email_client
.send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body)
.await
}
将 send_confirmation_email
方法重构为接收令牌作为参数是一个明智的选择,这不仅可以使添加生成逻辑更加容易,还能提高代码的清晰度和可维护性。
rust
//! src/routes/subscriptions.rs
#[allow(clippy::async_yields_async)]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool, email_client, base_url),
fields(
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
base_url: web::Data<ApplicationBaseUrl>,
) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let subscriber_id = match insert_subscriber(&mut transaction, &new_subscriber).await {
Ok(subscriber_id) => subscriber_id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let subscription_token = generate_subscription_token();
if store_token(&mut transaction, subscriber_id, &subscription_token)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
if transaction.commit().await.is_err() {
return HttpResponse::InternalServerError().finish();
}
if send_confirmation_email(
&email_client,
new_subscriber,
&base_url.0,
&subscription_token,
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
base_url: &str,
subscription_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url, subscription_token
);
let plain_body = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
let html_body = format!(
"Welcome to our newsletter!<br />Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
email_client
.send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body)
.await
}
确实,订阅令牌不同于密码,它们是单次使用的,并且不授予对受保护信息的访问权限。因此,虽然我们需要确保它们足够难以猜测以防止滥用,但不需要达到密码那样严格的强度要求。使用加密安全的伪随机数生成器(CSPRNG)来生成这些令牌是一个合理的选择,这可以提供足够的安全性,同时保持实现的简单性。
添加依赖
rust
[dependencies]
rand = { version = "0.8", features = ["std_rng"] }
rust
//! src/routes/subscriptions.rs
fn generate_subscription_token() -> String {
let mut rng = thread_rng();
std::iter::repeat_with(|| rng.sample(Alphanumeric))
.map(char::from)
.take(25)
.collect()
}
使用25个字符我们可以得到大约 10451045 种可能的令牌------这对于我们的情况来说应该是绰绰有余了。 为了在 GET /subscriptions/confirm
中验证令牌的有效性,我们需要确保 POST /subscriptions
将新生成的令牌存储到数据库中。 我们为此目的添加的 subscription_tokens
表有两个字段:subscription_token
和 subscriber_id
。 目前我们在 insert_subscriber
方法中生成订阅者的标识符,但我们从未将其返回给调用者:
解决方案
为了确保每次创建新订阅时都动态生成唯一的订阅令牌,并将其存储在 subscription_tokens
表中,同时确保可以在确认订阅时验证这些令牌,我们需要调整
rust
//! src/routes/subscriptions.rs
#[allow(clippy::async_yields_async)]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool, email_client, base_url),
fields(
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
base_url: web::Data<ApplicationBaseUrl>,
) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let subscriber_id = match insert_subscriber(&mut transaction, &new_subscriber).await {
Ok(subscriber_id) => subscriber_id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let subscription_token = generate_subscription_token();
if store_token(&mut transaction, subscriber_id, &subscription_token)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
if transaction.commit().await.is_err() {
return HttpResponse::InternalServerError().finish();
}
if send_confirmation_email(
&email_client,
new_subscriber,
&base_url.0,
&subscription_token,
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
#[tracing::instrument(
name = "Store subscription token in the database",
skip(subscription_token, transaction)
)]
pub async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
subscriber_id: Uuid,
subscription_token: &str,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO subscription_tokens (subscription_token, subscriber_id)
VALUES ($1, $2)
"#,
subscription_token,
subscriber_id
);
transaction.execute(query).await.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
rust
//! src/routes/subscriptions_confirm.rs
#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String,
}
#[allow(clippy::async_yields_async)]
#[tracing::instrument(name = "Confirm a pending subscriber", skip(parameters, pool))]
pub async fn confirm(parameters: web::Query<Parameters>, pool: web::Data<PgPool>) -> HttpResponse {
let id = match get_subscriber_id_from_token(&pool, ¶meters.subscription_token).await {
Ok(id) => id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
match id {
// Non-existing token!
None => HttpResponse::Unauthorized().finish(),
Some(subscriber_id) => {
if confirm_subscriber(&pool, subscriber_id).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
}
}
改造基本上就完成了,下一节我们把数据库的事务加进去