Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

End of Chapter 8 #12

Merged
merged 2 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ name = "claans_api"

[dependencies]
actix-web = "4.9.0"
anyhow = "1.0.89"
chrono = "0.4.38"
config = "0.14.0"
rand = { version = "0.8.5", features = ["std_rng"] }
secrecy = { version = "0.10.2", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde-aux = "4.5.0"
sqlx = { version = "0.8.2", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
thiserror = "1.0.64"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
tracing = { version = "0.1.40", features = ["log"] }
tracing-actix-web = "0.7.13"
Expand Down
13 changes: 13 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,16 @@ mod users_confirm;
pub use health_check::*;
pub use users::*;
pub use users_confirm::*;

fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
108 changes: 71 additions & 37 deletions src/routes/users.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use actix_web::{web, HttpResponse};
use actix_web::{http::StatusCode, web, HttpResponse, ResponseError};
use anyhow::Context;
use chrono::Utc;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use sqlx::{Executor, PgPool, Postgres, Transaction};
Expand All @@ -10,6 +11,8 @@ use crate::{
startup::ApplicationBaseUrl,
};

use super::error_chain_fmt;

#[derive(serde::Deserialize)]
pub struct FormData {
email: String,
Expand All @@ -26,6 +29,29 @@ impl TryFrom<FormData> for NewUser {
}
}

#[derive(thiserror::Error)]
pub enum RegisterError {
#[error("{0}")]
ValidationError(String),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}

impl std::fmt::Debug for RegisterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}

impl ResponseError for RegisterError {
fn status_code(&self) -> StatusCode {
match self {
RegisterError::ValidationError(_) => StatusCode::BAD_REQUEST,
RegisterError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

#[tracing::instrument(
name = "Adding a new user",
skip(form, pool, email_client, base_url),
Expand All @@ -39,36 +65,27 @@ pub async fn register(
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
base_url: web::Data<ApplicationBaseUrl>,
) -> HttpResponse {
let new_user = match form.0.try_into() {
Ok(user) => user,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let user_id = match insert_user(&mut transaction, &new_user).await {
Ok(user_id) => user_id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
) -> Result<HttpResponse, RegisterError> {
let new_user = form.0.try_into().map_err(RegisterError::ValidationError)?;
let mut transaction = pool
.begin()
.await
.context("Failed to acquire a Postgres connection from the pool")?;
let user_id = insert_user(&mut transaction, &new_user)
.await
.context("Failed to insert a new user in the database")?;
let user_token = generate_user_token();
if store_token(&mut transaction, user_id, &user_token)
store_token(&mut transaction, user_id, &user_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_user, &base_url.0, &user_token)
.context("Failed to store the confirmation token for a new user")?;
transaction
.commit()
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
.context("Failed to commit SQL transaction to store a new user and user_token")?;
send_confirmation_email(&email_client, new_user, &base_url.0, &user_token)
.await
.context("Failed to send a confirmation email to new user")?;
Ok(HttpResponse::Ok().finish())
}

fn generate_user_token() -> String {
Expand Down Expand Up @@ -120,10 +137,7 @@ pub async fn insert_user(
new_user.name.as_ref(),
Utc::now()
);
transaction.execute(query).await.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
transaction.execute(query).await?;
Ok(user_id)
}

Expand All @@ -135,7 +149,7 @@ pub async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
user_id: Uuid,
user_token: &str,
) -> Result<(), sqlx::Error> {
) -> Result<(), StoreTokenError> {
let query = sqlx::query!(
r#"
INSERT INTO user_tokens (user_token, user_id)
Expand All @@ -144,9 +158,29 @@ pub async fn store_token(
user_token,
user_id
);
transaction.execute(query).await.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
transaction.execute(query).await.map_err(StoreTokenError)?;
Ok(())
}

pub struct StoreTokenError(sqlx::Error);

impl std::error::Error for StoreTokenError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}

impl std::fmt::Debug for StoreTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}

impl std::fmt::Display for StoreTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"A database error was encountered while trying to store a user token"
)
}
}
64 changes: 40 additions & 24 deletions src/routes/users_confirm.rs
Original file line number Diff line number Diff line change
@@ -1,41 +1,61 @@
use actix_web::{web, HttpResponse};
use actix_web::{http::StatusCode, web, HttpResponse, ResponseError};
use anyhow::Context;
use sqlx::PgPool;
use uuid::Uuid;

use super::error_chain_fmt;

#[derive(serde::Deserialize)]
pub struct Parameters {
user_token: String,
}

#[tracing::instrument(name = "Confirm a pending user", skip(parameters, pool))]
pub async fn confirm(parameters: web::Query<Parameters>, pool: web::Data<PgPool>) -> HttpResponse {
let id = match get_user_id_from_token(&pool, &parameters.user_token).await {
Ok(id) => id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
match id {
None => HttpResponse::Unauthorized().finish(),
Some(user_id) => {
if confirm_user(&pool, user_id).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
#[derive(thiserror::Error)]
pub enum ConfirmationError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
#[error("There is not user associated with the provided token")]
UnknownToken,
}

impl std::fmt::Debug for ConfirmationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}

impl ResponseError for ConfirmationError {
fn status_code(&self) -> StatusCode {
match self {
Self::UnknownToken => StatusCode::UNAUTHORIZED,
Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

#[tracing::instrument(name = "Confirm a pending user", skip(parameters, pool))]
pub async fn confirm(
parameters: web::Query<Parameters>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ConfirmationError> {
let user_id = get_user_id_from_token(&pool, &parameters.user_token)
.await
.context("Failed to retrieve user id associated with token provided")?
.ok_or(ConfirmationError::UnknownToken)?;
confirm_user(&pool, user_id)
.await
.context("Failed to update user status to 'confirmed'")?;
Ok(HttpResponse::Ok().finish())
}

#[tracing::instrument(name = "Mark user as confirmed", skip(user_id, pool))]
pub async fn confirm_user(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"UPDATE users SET status = 'confirmed' WHERE id = $1"#,
user_id,
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
.await?;
Ok(())
}

Expand All @@ -50,10 +70,6 @@ pub async fn get_user_id_from_token(
user_token,
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
.await?;
Ok(result.map(|r| r.user_id))
}
17 changes: 17 additions & 0 deletions tests/api/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,20 @@ async fn register_sends_a_confirmation_email_with_a_link() {
let confirmation_links = app.get_confirmation_links(email_request);
assert_eq!(confirmation_links.html, confirmation_links.plain_text);
}

#[tokio::test]
async fn register_fails_if_there_is_a_fatal_database_error() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
sqlx::query!("ALTER TABLE users DROP COLUMN email;",)
.execute(&app.db_pool)
.await
.unwrap();

// Act
let response = app.post_users(body.into()).await;

// Assert
assert_eq!(response.status().as_u16(), 500);
}