Skip to content
Open
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
34 changes: 27 additions & 7 deletions sqlx-postgres/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions sqlx-postgres/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
6 changes: 6 additions & 0 deletions sqlx-postgres/migrations/1_user.sql
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 sqlx-postgres/migrations/2_post.sql
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 sqlx-postgres/migrations/3_comment.sql
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 sqlx-postgres/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 sqlx-postgres/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 sqlx-postgres/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))
}
93 changes: 93 additions & 0 deletions sqlx-postgres/src/http/post/mod.rs
Original file line number Diff line number Diff line change
@@ -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<PgPool>,
Json(req): Json<CreatePostRequest>,
) -> Result<Json<Post>> {
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<PgPool>) -> Result<Json<Vec<Post>>> {
// 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))
}
Loading