构建一个rust生产应用读书笔记7-确认邮件4

添加新的表

关于subscription_tokens的添加,确实不需要三个步骤。过程可以更简单,主要分为两个阶段:

  1. 创建迁移并更新数据库模式 :首先,你可以创建一个新的迁移脚本,在这个脚本中定义subscription_tokens表结构,并将其应用到数据库中。在这一阶段,当前的应用程序代码仍然不会使用这个新的表。这样做的好处是可以在不影响现有功能的情况下安全地更新数据库结构。

  2. 部署新版本应用程序 :一旦数据库已经更新完毕,就可以部署一个新版本的应用程序,该版本会开始使用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)的方法是一个很好的选择,它能够确保代码的质量和可靠性。

准备工作

  1. 设置环境:确保你的开发环境中安装了必要的工具,比如邮件发送服务(如Action Mailer在Rails中),以及用于模拟邮件发送的库。

  2. 创建测试用例:为新的功能编写初始测试案例,这些测试应该覆盖你期望的功能行为,即使此时它们可能会失败(即"红色"状态)。这有助于定义明确的目标,并且让开发者知道他们需要实现什么。

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);
}

修改subscriptions.rs

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::RequestTestApp(或端口号 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_tokensubscriber_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, &parameters.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()
        }
    }
}

改造基本上就完成了,下一节我们把数据库的事务加进去

相关推荐
羊小猪~~2 小时前
MYSQL学习笔记(二):基本的SELECT语句使用(基本、条件、聚合函数查询)
数据库·笔记·sql·学习·mysql·考研·数据分析
然然阿然然2 小时前
2025.1.15——二、字符型注入
网络·数据库·sql·学习·网络安全
!!!5253 小时前
MyBatis-增删改查操作&一些细节
java·数据库·spring boot·mybatis
然然阿然然3 小时前
2025.1.15——六、SQL结构【❤sqlmap❤】
数据库·sql·学习·安全·web安全·网络安全
霍格沃兹测试开发学社测试人社区3 小时前
三大智能体平台对比分析:FastGPT、Dify、Coze 哪个更适合你?
大数据·软件测试·数据库·人工智能·测试开发
专注VB编程开发20年4 小时前
.NET Core封装Activex Dll,向COM公开.NET Core组件
数据库·ui·.netcore·dll·com·activex
少年攻城狮4 小时前
Oracle系列---【Oracle中密码的策略如何设置】
数据库·oracle
小蒜学长4 小时前
疾病防控综合系统设计与实现(代码+数据库+LW)
前端·数据库·vue.js·spring boot·后端·oracle
少年的云和月(^~^)4 小时前
MySQL存储过程
数据库·mysql
TiDB_PingCAP4 小时前
唐刘:TiDB 的 2024 - Cloud、SaaS 与 AI
数据库·人工智能·ai·tidb·saas