From 66c3bdfb6425cc87c3bdca65b4abddd42929fe57 Mon Sep 17 00:00:00 2001 From: Alex Ted Date: Mon, 27 Jan 2025 15:42:43 +0300 Subject: [PATCH] feat: base sqlx integration example --- sqlx-postgres/Cargo.toml | 34 ++++- sqlx-postgres/README.md | 10 ++ sqlx-postgres/migrations/1_user.sql | 6 + sqlx-postgres/migrations/2_post.sql | 8 ++ sqlx-postgres/migrations/3_comment.sql | 9 ++ sqlx-postgres/src/http/error.rs | 75 +++++++++++ sqlx-postgres/src/http/mod.rs | 26 ++++ sqlx-postgres/src/http/post/comment.rs | 100 ++++++++++++++ sqlx-postgres/src/http/post/mod.rs | 93 +++++++++++++ sqlx-postgres/src/http/user.rs | 95 ++++++++++++++ sqlx-postgres/src/lib.rs | 3 + sqlx-postgres/src/main.rs | 116 ++--------------- sqlx-postgres/src/password.rs | 34 +++++ sqlx-postgres/tests/comment.rs | 152 ++++++++++++++++++++++ sqlx-postgres/tests/common.rs | 71 ++++++++++ sqlx-postgres/tests/fixtures/comments.sql | 12 ++ sqlx-postgres/tests/fixtures/posts.sql | 8 ++ sqlx-postgres/tests/fixtures/users.sql | 10 ++ sqlx-postgres/tests/post.rs | 120 +++++++++++++++++ sqlx-postgres/tests/user.rs | 89 +++++++++++++ 20 files changed, 961 insertions(+), 110 deletions(-) create mode 100644 sqlx-postgres/README.md create mode 100644 sqlx-postgres/migrations/1_user.sql create mode 100644 sqlx-postgres/migrations/2_post.sql create mode 100644 sqlx-postgres/migrations/3_comment.sql create mode 100644 sqlx-postgres/src/http/error.rs create mode 100644 sqlx-postgres/src/http/mod.rs create mode 100644 sqlx-postgres/src/http/post/comment.rs create mode 100644 sqlx-postgres/src/http/post/mod.rs create mode 100644 sqlx-postgres/src/http/user.rs create mode 100644 sqlx-postgres/src/lib.rs create mode 100644 sqlx-postgres/src/password.rs create mode 100644 sqlx-postgres/tests/comment.rs create mode 100644 sqlx-postgres/tests/common.rs create mode 100644 sqlx-postgres/tests/fixtures/comments.sql create mode 100644 sqlx-postgres/tests/fixtures/posts.sql create mode 100644 sqlx-postgres/tests/fixtures/users.sql create mode 100644 sqlx-postgres/tests/post.rs create mode 100644 sqlx-postgres/tests/user.rs diff --git a/sqlx-postgres/Cargo.toml b/sqlx-postgres/Cargo.toml index 1d2bb2a..6cb3b73 100644 --- a/sqlx-postgres/Cargo.toml +++ b/sqlx-postgres/Cargo.toml @@ -1,13 +1,33 @@ [package] -name = "example-sqlx-postgres" +name = "sqlx-example-postgres-axum-social" version = "0.1.0" edition = "2021" -publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -axum.workspace = true -tokio = { version = "1.0", features = ["full"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Primary crates +axum = { version = "0.5.13", features = ["macros"] } +sqlx = { path = "../../../", features = [ "runtime-tokio", "tls-rustls-ring", "postgres", "time", "uuid" ] } +tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"] } + +# Important secondary crates +argon2 = "0.4.1" +rand = "0.8.5" +regex = "1.6.0" +serde = "1.0.140" +serde_with = { version = "2.0.0", features = ["time_0_3"] } +time = "0.3.11" +uuid = { version = "1.1.2", features = ["serde"] } +validator = { version = "0.16.0", features = ["derive"] } + +# Auxilliary crates +anyhow = "1.0.58" +dotenvy = "0.15.1" +once_cell = "1.13.0" +thiserror = "2.0.0" +tracing = "0.1.35" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "postgres"] } +[dev-dependencies] +serde_json = "1.0.82" +tower = "0.4.13" diff --git a/sqlx-postgres/README.md b/sqlx-postgres/README.md new file mode 100644 index 0000000..dfccd84 --- /dev/null +++ b/sqlx-postgres/README.md @@ -0,0 +1,10 @@ +This example demonstrates how to write integration tests for an API build with [Axum] and SQLx using `#[sqlx::test]`. + +See also: https://github.com/tokio-rs/axum/blob/main/examples/testing + +# Warning + +For the sake of brevity, this project omits numerous critical security precautions. You can use it as a starting point, +but deploy to production at your own risk! + +[Axum]: https://github.com/tokio-rs/axum diff --git a/sqlx-postgres/migrations/1_user.sql b/sqlx-postgres/migrations/1_user.sql new file mode 100644 index 0000000..62d42bc --- /dev/null +++ b/sqlx-postgres/migrations/1_user.sql @@ -0,0 +1,6 @@ +create table "user" +( + user_id uuid primary key default gen_random_uuid(), + username text unique not null, + password_hash text not null +); diff --git a/sqlx-postgres/migrations/2_post.sql b/sqlx-postgres/migrations/2_post.sql new file mode 100644 index 0000000..b91705f --- /dev/null +++ b/sqlx-postgres/migrations/2_post.sql @@ -0,0 +1,8 @@ +create table post ( + post_id uuid primary key default gen_random_uuid(), + user_id uuid not null references "user"(user_id), + content text not null, + created_at timestamptz not null default now() +); + +create index on post(created_at desc); diff --git a/sqlx-postgres/migrations/3_comment.sql b/sqlx-postgres/migrations/3_comment.sql new file mode 100644 index 0000000..d76cce1 --- /dev/null +++ b/sqlx-postgres/migrations/3_comment.sql @@ -0,0 +1,9 @@ +create table comment ( + comment_id uuid primary key default gen_random_uuid(), + post_id uuid not null references post(post_id), + user_id uuid not null references "user"(user_id), + content text not null, + created_at timestamptz not null default now() +); + +create index on comment(post_id, created_at); diff --git a/sqlx-postgres/src/http/error.rs b/sqlx-postgres/src/http/error.rs new file mode 100644 index 0000000..fc4fd5e --- /dev/null +++ b/sqlx-postgres/src/http/error.rs @@ -0,0 +1,75 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; + +use serde_with::DisplayFromStr; +use validator::ValidationErrors; + +/// An API-friendly error type. +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// A SQLx call returned an error. + /// + /// The exact error contents are not reported to the user in order to avoid leaking + /// information about databse internals. + #[error("an internal database error occurred")] + Sqlx(#[from] sqlx::Error), + + /// Similarly, we don't want to report random `anyhow` errors to the user. + #[error("an internal server error occurred")] + Anyhow(#[from] anyhow::Error), + + #[error("validation error in request body")] + InvalidEntity(#[from] ValidationErrors), + + #[error("{0}")] + UnprocessableEntity(String), + + #[error("{0}")] + Conflict(String), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + #[serde_with::serde_as] + #[serde_with::skip_serializing_none] + #[derive(serde::Serialize)] + struct ErrorResponse<'a> { + // Serialize the `Display` output as the error message + #[serde_as(as = "DisplayFromStr")] + message: &'a Error, + + errors: Option<&'a ValidationErrors>, + } + + let errors = match &self { + Error::InvalidEntity(errors) => Some(errors), + _ => None, + }; + + // Normally you wouldn't just print this, but it's useful for debugging without + // using a logging framework. + println!("API error: {self:?}"); + + ( + self.status_code(), + Json(ErrorResponse { + message: &self, + errors, + }), + ) + .into_response() + } +} + +impl Error { + fn status_code(&self) -> StatusCode { + use Error::*; + + match self { + Sqlx(_) | Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR, + InvalidEntity(_) | UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY, + Conflict(_) => StatusCode::CONFLICT, + } + } +} diff --git a/sqlx-postgres/src/http/mod.rs b/sqlx-postgres/src/http/mod.rs new file mode 100644 index 0000000..a871a93 --- /dev/null +++ b/sqlx-postgres/src/http/mod.rs @@ -0,0 +1,26 @@ +use anyhow::Context; +use axum::{Extension, Router}; +use sqlx::PgPool; + +mod error; + +mod post; +mod user; + +pub use self::error::Error; + +pub type Result = ::std::result::Result; + +pub fn app(db: PgPool) -> Router { + Router::new() + .merge(user::router()) + .merge(post::router()) + .layer(Extension(db)) +} + +pub async fn serve(db: PgPool) -> anyhow::Result<()> { + axum::Server::bind(&"0.0.0.0:8080".parse().unwrap()) + .serve(app(db).into_make_service()) + .await + .context("failed to serve API") +} diff --git a/sqlx-postgres/src/http/post/comment.rs b/sqlx-postgres/src/http/post/comment.rs new file mode 100644 index 0000000..630deda --- /dev/null +++ b/sqlx-postgres/src/http/post/comment.rs @@ -0,0 +1,100 @@ +use axum::extract::Path; +use axum::{Extension, Json, Router}; + +use axum::routing::get; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::http::user::UserAuth; +use sqlx::PgPool; +use validator::Validate; + +use crate::http::Result; + +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new().route( + "/v1/post/:postId/comment", + get(get_post_comments).post(create_post_comment), + ) +} + +#[derive(Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +struct CreateCommentRequest { + auth: UserAuth, + #[validate(length(min = 1, max = 1000))] + content: String, +} + +#[serde_with::serde_as] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Comment { + comment_id: Uuid, + username: String, + content: String, + // `OffsetDateTime`'s default serialization format is not standard. + #[serde_as(as = "Rfc3339")] + created_at: OffsetDateTime, +} + +// #[axum::debug_handler] // very useful! +async fn create_post_comment( + db: Extension, + Path(post_id): Path, + Json(req): Json, +) -> Result> { + req.validate()?; + let user_id = req.auth.verify(&*db).await?; + + let comment = sqlx::query_as!( + Comment, + // language=PostgreSQL + r#" + with inserted_comment as ( + insert into comment(user_id, post_id, content) + values ($1, $2, $3) + returning comment_id, user_id, content, created_at + ) + select comment_id, username, content, created_at + from inserted_comment + inner join "user" using (user_id) + "#, + user_id, + post_id, + req.content + ) + .fetch_one(&*db) + .await?; + + Ok(Json(comment)) +} + +/// Returns comments in ascending chronological order. +async fn get_post_comments( + db: Extension, + Path(post_id): Path, +) -> Result>> { + // Note: normally you'd want to put a `LIMIT` on this as well, + // though that would also necessitate implementing pagination. + let comments = sqlx::query_as!( + Comment, + // language=PostgreSQL + r#" + select comment_id, username, content, created_at + from comment + inner join "user" using (user_id) + where post_id = $1 + order by created_at + "#, + post_id + ) + .fetch_all(&*db) + .await?; + + Ok(Json(comments)) +} diff --git a/sqlx-postgres/src/http/post/mod.rs b/sqlx-postgres/src/http/post/mod.rs new file mode 100644 index 0000000..09c2fa4 --- /dev/null +++ b/sqlx-postgres/src/http/post/mod.rs @@ -0,0 +1,93 @@ +use axum::{Extension, Json, Router}; + +use axum::routing::get; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::http::user::UserAuth; +use sqlx::PgPool; +use validator::Validate; + +use crate::http::Result; + +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +mod comment; + +pub fn router() -> Router { + Router::new() + .route("/v1/post", get(get_posts).post(create_post)) + .merge(comment::router()) +} + +#[derive(Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +struct CreatePostRequest { + auth: UserAuth, + #[validate(length(min = 1, max = 1000))] + content: String, +} + +#[serde_with::serde_as] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Post { + post_id: Uuid, + username: String, + content: String, + // `OffsetDateTime`'s default serialization format is not standard. + #[serde_as(as = "Rfc3339")] + created_at: OffsetDateTime, +} + +// #[axum::debug_handler] // very useful! +async fn create_post( + db: Extension, + Json(req): Json, +) -> Result> { + req.validate()?; + let user_id = req.auth.verify(&*db).await?; + + let post = sqlx::query_as!( + Post, + // language=PostgreSQL + r#" + with inserted_post as ( + insert into post(user_id, content) + values ($1, $2) + returning post_id, user_id, content, created_at + ) + select post_id, username, content, created_at + from inserted_post + inner join "user" using (user_id) + "#, + user_id, + req.content + ) + .fetch_one(&*db) + .await?; + + Ok(Json(post)) +} + +/// Returns posts in descending chronological order. +async fn get_posts(db: Extension) -> Result>> { + // Note: normally you'd want to put a `LIMIT` on this as well, + // though that would also necessitate implementing pagination. + let posts = sqlx::query_as!( + Post, + // language=PostgreSQL + r#" + select post_id, username, content, created_at + from post + inner join "user" using (user_id) + order by created_at desc + "# + ) + .fetch_all(&*db) + .await?; + + Ok(Json(posts)) +} diff --git a/sqlx-postgres/src/http/user.rs b/sqlx-postgres/src/http/user.rs new file mode 100644 index 0000000..55f7f05 --- /dev/null +++ b/sqlx-postgres/src/http/user.rs @@ -0,0 +1,95 @@ +use axum::http::StatusCode; +use axum::{routing::post, Extension, Json, Router}; +use once_cell::sync::Lazy; +use rand::Rng; +use regex::Regex; +use std::time::Duration; + +use serde::Deserialize; +use sqlx::{PgExecutor, PgPool}; +use uuid::Uuid; +use validator::Validate; + +use crate::http::{Error, Result}; + +pub type UserId = Uuid; + +pub fn router() -> Router { + Router::new().route("/v1/user", post(create_user)) +} + +static USERNAME_REGEX: Lazy = Lazy::new(|| Regex::new(r"^[0-9A-Za-z_]+$").unwrap()); + +// CREATE USER + +#[derive(Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct UserAuth { + #[validate(length(min = 3, max = 16), regex = "USERNAME_REGEX")] + username: String, + #[validate(length(min = 8, max = 32))] + password: String, +} + +// WARNING: this API has none of the checks that a normal user signup flow implements, +// such as email or phone verification. +async fn create_user(db: Extension, Json(req): Json) -> Result { + req.validate()?; + + let UserAuth { username, password } = req; + + // It would be irresponsible to store passwords in plaintext, however. + let password_hash = crate::password::hash(password).await?; + + sqlx::query!( + // language=PostgreSQL + r#" + insert into "user"(username, password_hash) + values ($1, $2) + "#, + username, + password_hash + ) + .execute(&*db) + .await + .map_err(|e| match e { + sqlx::Error::Database(dbe) if dbe.constraint() == Some("user_username_key") => { + Error::Conflict("username taken".into()) + } + _ => e.into(), + })?; + + Ok(StatusCode::NO_CONTENT) +} + +impl UserAuth { + // NOTE: normally we wouldn't want to verify the username and password every time, + // but persistent sessions would have complicated the example. + pub async fn verify(self, db: impl PgExecutor<'_> + Send) -> Result { + self.validate()?; + + let maybe_user = sqlx::query!( + r#"select user_id, password_hash from "user" where username = $1"#, + self.username + ) + .fetch_optional(db) + .await?; + + if let Some(user) = maybe_user { + let verified = crate::password::verify(self.password, user.password_hash).await?; + + if verified { + return Ok(user.user_id); + } + } + + // Sleep a random amount of time to avoid leaking existence of a user in timing. + let sleep_duration = + rand::thread_rng().gen_range(Duration::from_millis(100)..=Duration::from_millis(500)); + tokio::time::sleep(sleep_duration).await; + + Err(Error::UnprocessableEntity( + "invalid username/password".into(), + )) + } +} diff --git a/sqlx-postgres/src/lib.rs b/sqlx-postgres/src/lib.rs new file mode 100644 index 0000000..76f3b5b --- /dev/null +++ b/sqlx-postgres/src/lib.rs @@ -0,0 +1,3 @@ +pub mod http; + +mod password; diff --git a/sqlx-postgres/src/main.rs b/sqlx-postgres/src/main.rs index 904a5a8..6b8f223 100644 --- a/sqlx-postgres/src/main.rs +++ b/sqlx-postgres/src/main.rs @@ -1,109 +1,19 @@ -//! Example of application using -//! -//! Run with -//! -//! ```not_rust -//! cargo run -p example-sqlx-postgres -//! ``` -//! -//! Test with curl: -//! -//! ```not_rust -//! curl 127.0.0.1:3000 -//! curl -X POST 127.0.0.1:3000 -//! ``` - -use axum::{ - extract::{FromRef, FromRequestParts, State}, - http::{request::Parts, StatusCode}, - routing::get, - Router, -}; -use sqlx::postgres::{PgPool, PgPoolOptions}; -use tokio::net::TcpListener; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use std::time::Duration; +use anyhow::Context; +use sqlx::postgres::PgPoolOptions; #[tokio::main] -async fn main() { - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - let db_connection_str = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://postgres:password@localhost".to_string()); - - // set up connection pool - let pool = PgPoolOptions::new() - .max_connections(5) - .acquire_timeout(Duration::from_secs(3)) - .connect(&db_connection_str) - .await - .expect("can't connect to database"); - - // build our application with some routes - let app = Router::new() - .route( - "/", - get(using_connection_pool_extractor).post(using_connection_extractor), - ) - .with_state(pool); - - // run it with hyper - let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); - tracing::debug!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, app).await.unwrap(); -} - -// we can extract the connection pool with `State` -async fn using_connection_pool_extractor( - State(pool): State, -) -> Result { - sqlx::query_scalar("select 'hello world from pg'") - .fetch_one(&pool) +async fn main() -> anyhow::Result<()> { + let database_url = dotenvy::var("DATABASE_URL") + // The error from `var()` doesn't mention the environment variable. + .context("DATABASE_URL must be set")?; + + let db = PgPoolOptions::new() + .max_connections(20) + .connect(&database_url) .await - .map_err(internal_error) -} - -// we can also write a custom extractor that grabs a connection from the pool -// which setup is appropriate depends on your application -struct DatabaseConnection(sqlx::pool::PoolConnection); - -impl FromRequestParts for DatabaseConnection -where - PgPool: FromRef, - S: Send + Sync, -{ - type Rejection = (StatusCode, String); - - async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result { - let pool = PgPool::from_ref(state); - - let conn = pool.acquire().await.map_err(internal_error)?; + .context("failed to connect to DATABASE_URL")?; - Ok(Self(conn)) - } -} - -async fn using_connection_extractor( - DatabaseConnection(mut conn): DatabaseConnection, -) -> Result { - sqlx::query_scalar("select 'hello world from pg'") - .fetch_one(&mut *conn) - .await - .map_err(internal_error) -} + sqlx::migrate!().run(&db).await?; -/// Utility function for mapping any error into a `500 Internal Server Error` -/// response. -fn internal_error(err: E) -> (StatusCode, String) -where - E: std::error::Error, -{ - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + sqlx_example_postgres_axum_social::http::serve(db).await } diff --git a/sqlx-postgres/src/password.rs b/sqlx-postgres/src/password.rs new file mode 100644 index 0000000..44f1551 --- /dev/null +++ b/sqlx-postgres/src/password.rs @@ -0,0 +1,34 @@ +use anyhow::{anyhow, Context}; +use tokio::task; + +use argon2::password_hash::SaltString; +use argon2::{password_hash, Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; + +pub async fn hash(password: String) -> anyhow::Result { + task::spawn_blocking(move || { + let salt = SaltString::generate(rand::thread_rng()); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow!(e).context("failed to hash password"))? + .to_string()) + }) + .await + .context("panic in hash()")? +} + +pub async fn verify(password: String, hash: String) -> anyhow::Result { + task::spawn_blocking(move || { + let hash = PasswordHash::new(&hash) + .map_err(|e| anyhow!(e).context("BUG: password hash invalid"))?; + + let res = Argon2::default().verify_password(password.as_bytes(), &hash); + + match res { + Ok(()) => Ok(true), + Err(password_hash::Error::Password) => Ok(false), + Err(e) => Err(anyhow!(e).context("failed to verify password")), + } + }) + .await + .context("panic in verify()")? +} diff --git a/sqlx-postgres/tests/comment.rs b/sqlx-postgres/tests/comment.rs new file mode 100644 index 0000000..84eb96d --- /dev/null +++ b/sqlx-postgres/tests/comment.rs @@ -0,0 +1,152 @@ +use sqlx::PgPool; + +use sqlx_example_postgres_axum_social::http; + +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use std::borrow::BorrowMut; + +use common::{expect_rfc3339_timestamp, expect_uuid, response_json, RequestBuilderExt}; + +use serde_json::json; + +mod common; + +#[sqlx::test(fixtures("users", "posts"))] +async fn test_create_comment(db: PgPool) { + let mut app = http::app(db); + + // Happy path! + let mut resp1 = app + .borrow_mut() + .oneshot( + Request::post("/v1/post/d9ca2672-24c5-4442-b32f-cd717adffbaa/comment").json(json! { + { + "auth": { + "username": "bob", + "password": "pro gamer 1990" + }, + "content": "lol bet ur still bad, 1v1 me" + } + }), + ) + .await + .unwrap(); + + assert_eq!(resp1.status(), StatusCode::OK); + + let resp1_json = response_json(&mut resp1).await; + + assert_eq!(resp1_json["username"], "bob"); + assert_eq!(resp1_json["content"], "lol bet ur still bad, 1v1 me"); + + let _comment_id = expect_uuid(&resp1_json["commentId"]); + + let _created_at = expect_rfc3339_timestamp(&resp1_json["createdAt"]); + + // Incorrect username + let mut resp2 = app + .borrow_mut() + .oneshot( + Request::post("/v1/post/d9ca2672-24c5-4442-b32f-cd717adffbaa/comment").json(json! { + { + "auth": { + "username": "bobbbbbb", + "password": "pro gamer 1990" + }, + "content": "lol bet ur still bad, 1v1 me" + } + }), + ) + .await + .unwrap(); + + assert_eq!(resp2.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp2_json = response_json(&mut resp2).await; + assert_eq!(resp2_json["message"], "invalid username/password"); + + // Incorrect password + let mut resp3 = app + .borrow_mut() + .oneshot( + Request::post("/v1/post/d9ca2672-24c5-4442-b32f-cd717adffbaa/comment").json(json! { + { + "auth": { + "username": "bob", + "password": "pro gamer 1990" + }, + "content": "lol bet ur still bad, 1v1 me" + } + }), + ) + .await + .unwrap(); + + assert_eq!(resp3.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp3_json = response_json(&mut resp3).await; + assert_eq!(resp3_json["message"], "invalid username/password"); +} + +#[sqlx::test(fixtures("users", "posts", "comments"))] +async fn test_list_comments(db: PgPool) { + let mut app = http::app(db); + + // This only has the happy path. + let mut resp = app + .borrow_mut() + .oneshot(Request::get("/v1/post/d9ca2672-24c5-4442-b32f-cd717adffbaa/comment").empty_body()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let resp_json = response_json(&mut resp).await; + + let comments = resp_json + .as_array() + .expect("expected request to return an array"); + + assert_eq!(comments.len(), 2); + + assert_eq!(comments[0]["username"], "bob"); + assert_eq!(comments[0]["content"], "lol bet ur still bad, 1v1 me"); + + let _comment_id = expect_uuid(&comments[0]["commentId"]); + let created_at_0 = expect_rfc3339_timestamp(&comments[0]["createdAt"]); + + assert_eq!(comments[1]["username"], "alice"); + assert_eq!(comments[1]["content"], "you're on!"); + + let _comment_id = expect_uuid(&comments[1]["commentId"]); + let created_at_1 = expect_rfc3339_timestamp(&comments[1]["createdAt"]); + + assert!( + created_at_0 < created_at_1, + "comments must be assorted in ascending order" + ); + + let mut resp = app + .borrow_mut() + .oneshot(Request::get("/v1/post/7e3d4d16-a35e-46ba-8223-b4f1debbfbfe/comment").empty_body()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let resp_json = response_json(&mut resp).await; + + let comments = resp_json + .as_array() + .expect("expected request to return an array"); + + assert_eq!(comments.len(), 1); + + assert_eq!(comments[0]["username"], "alice"); + assert_eq!(comments[0]["content"], "lol you're just mad you lost :P"); + + let _comment_id = expect_uuid(&comments[0]["commentId"]); + let _created_at = expect_rfc3339_timestamp(&comments[0]["createdAt"]); +} diff --git a/sqlx-postgres/tests/common.rs b/sqlx-postgres/tests/common.rs new file mode 100644 index 0000000..2b7f169 --- /dev/null +++ b/sqlx-postgres/tests/common.rs @@ -0,0 +1,71 @@ +// This is imported by different tests that use different functions. +#![allow(dead_code)] + +use axum::body::{Body, BoxBody, HttpBody}; +use axum::http::header::CONTENT_TYPE; +use axum::http::{request, Request}; +use axum::response::Response; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use uuid::Uuid; + +pub trait RequestBuilderExt { + fn json(self, json: serde_json::Value) -> Request; + + fn empty_body(self) -> Request; +} + +impl RequestBuilderExt for request::Builder { + fn json(self, json: serde_json::Value) -> Request { + self.header("Content-Type", "application/json") + .body(Body::from(json.to_string())) + .expect("failed to build request") + } + + fn empty_body(self) -> Request { + self.body(Body::empty()).expect("failed to build request") + } +} + +pub async fn response_json(resp: &mut Response) -> serde_json::Value { + assert_eq!( + resp.headers() + .get(CONTENT_TYPE) + .expect("expected Content-Type"), + "application/json" + ); + + let body = resp.body_mut(); + + let mut bytes = Vec::new(); + + while let Some(res) = body.data().await { + let chunk = res.expect("error reading response body"); + + bytes.extend_from_slice(&chunk[..]); + } + + serde_json::from_slice(&bytes).expect("failed to read response body as json") +} + +#[track_caller] +pub fn expect_string(value: &serde_json::Value) -> &str { + value + .as_str() + .unwrap_or_else(|| panic!("expected string, got {value:?}")) +} + +#[track_caller] +pub fn expect_uuid(value: &serde_json::Value) -> Uuid { + expect_string(value) + .parse::() + .unwrap_or_else(|e| panic!("failed to parse UUID from {value:?}: {e}")) +} + +#[track_caller] +pub fn expect_rfc3339_timestamp(value: &serde_json::Value) -> OffsetDateTime { + let s = expect_string(value); + + OffsetDateTime::parse(s, &Rfc3339) + .unwrap_or_else(|e| panic!("failed to parse RFC-3339 timestamp from {value:?}: {e}")) +} diff --git a/sqlx-postgres/tests/fixtures/comments.sql b/sqlx-postgres/tests/fixtures/comments.sql new file mode 100644 index 0000000..b53a090 --- /dev/null +++ b/sqlx-postgres/tests/fixtures/comments.sql @@ -0,0 +1,12 @@ +INSERT INTO public.comment (comment_id, post_id, user_id, content, created_at) +VALUES + -- from: bob + ('3a86b8f8-827b-4f14-94a2-34517b4b5bde', 'd9ca2672-24c5-4442-b32f-cd717adffbaa', + 'c994b839-84f4-4509-ad49-59119133d6f5', 'lol bet ur still bad, 1v1 me', '2022-07-29 01:52:31.167673'), + -- from: alice + ('d6f862b5-2b87-4af4-b15e-6b3398729e6d', 'd9ca2672-24c5-4442-b32f-cd717adffbaa', + '51b374f1-93ae-4c5c-89dd-611bda8412ce', 'you''re on!', '2022-07-29 01:53:53.115782'), + -- from: alice + ('1eed85ae-adae-473c-8d05-b1dae0a1df63', '7e3d4d16-a35e-46ba-8223-b4f1debbfbfe', + '51b374f1-93ae-4c5c-89dd-611bda8412ce', 'lol you''re just mad you lost :P', '2022-07-29 01:55:50.116119'); + diff --git a/sqlx-postgres/tests/fixtures/posts.sql b/sqlx-postgres/tests/fixtures/posts.sql new file mode 100644 index 0000000..8d875e5 --- /dev/null +++ b/sqlx-postgres/tests/fixtures/posts.sql @@ -0,0 +1,8 @@ +INSERT INTO public.post (post_id, user_id, content, created_at) +VALUES + -- from: alice + ('d9ca2672-24c5-4442-b32f-cd717adffbaa', '51b374f1-93ae-4c5c-89dd-611bda8412ce', + 'This new computer is blazing fast!', '2022-07-29 01:36:24.679082'), + -- from: bob + ('7e3d4d16-a35e-46ba-8223-b4f1debbfbfe', 'c994b839-84f4-4509-ad49-59119133d6f5', '@alice is a haxxor', + '2022-07-29 01:54:45.823523'); diff --git a/sqlx-postgres/tests/fixtures/users.sql b/sqlx-postgres/tests/fixtures/users.sql new file mode 100644 index 0000000..5c29415 --- /dev/null +++ b/sqlx-postgres/tests/fixtures/users.sql @@ -0,0 +1,10 @@ +INSERT INTO public."user" (user_id, username, password_hash) +VALUES + -- username: "alice"; password: "rustacean since 2015" + ('51b374f1-93ae-4c5c-89dd-611bda8412ce', 'alice', + '$argon2id$v=19$m=4096,t=3,p=1$3v3ats/tYTXAYs3q9RycDw$ZltwjS3oQwPuNmL9f6DNb+sH5N81dTVZhVNbUQzmmVU'), + -- username: "bob"; password: "pro gamer 1990" + ('c994b839-84f4-4509-ad49-59119133d6f5', 'bob', + '$argon2id$v=19$m=4096,t=3,p=1$1zbkRinUH9WHzkyu8C1Vlg$70pu5Cca/s3d0nh5BYQGkN7+s9cqlNxTE7rFZaUaP4c'); + + diff --git a/sqlx-postgres/tests/post.rs b/sqlx-postgres/tests/post.rs new file mode 100644 index 0000000..c23220a --- /dev/null +++ b/sqlx-postgres/tests/post.rs @@ -0,0 +1,120 @@ +use sqlx::PgPool; + +use sqlx_example_postgres_axum_social::http; + +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use std::borrow::BorrowMut; + +use common::{expect_rfc3339_timestamp, expect_uuid, response_json, RequestBuilderExt}; + +use serde_json::json; + +mod common; + +#[sqlx::test(fixtures("users"))] +async fn test_create_post(db: PgPool) { + let mut app = http::app(db); + + // Happy path! + let mut resp1 = app + .borrow_mut() + .oneshot(Request::post("/v1/post").json(json! { + { + "auth": { + "username": "alice", + "password": "rustacean since 2015" + }, + "content": "This new computer is blazing fast!" + } + })) + .await + .unwrap(); + + assert_eq!(resp1.status(), StatusCode::OK); + + let resp1_json = response_json(&mut resp1).await; + + assert_eq!(resp1_json["username"], "alice"); + assert_eq!(resp1_json["content"], "This new computer is blazing fast!"); + + let _post_id = expect_uuid(&resp1_json["postId"]); + let _created_at = expect_rfc3339_timestamp(&resp1_json["createdAt"]); + + // Incorrect username + let mut resp2 = app + .borrow_mut() + .oneshot(Request::post("/v1/post").json(json! { + { + "auth": { + "username": "aliceee", + "password": "rustacean since 2015" + }, + "content": "This new computer is blazing fast!" + } + })) + .await + .unwrap(); + + assert_eq!(resp2.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp2_json = response_json(&mut resp2).await; + assert_eq!(resp2_json["message"], "invalid username/password"); + + // Incorrect password + let mut resp3 = app + .borrow_mut() + .oneshot(Request::post("/v1/post").json(json! { + { + "auth": { + "username": "alice", + "password": "rustaceansince2015" + }, + "content": "This new computer is blazing fast!" + } + })) + .await + .unwrap(); + + assert_eq!(resp3.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp3_json = response_json(&mut resp3).await; + assert_eq!(resp3_json["message"], "invalid username/password"); +} + +#[sqlx::test(fixtures("users", "posts"))] +async fn test_list_posts(db: PgPool) { + // This only has the happy path. + let mut resp = http::app(db) + .oneshot(Request::get("/v1/post").empty_body()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let resp_json = response_json(&mut resp).await; + + let posts = resp_json + .as_array() + .expect("expected GET /v1/post to return an array"); + + assert_eq!(posts.len(), 2); + + assert_eq!(posts[0]["username"], "bob"); + assert_eq!(posts[0]["content"], "@alice is a haxxor"); + + let _post_id = expect_uuid(&posts[0]["postId"]); + let created_at_0 = expect_rfc3339_timestamp(&posts[0]["createdAt"]); + + assert_eq!(posts[1]["username"], "alice"); + assert_eq!(posts[1]["content"], "This new computer is blazing fast!"); + + let _post_id = expect_uuid(&posts[1]["postId"]); + let created_at_1 = expect_rfc3339_timestamp(&posts[1]["createdAt"]); + + assert!( + created_at_0 > created_at_1, + "posts must be sorted in descending order" + ); +} diff --git a/sqlx-postgres/tests/user.rs b/sqlx-postgres/tests/user.rs new file mode 100644 index 0000000..cfc642a --- /dev/null +++ b/sqlx-postgres/tests/user.rs @@ -0,0 +1,89 @@ +use sqlx::PgPool; + +use sqlx_example_postgres_axum_social::http; + +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use std::borrow::BorrowMut; + +use common::{response_json, RequestBuilderExt}; + +use serde_json::json; + +mod common; + +#[sqlx::test] +async fn test_create_user(db: PgPool) { + let mut app = http::app(db); + + // Happy path! + let resp1 = app + .borrow_mut() + // We handle JSON objects directly to sanity check the serialization and deserialization + .oneshot(Request::post("/v1/user").json(json! {{ + "username": "alice", + "password": "rustacean since 2015" + }})) + .await + .unwrap(); + + assert_eq!(resp1.status(), StatusCode::NO_CONTENT); + + // Username taken + let mut resp2 = app + .borrow_mut() + .oneshot(Request::post("/v1/user").json(json! {{ + "username": "alice", + "password": "uhhh i forgot" + }})) + .await + .unwrap(); + + assert_eq!(resp2.status(), StatusCode::CONFLICT); + + let resp2_json = response_json(&mut resp2).await; + assert_eq!(resp2_json["message"], "username taken"); + + // Invalid username + let mut resp3 = app + .borrow_mut() + .oneshot(Request::post("/v1/user").json(json! {{ + "username": "definitely an invalid username", + "password": "password" + }})) + .await + .unwrap(); + + assert_eq!(resp3.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp3_json = response_json(&mut resp3).await; + + assert_eq!(resp3_json["message"], "validation error in request body"); + assert!( + resp3_json["errors"]["username"].is_array(), + "errors.username is not an array: {:?}", + resp3_json + ); + + // Invalid password + let mut resp4 = app + .borrow_mut() + .oneshot(Request::post("/v1/user").json(json! {{ + "username": "bobby123", + "password": "" + }})) + .await + .unwrap(); + + assert_eq!(resp4.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let resp4_json = response_json(&mut resp4).await; + + assert_eq!(resp4_json["message"], "validation error in request body"); + assert!( + resp4_json["errors"]["password"].is_array(), + "errors.password is not an array: {:?}", + resp4_json + ); +}