-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WIP feat: implement testing utilities
- Loading branch information
Showing
67 changed files
with
3,566 additions
and
249 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
[package] | ||
name = "sqlx-example-postgres-axum-social" | ||
version = "0.1.0" | ||
edition = "2021" | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[dependencies] | ||
# Primary crates | ||
axum = { version = "0.5.13", features = ["macros"] } | ||
sqlx = { version = "0.6.0", path = "../../../", features = ["runtime-tokio-rustls", "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 = "1.0.31" | ||
tracing = "0.1.35" | ||
|
||
[dev-dependencies] | ||
serde_json = "1.0.82" | ||
tower = "0.4.13" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
6 changes: 6 additions & 0 deletions
6
examples/postgres/axum-social-with-tests/migrations/1_user.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
); |
8 changes: 8 additions & 0 deletions
8
examples/postgres/axum-social-with-tests/migrations/2_post.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); |
9 changes: 9 additions & 0 deletions
9
examples/postgres/axum-social-with-tests/migrations/3_comment.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); |
75 changes: 75 additions & 0 deletions
75
examples/postgres/axum-social-with-tests/src/http/error.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T, E = Error> = ::std::result::Result<T, E>; | ||
|
||
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") | ||
} |
100 changes: 100 additions & 0 deletions
100
examples/postgres/axum-social-with-tests/src/http/post/comment.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<PgPool>, | ||
Path(post_id): Path<Uuid>, | ||
Json(req): Json<CreateCommentRequest>, | ||
) -> Result<Json<Comment>> { | ||
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<PgPool>, | ||
Path(post_id): Path<Uuid>, | ||
) -> Result<Json<Vec<Comment>>> { | ||
// 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)) | ||
} |
Oops, something went wrong.