Skip to content

Commit

Permalink
WIP feat: implement testing utilities
Browse files Browse the repository at this point in the history
  • Loading branch information
abonander committed Jul 30, 2022
1 parent 9a6d07f commit 1c9f28e
Show file tree
Hide file tree
Showing 67 changed files with 3,566 additions and 249 deletions.
444 changes: 438 additions & 6 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ members = [
"sqlx-cli",
"sqlx-bench",
"examples/mysql/todos",
"examples/postgres/axum-social-with-tests",
"examples/postgres/files",
"examples/postgres/json",
"examples/postgres/listen",
Expand Down Expand Up @@ -206,6 +207,11 @@ name = "sqlite-derives"
path = "tests/sqlite/derives.rs"
required-features = ["sqlite", "macros"]

[[test]]
name = "sqlite-test-attr"
path = "tests/sqlite/test-attr.rs"
required-features = ["sqlite", "macros"]

#
# MySQL
#
Expand All @@ -230,6 +236,11 @@ name = "mysql-macros"
path = "tests/mysql/macros.rs"
required-features = ["mysql", "macros"]

[[test]]
name = "mysql-test-attr"
path = "tests/mysql/test-attr.rs"
required-features = ["mysql", "macros"]

#
# PostgreSQL
#
Expand Down Expand Up @@ -259,6 +270,11 @@ name = "postgres-derives"
path = "tests/postgres/derives.rs"
required-features = ["postgres", "macros"]

[[test]]
name = "postgres-test-attr"
path = "tests/postgres/test-attr.rs"
required-features = ["postgres", "macros"]

#
# Microsoft SQL Server (MSSQL)
#
Expand Down
33 changes: 33 additions & 0 deletions examples/postgres/axum-social-with-tests/Cargo.toml
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"
10 changes: 10 additions & 0 deletions examples/postgres/axum-social-with-tests/README.md
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
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
);
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);
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 examples/postgres/axum-social-with-tests/src/http/error.rs
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,
}
}
}
26 changes: 26 additions & 0 deletions examples/postgres/axum-social-with-tests/src/http/mod.rs
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 examples/postgres/axum-social-with-tests/src/http/post/comment.rs
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))
}
Loading

0 comments on commit 1c9f28e

Please sign in to comment.