First working API.

Simple auth by creating sessions and storing in db
This commit is contained in:
Martin Berg Alstad 2024-08-29 16:43:04 +02:00
commit 5d5e6393ac
50 changed files with 6410 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="postgres://postgres:postgres@localhost:32784/postgres"

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@localhost" uuid="a9834eed-2984-4bc3-80c8-dcff9fb3fa8b">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:32784/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

15
.idea/hotel_service.iml generated Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/diesel_crud/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/diesel_crud/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/diesel_crud_trait/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/hotel_service.iml" filepath="$PROJECT_DIR$/.idea/hotel_service.iml" />
</modules>
</component>
</project>

9
.idea/sqldialects.xml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/00000000000000_diesel_initial_setup/down.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/00000000000000_diesel_initial_setup/up.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/2024-08-04-214620_init_tables/up.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3422
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

54
Cargo.toml Normal file
View File

@ -0,0 +1,54 @@
[package]
name = "hotel_service"
version = "0.1.0"
edition = "2021"
[dependencies]
# API
axum = { version = "0.7.5", features = ["macros"] }
# Async
tokio = { version = "1.39.2", features = ["rt-multi-thread"] }
futures = "0.3.30"
# Auth
axum-login = "0.15.3"
tower-sessions = { version = "0.12.3", features = ["axum-core"] }
# Cryptography
sha2 = "0.10.8"
digest = "0.10.7"
rand = { version = "0.8.5", features = ["rand_chacha"] }
rand_chacha = "0.3.1"
# DB
diesel = { version = "2.2.3", features = ["postgres", "chrono", "serde_json"] }
diesel-async = { version = "0.5.0", features = ["postgres", "deadpool"] }
deadpool-diesel = { version = "0.6.1", features = ["postgres"] }
# Env
dotenvy_macro = "0.15.7"
# Error handling
thiserror = "1.0.63"
# Serialization
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.127"
base64ct = { version = "1.6.0", features = ["alloc"] }
# Time
chrono = { version = "0.4.38", features = ["serde", "std"] }
# Utils
derive_more = { version = "1.0.0", features = ["from", "constructor"] }
strum = { version = "0.26.3", features = ["derive"] }
secrecy = { version = "0.8.0", features = ["serde"] }
bon = "2.0.0"
# Validation
validator = { version = "0.18.1", features = ["derive"] }
axum-valid = { version = "0.19.0", features = ["validator"] }
lib = { path = "../lib", features = ["axum", "serde", "derive", "diesel", "time"] }
#lib = { git = "https://github.com/emberal/rust-lib", tag = "1.4.3", features = ["axum", "serde", "derive"] }
[dev-dependencies]
rstest = "0.22.0"
testcontainers-modules = { version = "0.9.0", features = ["postgres"] }
async-std = { version = "1.12.0", features = ["attributes"] }
tower = { version = "0.5.0", features = ["util"] }
mime = "0.3.17"
diesel_async_migrations = "0.14.0"
hyper = "1.4.1"

27
Makefile.toml Normal file
View File

@ -0,0 +1,27 @@
[tasks.fmt]
command = "cargo"
args = ["fmt"]
[tasks.lint]
command = "cargo"
args = ["clippy"]
[tasks.release]
command = "cargo"
args = ["build", "--release"]
[tasks.run_migrations]
command = "diesel"
args = ["migration", "run"]
[tasks.redo_migrations]
command = "diesel"
args = ["migration", "redo"]
[tasks.test]
command = "cargo"
args = ["test", "--all-features"]
[tasks.coverage]
command = "cargo"
args = ["llvm-cov"]

7
README.md Normal file
View File

@ -0,0 +1,7 @@
- [ ] Tests for handlers
- [ ] Generic AsyncConnection or mocking
- [ ] GitHub Actions
- [ ] Dockerfile
- [ ] OAuth2 ?
- [ ] OpenAPI
- [ ] Streaming

9
diesel.toml Normal file
View File

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "/home/martin/git/rust/hotel_service/migrations"

18
http/auth.http Normal file
View File

@ -0,0 +1,18 @@
### Register a new user
POST {{baseurl}}/auth/register
Content-Type: application/json
{
"email": "test@test.com",
"password": "password",
"role": "ADMIN"
}
### Login
POST {{baseurl}}/auth/login
Content-Type: application/json
{
"email": "test@test.com",
"password": "password"
}

View File

@ -0,0 +1,5 @@
{
"dev": {
"baseurl": "http://localhost:8000"
}
}

5
http/reservation.http Normal file
View File

@ -0,0 +1,5 @@
### List of all reservations
GET {{baseurl}}/reservation
### GET A specific reservation
GET {{baseurl}}/reservation/1

0
migrations/.keep Normal file
View File

View File

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,6 @@
DROP TABLE IF EXISTS hotel CASCADE;
DROP TABLE IF EXISTS room CASCADE;
DROP TABLE IF EXISTS reservation CASCADE;
DROP TABLE IF EXISTS task CASCADE;
DROP TABLE IF EXISTS "user" CASCADE;
DROP TABLE IF EXISTS session CASCADE;

View File

@ -0,0 +1,51 @@
CREATE TABLE hotel
(
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) NOT NULL
);
CREATE TABLE room
(
id INTEGER PRIMARY KEY,
hotel_id INTEGER NOT NULL,
beds INTEGER NOT NULL CHECK (beds > 0),
size INTEGER NOT NULL CHECK (size > 0),
FOREIGN KEY (hotel_id) REFERENCES hotel (id)
);
CREATE TABLE "user"
(
email VARCHAR(255) PRIMARY KEY,
hash VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
role SMALLINT NOT NULL CHECK ( role IN (0, 3) )
);
CREATE TABLE reservation
(
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL,
start TIMESTAMP NOT NULL,
"end" TIMESTAMP NOT NULL,
"user" VARCHAR(255) NOT NULL,
checked_in BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES room (id),
FOREIGN KEY ("user") REFERENCES "user" (email)
);
CREATE TABLE task
(
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL,
description TEXT NOT NULL,
status VARCHAR(12) NOT NULL CHECK ( status IN ('todo', 'in_progress', 'done') ),
FOREIGN KEY (room_id) REFERENCES room (id)
);
CREATE TABLE session
(
id VARCHAR(128) PRIMARY KEY,
data JSONB NOT NULL,
expiry_date TIMESTAMP NOT NULL
)

102
src/auth.rs Normal file
View File

@ -0,0 +1,102 @@
use crate::error::ResponseError;
use crate::models::user::{User, UserRole};
use crate::result::ResponseResult;
use crate::services::user_service::UserService;
use rand::distributions::Alphanumeric;
use rand::Rng;
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
use sha2::{Digest, Sha256};
use std::future::Future;
pub type AuthSession<Pool> = axum_login::AuthSession<UserService<Pool>>;
pub async fn authenticate<'a, Ok, Fut>(
maybe_user: Option<&'a User>,
roles: impl IntoIterator<Item = UserRole>,
authenticated: impl FnOnce(&'a User) -> Fut,
) -> ResponseResult<Ok>
where
Fut: Future<Output = ResponseResult<Ok>>,
{
if let Some(user) = maybe_user {
let int_roles = roles
.into_iter()
.map(UserRole::into_i16)
.collect::<Vec<_>>();
if int_roles.contains(&user.role) {
authenticated(user).await
} else {
Err(ResponseError::Forbidden)
}
} else {
Err(ResponseError::Unauthorized)
}
}
pub(crate) fn hash_password(password: &str, salt: &[u8], output: &mut [u8; 32]) {
let hasher = Sha256::new()
.chain_update(password)
.chain_update("$")
.chain_update(salt);
output.copy_from_slice(hasher.finalize().as_slice());
}
pub(crate) fn generate_salt() -> Vec<u8> {
ChaCha20Rng::from_entropy()
.sample_iter(Alphanumeric)
.take(16)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ok;
use crate::result::Success;
#[tokio::test]
async fn test_authenticate_user_none() {
let user = None;
assert_eq!(
authenticate(user, [UserRole::Admin], |_| async { ok!() }).await,
Err(ResponseError::Unauthorized)
);
}
#[tokio::test]
async fn test_authenticate_user_unauthorized() {
let user = Some(User {
email: "test@test.com".to_string(),
role: UserRole::Guest.into_i16(),
hash: String::default(),
salt: String::default(),
});
assert_eq!(
authenticate(user.as_ref(), [UserRole::Admin], |_| async { ok!() }).await,
Err(ResponseError::Forbidden)
);
}
#[tokio::test]
async fn test_authenticate_user_authorized() {
let user = Some(User {
email: "test@test.com".to_string(),
role: UserRole::Admin.into_i16(),
hash: String::default(),
salt: String::default(),
});
assert_eq!(
authenticate(user.as_ref(), [UserRole::Admin], |_| async { ok!() }).await,
Ok(Success::Ok(()))
);
}
#[test]
fn test_hash_password() {
let salt = generate_salt();
let mut hashed = [0; 32];
hash_password("password", &salt, &mut hashed);
}
}

8
src/config.rs Normal file
View File

@ -0,0 +1,8 @@
use dotenvy_macro::dotenv;
pub(crate) const DATABASE_URL: &str = dotenv!("DATABASE_URL");
pub(crate) const POOL_SIZE: usize = 10;
pub(crate) const SESSION_MAX_AGE: i64 = 1;
#[cfg(test)]
pub(crate) static MIGRATIONS: diesel_async_migrations::EmbeddedMigrations =
diesel_async_migrations::embed_migrations!("./migrations");

35
src/database.rs Normal file
View File

@ -0,0 +1,35 @@
use crate::config;
use crate::error::AppError;
use axum::async_trait;
use deadpool_diesel::postgres::BuildError;
use deadpool_diesel::Status;
use diesel_async::pooled_connection::deadpool::{Object, Pool};
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::AsyncPgConnection;
pub type PgPool = Pool<AsyncPgConnection>;
#[async_trait]
pub trait GetConnection: Clone + Send + Sync {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError>;
fn status(&self) -> Status;
}
#[async_trait]
impl GetConnection for PgPool {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError> {
self.get().await.map_err(Into::into)
}
fn status(&self) -> Status {
self.status()
}
}
pub(crate) fn create_pool() -> Result<PgPool, BuildError> {
create_pool_from_url(config::DATABASE_URL)
}
pub(crate) fn create_pool_from_url(url: impl Into<String>) -> Result<PgPool, BuildError> {
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(url);
Pool::builder(config).max_size(config::POOL_SIZE).build()
}

180
src/error.rs Normal file
View File

@ -0,0 +1,180 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum_login::AuthnBackend;
use derive_more::Constructor;
use diesel::result::DatabaseErrorKind;
use diesel_async::pooled_connection::deadpool;
use crate::services::reservation_service::ReservationError;
use lib::diesel_crud_trait::CrudError;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
use thiserror::Error;
type DieselError = diesel::result::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error(transparent)]
PoolError(#[from] deadpool::PoolError),
#[error(transparent)]
CrudError(CrudError),
#[error(transparent)]
DatabaseError(#[from] diesel::result::Error),
#[error("Auth session error: {0}")]
AuthSessionError(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Resource not found")]
NotFound,
#[error("Unauthorized. Please log in at /auth/login")]
Unauthorized,
#[error("Forbidden. Insufficient permissions")]
Forbidden,
#[error("Internal server error: {0}")]
InternalServerError(String),
#[error(transparent)]
JsonError(#[from] serde_json::Error),
#[error(transparent)]
TimeParseError(#[from] chrono::ParseError),
#[error(transparent)]
IdParseError(#[from] std::num::ParseIntError),
#[error(transparent)]
FromUtf8Error(#[from] std::string::FromUtf8Error),
#[error("Base64 error: {0}")]
Base64Error(String),
}
#[derive(Debug, Error, PartialEq)]
pub enum ResponseError {
#[error("{0}")]
BadRequest(String), // 400
#[error("User is not authenticated. Please log in at /auth/login")]
Unauthorized, // 401
#[error("Forbidden. Insufficient permissions")]
Forbidden, // 403
#[error("{0}")]
NotFound(String), // 404
#[error("{0}")]
Conflict(String), // 409
#[error("{0}")]
InternalServerError(String), // 500
}
#[derive(Debug, Serialize, Deserialize, IntoResponse, Constructor)]
pub(crate) struct ErrorResponseBody {
pub kind: i32,
pub error: String,
}
impl IntoResponse for ResponseError {
fn into_response(self) -> Response {
match self {
ResponseError::BadRequest(error) => {
(StatusCode::BAD_REQUEST, ErrorResponseBody::new(400, error))
}
ResponseError::Unauthorized => (
StatusCode::UNAUTHORIZED,
ErrorResponseBody::new(401, self.to_string()),
),
ResponseError::Forbidden => (
StatusCode::FORBIDDEN,
ErrorResponseBody::new(403, self.to_string()),
),
ResponseError::NotFound(error) => {
(StatusCode::NOT_FOUND, ErrorResponseBody::new(404, error))
}
ResponseError::Conflict(error) => {
(StatusCode::CONFLICT, ErrorResponseBody::new(409, error))
}
ResponseError::InternalServerError(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponseBody::new(500, error),
),
}
.into_response()
}
}
impl From<CrudError> for ResponseError {
fn from(value: CrudError) -> Self {
match &value {
CrudError::NotFound => Self::NotFound(value.to_string()),
CrudError::Other(diesel::result::Error::DatabaseError(
DatabaseErrorKind::UniqueViolation,
error,
)) => Self::Conflict(error.message().to_string()),
_ => Self::InternalServerError(value.to_string()),
}
}
}
impl From<ReservationError> for ResponseError {
fn from(value: ReservationError) -> Self {
match value {
ReservationError::NotFound => Self::NotFound(value.to_string()),
ReservationError::Other(error) => Self::InternalServerError(error),
_ => Self::BadRequest(value.to_string()),
}
}
}
impl From<deadpool::PoolError> for ResponseError {
fn from(value: deadpool::PoolError) -> Self {
Self::InternalServerError(value.to_string())
}
}
impl<T: AuthnBackend> From<axum_login::Error<T>> for ResponseError {
fn from(value: axum_login::Error<T>) -> Self {
Self::BadRequest(value.to_string())
}
}
impl From<AppError> for ResponseError {
fn from(value: AppError) -> Self {
match value {
AppError::NotFound => Self::NotFound(value.to_string()),
AppError::BadRequest(error) => Self::BadRequest(error),
AppError::Unauthorized => Self::Unauthorized,
AppError::Forbidden => Self::Forbidden,
AppError::CrudError(error) => error.into(),
_ => Self::InternalServerError(value.to_string()),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()).into_response(),
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()).into_response(),
otherwise => (StatusCode::INTERNAL_SERVER_ERROR, otherwise.to_string()).into_response(),
}
}
}
impl From<base64ct::Error> for AppError {
fn from(value: base64ct::Error) -> Self {
Self::Base64Error(value.to_string())
}
}
impl From<CrudError> for AppError {
fn from(value: CrudError) -> Self {
match value {
CrudError::NotFound => Self::NotFound,
CrudError::Other(DieselError::DatabaseError(
DatabaseErrorKind::ForeignKeyViolation,
error,
)) => Self::BadRequest(format!(
"Foreign key violation: '{}' on constraint '{}'",
error.message(),
error.constraint_name().unwrap_or_default()
)),
otherwise => Self::CrudError(otherwise),
}
}
}

100
src/main.rs Normal file
View File

@ -0,0 +1,100 @@
#[cfg(test)]
#[macro_use]
extern crate rstest;
use crate::database::{create_pool, GetConnection};
use crate::services::session_service::SessionService;
use crate::services::user_service::UserService;
use axum::Router;
use axum_login::tower_sessions::SessionManagerLayer;
use axum_login::AuthManagerLayerBuilder;
use lib::axum::app::AppBuilder;
use tower_sessions::cookie::time::Duration;
use tower_sessions::Expiry;
mod auth;
mod config;
mod database;
mod error;
mod models;
mod result;
mod routes;
mod schema;
mod services;
#[cfg(test)]
mod test;
pub fn create_app<Pool>(pool: Pool) -> Router
where
Pool: GetConnection + 'static,
{
let session_service = SessionService::new(pool.clone());
let user_service = UserService::new(pool.clone());
let session_layer = SessionManagerLayer::new(session_service)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(Duration::days(
config::SESSION_MAX_AGE,
)));
let auth_layer = AuthManagerLayerBuilder::new(user_service.clone(), session_layer).build();
AppBuilder::new()
.routes([
routes::hotel::router()
.with_state(pool.clone())
.login_required::<Pool>(),
routes::reservation::router()
.with_state(pool.clone())
.login_required::<Pool>(),
routes::room::router()
.with_state(pool.clone())
.login_required::<Pool>(),
routes::task::router()
.with_state(pool.clone())
.login_required::<Pool>(),
routes::user::router()
.with_state(pool)
.login_required::<Pool>(),
routes::auth::router().with_state(user_service),
])
.layer(auth_layer)
.build()
}
#[tokio::main]
async fn main() {
let pool = create_pool().unwrap();
AppBuilder::from_router(create_app(pool))
.serve()
.await
.unwrap();
}
trait LoginRequired {
fn login_required<Pool>(self) -> Self
where
Pool: GetConnection + Send + Sync + 'static;
}
impl<S> LoginRequired for Router<S>
where
S: Clone + Send + Sync + 'static,
{
fn login_required<Pool>(self) -> Self
where
Pool: GetConnection + Send + Sync + 'static,
{
use axum_login::axum::{
middleware::{from_fn, Next},
response::IntoResponse,
};
self.route_layer(from_fn(
|auth_session: axum_login::AuthSession<UserService<Pool>>, req, next: Next| async move {
if auth_session.user.is_some() {
next.run(req).await
} else {
axum::http::StatusCode::UNAUTHORIZED.into_response()
}
},
))
}
}

34
src/models/common.rs Normal file
View File

@ -0,0 +1,34 @@
use lib::axum::wrappers::Count;
use lib::diesel_crud_trait::CrudError;
pub(crate) fn map_count(count: Result<usize, CrudError>) -> Result<Count, CrudError> {
match count {
Ok(0) => Err(CrudError::NotFound),
Ok(n) => Ok(n.into()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use lib::diesel_crud_trait::CrudError;
#[test]
fn test_map_count_0_is_not_found() {
assert_eq!(map_count(Ok(0)), Err(CrudError::NotFound));
}
#[test]
fn test_map_count_n_is_count() {
assert_eq!(map_count(Ok(1)), Ok(1.into()));
}
#[test]
fn test_map_count_error_is_error() {
assert_eq!(
map_count(Err(CrudError::PoolError("error".to_string()))),
Err(CrudError::PoolError("error".to_string()))
);
}
}

33
src/models/hotel.rs Normal file
View File

@ -0,0 +1,33 @@
use derive_more::Constructor;
use diesel::{AsChangeset, Identifiable, Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::DieselCrud;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(
Queryable, Selectable, Identifiable, AsChangeset, Serialize, IntoResponse, DieselCrud, Validate,
)]
#[diesel_crud(insert = CreateHotel)]
#[diesel(table_name = crate::schema::hotel)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Hotel {
#[diesel_crud(pk)]
pub id: i32,
#[validate(length(min = 1))]
pub name: String,
#[validate(length(min = 1))]
pub address: String,
}
#[derive(Insertable, Deserialize, Validate, Constructor)]
#[diesel(table_name = crate::schema::hotel)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CreateHotel {
#[validate(range(min = 1))]
pub id: Option<i32>,
#[validate(length(min = 1))]
pub name: String,
#[validate(length(min = 1))]
pub address: String,
}

7
src/models/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod common;
pub mod hotel;
pub mod reservation;
pub mod room;
pub mod session;
pub mod task;
pub mod user;

109
src/models/reservation.rs Normal file
View File

@ -0,0 +1,109 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::Constructor;
use diesel::{AsChangeset, Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::DieselCrud;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use validator::{Validate, ValidationError};
#[derive(Clone, Validate, Insertable, Deserialize, Constructor)]
#[validate(schema(function = "validate_create_reservation"))]
#[diesel(table_name = crate::schema::reservation)]
pub struct CreateReservation {
pub room_id: i32,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
#[validate(email)]
pub user: String,
}
fn validate_create_reservation(reservation: &CreateReservation) -> Result<(), ValidationError> {
_validate_reservation(&reservation.start, &reservation.end)
}
#[derive(
Clone,
Validate,
Queryable,
Selectable,
AsChangeset,
Serialize,
Deserialize,
IntoResponse,
DieselCrud,
)]
#[diesel_crud(insert = CreateReservation)]
#[validate(schema(function = "validate_reservation"))]
#[diesel(table_name = crate::schema::reservation)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Reservation {
#[diesel_crud(pk)]
pub id: i32,
pub room_id: i32,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
#[validate(email)]
pub user: String,
pub checked_in: bool,
}
fn validate_reservation(reservation: &Reservation) -> Result<(), ValidationError> {
_validate_reservation(&reservation.start, &reservation.end)
}
fn _validate_reservation(
start: &NaiveDateTime,
end: &NaiveDateTime,
) -> Result<(), ValidationError> {
if start >= end {
let mut err = ValidationError::new("start must be before end");
err.add_param(Cow::from("start"), start);
err.add_param(Cow::from("end"), end);
Err(err)
} else if start < &Utc::now().naive_utc() {
let mut err = ValidationError::new("start must be in the future");
err.add_param(Cow::from("start"), start);
Err(err)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_reservation_ok() {
let reservation = CreateReservation {
room_id: 1,
start: Utc::now().naive_utc() + chrono::Duration::days(1),
end: Utc::now().naive_utc() + chrono::Duration::days(2),
user: "a@example.com".to_string(),
};
assert!(validate_create_reservation(&reservation).is_ok());
}
#[test]
fn test_validate_reservation_start_after_end() {
let reservation = CreateReservation {
room_id: 1,
start: Utc::now().naive_utc() + chrono::Duration::days(1),
end: Utc::now().naive_utc(),
user: "a@example.com".to_string(),
};
assert!(validate_create_reservation(&reservation).is_err());
}
#[test]
fn test_validate_reservation_start_in_past() {
let reservation = CreateReservation {
room_id: 1,
start: Utc::now().naive_utc() - chrono::Duration::days(1),
end: Utc::now().naive_utc(),
user: "a@example.com".to_string(),
};
assert!(validate_create_reservation(&reservation).is_err());
}
}

34
src/models/room.rs Normal file
View File

@ -0,0 +1,34 @@
use diesel::{AsChangeset, Associations, Identifiable, Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::DieselCrud;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(
Debug,
PartialEq,
Queryable,
Selectable,
Identifiable,
Associations,
AsChangeset,
Insertable,
Serialize,
Deserialize,
IntoResponse,
Validate,
DieselCrud,
)]
#[diesel(belongs_to(crate::models::hotel::Hotel))]
#[diesel(table_name = crate::schema::room)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Room {
#[diesel_crud(pk)]
pub id: i32,
#[validate(range(min = 1))]
pub hotel_id: i32,
#[validate(range(min = 1))]
pub beds: i32,
#[validate(range(min = 1))]
pub size: i32,
}

75
src/models/session.rs Normal file
View File

@ -0,0 +1,75 @@
use crate::error::AppError;
use chrono::{DateTime, NaiveDateTime};
use diesel::{Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::{DieselCrudCreate, DieselCrudDelete, DieselCrudRead};
use serde_json::Value;
use std::collections::HashMap;
use std::time::SystemTime;
use tower_sessions::cookie::time::OffsetDateTime;
use tower_sessions::session;
use tower_sessions::session::Record;
#[derive(Insertable, Queryable, Selectable, DieselCrudCreate, DieselCrudRead, DieselCrudDelete)]
#[diesel(table_name = crate::schema::session)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Session {
#[diesel_crud(pk)]
pub id: String,
pub data: Value,
pub expiry_date: NaiveDateTime,
}
impl TryFrom<Record> for Session {
type Error = AppError;
fn try_from(value: Record) -> Result<Self, Self::Error> {
Ok(Self {
id: value.id.0.to_string(),
data: serde_json::to_value(value.data)?,
expiry_date: DateTime::from_timestamp_micros(value.expiry_date.unix_timestamp())
.ok_or(AppError::InternalServerError(
"Failed to convert time".into(),
))?
.naive_utc(),
})
}
}
impl Session {
pub fn try_into_record(self) -> Result<Record, AppError> {
Ok(Record {
id: session::Id(self.id.trim().parse::<i128>()?),
expiry_date: self.expiry_date.into_offset_date_time(),
data: self.data.into_hash_map(),
})
}
}
trait IntoOffsetDateTime {
fn into_offset_date_time(self) -> OffsetDateTime;
}
impl IntoOffsetDateTime for NaiveDateTime {
fn into_offset_date_time(self) -> OffsetDateTime {
let system_time = SystemTime::from(self.and_utc());
OffsetDateTime::from(system_time)
}
}
impl IntoHashMap<String, Value> for Value {
fn into_hash_map(self) -> HashMap<String, Value> {
if let Value::Object(map) = self {
map.keys().cloned().zip(map.values().cloned()).collect()
} else {
HashMap::new()
}
}
}
trait IntoHashMap<Key, Value> {
fn into_hash_map(self) -> HashMap<Key, Value>;
}
#[cfg(test)]
mod tests {
// TODO
}

78
src/models/task.rs Normal file
View File

@ -0,0 +1,78 @@
use diesel::{AsChangeset, Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::DieselCrud;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::Display;
use validator::{Validate, ValidationError};
#[derive(
Queryable, Selectable, AsChangeset, Validate, Serialize, Deserialize, IntoResponse, DieselCrud,
)]
#[diesel_crud(insert = CreateTask)]
#[diesel(table_name = crate::schema::task)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Task {
#[diesel_crud(pk)]
pub id: i32,
#[validate(range(min = 1))]
pub room_id: i32,
#[validate(length(min = 1))]
pub description: String,
#[validate(custom(function = "validate_task_status"))]
pub status: String, // TODO use Enum
}
#[derive(Insertable, Validate, Deserialize)]
#[diesel(table_name = crate::schema::task)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CreateTask {
#[validate(range(min = 1))]
pub room_id: i32,
#[validate(length(min = 1))]
pub description: String,
#[validate(custom(function = "validate_task_status"))]
pub status: String,
}
fn validate_task_status(status: &str) -> Result<(), ValidationError> {
if ["todo", "in_progress", "done"].contains(&status) {
Ok(())
} else {
let mut err = ValidationError::new("status must be 'todo', 'in_progress' or 'done'");
err.add_param(Cow::from("status"), &status);
Err(err)
}
}
#[derive(Debug, Clone, Copy, Display, Deserialize)]
#[strum(serialize_all = "snake_case")]
pub enum TaskStatus {
Todo,
InProgress,
Done,
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest]
fn test_validate_task_status_legal_status(
#[values("todo", "in_progress", "done")] status: &str,
) {
assert!(validate_task_status(status).is_ok());
}
#[rstest]
fn test_validate_task_status_illegal_status(#[values("illegal", "status")] status: &str) {
assert!(validate_task_status(status).is_err());
}
#[test]
fn test_enum_display() {
assert_eq!(TaskStatus::Todo.to_string(), "todo");
assert_eq!(TaskStatus::InProgress.to_string(), "in_progress");
assert_eq!(TaskStatus::Done.to_string(), "done");
}
}

126
src/models/user.rs Normal file
View File

@ -0,0 +1,126 @@
use crate::auth::{generate_salt, hash_password};
use crate::error::AppError;
use axum_login::AuthUser;
use base64ct::{Base64, Encoding};
use derive_more::From;
use diesel::{Insertable, Queryable, Selectable};
use lib::diesel_crud_derive::{DieselCrudCreate, DieselCrudDelete, DieselCrudList, DieselCrudRead};
use lib::into_response_derive::IntoResponse;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use strum::EnumIs;
use validator::Validate;
#[derive(
Debug,
Clone,
Queryable,
Selectable,
Insertable,
Validate,
Serialize,
Deserialize,
IntoResponse,
DieselCrudRead,
DieselCrudCreate,
DieselCrudDelete,
DieselCrudList,
)]
#[diesel(table_name = crate::schema::user)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
#[diesel_crud(pk)]
#[validate(email)]
pub email: String,
pub hash: String,
pub salt: String,
pub role: i16, // TODO: use enum
}
#[derive(Validate, Deserialize)]
pub struct UserCredentials {
#[validate(email)]
pub email: String,
pub password: SecretString,
}
#[derive(Clone, Validate, Deserialize)]
pub struct CreateUser {
#[validate(email)]
pub email: String,
pub password: SecretString,
pub role: Option<UserRole>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, EnumIs)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserRole {
Admin,
Receptionist,
Janitor,
#[default]
Guest,
}
impl CreateUser {
pub fn new(email: impl Into<String>, password: impl Into<String>) -> Self {
Self {
email: email.into(),
password: SecretString::new(password.into()),
role: None,
}
}
}
impl User {
pub fn hash_from_credentials(create_user: CreateUser) -> Result<Self, AppError> {
let salt = generate_salt();
let mut hashed = [0; 32];
hash_password(create_user.password.expose_secret(), &salt, &mut hashed);
Ok(Self {
email: create_user.email.clone(),
hash: Base64::encode_string(&hashed),
salt: String::from_utf8(salt.to_vec())?,
role: create_user.role.unwrap_or_default() as i16,
})
}
}
impl UserRole {
pub fn into_i16(self) -> i16 {
self as i16
}
}
impl AuthUser for User {
type Id = String;
fn id(&self) -> Self::Id {
self.email.clone()
}
fn session_auth_hash(&self) -> &[u8] {
// TODO use decode?
self.hash.as_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_from_credentials() {
let credentials = CreateUser {
email: "me@test.com".to_string(),
password: SecretString::new("password".to_string()),
role: None,
};
let user = User::hash_from_credentials(credentials.clone()).unwrap();
assert_eq!(user.email, credentials.email);
assert_eq!(user.role, UserRole::Guest.into_i16());
assert!(!user.hash.is_empty());
assert!(!user.salt.is_empty());
}
}

85
src/result.rs Normal file
View File

@ -0,0 +1,85 @@
use crate::error::ResponseError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
pub type ResponseResult<T = ()> = Result<Success<T>, ResponseError>;
#[derive(Debug, Clone, PartialEq)]
pub enum Success<T = ()> {
Ok(T),
Created(T),
}
impl<T: IntoResponse> IntoResponse for Success<T> {
fn into_response(self) -> Response {
match self {
Success::Ok(value) => (StatusCode::OK, value).into_response(),
Success::Created(value) => (StatusCode::CREATED, value).into_response(),
}
}
}
impl<T> From<T> for Success<T> {
fn from(value: T) -> Self {
Success::Ok(value)
}
}
#[macro_export]
macro_rules! ok {
() => {
Ok($crate::result::Success::Ok(()))
};
($expr:expr) => {
Ok($crate::result::Success::Ok($expr))
};
}
#[macro_export]
macro_rules! created {
() => {
Ok($crate::result::Success::Created(()))
};
($expr:expr) => {
Ok($crate::result::Success::Created($expr))
};
}
#[macro_export]
macro_rules! bad_request {
($expr:expr) => {
Err($crate::error::ResponseError::BadRequest($expr.to_string()))
};
}
#[macro_export]
macro_rules! not_found {
($expr:expr) => {
Err($crate::error::ResponseError::NotFound($expr.to_string()))
};
}
#[macro_export]
macro_rules! unauthorized {
($expr:expr) => {
Err($crate::error::ResponseError::Unauthorized(
$expr.to_string(),
))
};
}
#[macro_export]
macro_rules! forbidden {
() => {
Err($crate::error::ResponseError::Forbidden)
};
}
#[macro_export]
macro_rules! internal_server_error {
($expr:expr) => {
Err($crate::error::ResponseError::InternalServerError(
$expr.to_string(),
))
};
}

139
src/routes/auth.rs Normal file
View File

@ -0,0 +1,139 @@
use crate::auth::AuthSession;
use crate::database::GetConnection;
use crate::models::user::{CreateUser, User, UserCredentials};
use crate::result::{ResponseResult, Success};
use crate::services::user_service::UserService;
use crate::{bad_request, internal_server_error, ok};
use axum::extract::State;
use axum::Json;
use axum_valid::Valid;
// router!(
// "/auth",
// routes!(
// post "/login" => login::<Pool>,
// post "/register" => register::<Pool>
// ),
// Pool: Clone, Send, Sync, GetConnection -> UserService
// );
pub fn router<Pool: Clone + Send + Sync + GetConnection + 'static>(
) -> axum::Router<UserService<Pool>> {
axum::Router::new().nest(
"/auth",
axum::Router::new()
.route("/login", axum::routing::post(login::<Pool>))
.route("/register", axum::routing::post(register::<Pool>)),
)
}
async fn login<Pool>(
mut auth_session: AuthSession<Pool>,
Valid(Json(credentials)): Valid<Json<UserCredentials>>,
) -> ResponseResult
where
Pool: GetConnection,
{
let user = match auth_session.authenticate(credentials).await {
Ok(Some(user)) => user,
Ok(None) => return bad_request!("Invalid credentials"),
Err(error) => return internal_server_error!(error),
};
auth_session.login(&user).await?;
ok!()
}
async fn register<Pool>(
State(service): State<UserService<Pool>>,
Valid(Json(create_user)): Valid<Json<CreateUser>>,
) -> ResponseResult<User>
where
Pool: GetConnection,
{
service
.insert(create_user)
.await
.map(Success::Created)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use crate::create_app;
use crate::error::ErrorResponseBody;
use crate::models::user::{CreateUser, User, UserRole};
use crate::test::{create_test_containers_pool, create_test_pool, BuildJson, DeserializeInto};
use axum::http::request::Builder;
use axum::http::{Request, StatusCode};
use secrecy::ExposeSecret;
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use tower::ServiceExt;
fn register() -> Builder {
Request::builder().uri("/auth/register").method("POST")
}
#[tokio::test]
async fn test_register_created() {
let pool = create_test_pool().await.unwrap();
let app = create_app(pool);
let create_user = CreateUser::new("test@email.com", "password");
let create_email = create_user.email.clone();
let response = app
.oneshot(register().json(create_user).unwrap())
.await
.unwrap();
let status = response.status();
let user: User = response.deserialize_into().await.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert_eq!(user.email, create_email);
assert_eq!(user.role, UserRole::default() as i16);
assert!(!user.hash.is_empty());
assert!(!user.salt.is_empty());
}
#[tokio::test]
async fn test_register_email_already_registered() {
let test_container = create_test_containers_pool().await.unwrap();
let app = create_app(test_container.pool);
let create_user = CreateUser::new("test@email.com", "password");
let call = || async {
app.clone()
.oneshot(register().json(create_user.clone()).unwrap())
.await
.unwrap()
};
let created = call().await;
let conflicted = call().await;
let conflicted_status = conflicted.status();
let response: ErrorResponseBody = conflicted.deserialize_into().await.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
assert_eq!(conflicted_status, StatusCode::CONFLICT);
assert!(
response.error.contains("user_pkey"),
"Error: {}",
response.error
);
}
impl Serialize for CreateUser {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_struct("CreateUser", 2)?;
s.serialize_field("email", &self.email)?;
s.serialize_field("password", &self.password.expose_secret())?;
s.serialize_field("role", &self.role)?;
s.end()
}
}
}

54
src/routes/hotel.rs Normal file
View File

@ -0,0 +1,54 @@
use axum::extract::{Path, State};
use lib::diesel_crud_trait::{DieselCrudList, DieselCrudRead};
use crate::auth::{authenticate, AuthSession};
use crate::models::hotel::Hotel;
use crate::models::user::UserRole;
use crate::result::{ResponseResult, Success};
use crate::GetConnection;
use lib::axum::wrappers::Array;
use lib::{router, routes};
router!(
"/hotel",
routes! {
get "/:id" => get_hotel::<T>,
get "/list" => list_hotels::<T>,
},
T: Clone, GetConnection
);
async fn get_hotel<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult<Hotel>
where
Pool: GetConnection,
{
authenticate(auth_session.user.as_ref(), [UserRole::Admin], |_| async {
Hotel::read(id, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
})
.await
}
async fn list_hotels<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
) -> ResponseResult<Array<Hotel>>
where
Pool: GetConnection,
{
authenticate(auth_session.user.as_ref(), [UserRole::Admin], |_| async {
Hotel::list(pool.get().await?.as_mut())
.await
.map(Array::from)
.map(Success::Ok)
.map_err(Into::into)
})
.await
}

6
src/routes/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod auth;
pub mod hotel;
pub mod reservation;
pub mod room;
pub mod task;
pub mod user;

191
src/routes/reservation.rs Normal file
View File

@ -0,0 +1,191 @@
use crate::auth::{authenticate, AuthSession};
use crate::error::ResponseError;
use crate::models::reservation::{CreateReservation, Reservation};
use crate::models::user::UserRole;
use crate::result::{ResponseResult, Success};
use crate::{forbidden, ok, GetConnection};
use axum::extract::{Path, State};
use axum::Json;
use axum_valid::Valid;
use lib::axum::wrappers::Array;
use lib::diesel_crud_trait::{
DieselCrudCreate, DieselCrudDelete, DieselCrudList, DieselCrudRead, DieselCrudUpdate,
};
use lib::time::DateTimeInterval;
use lib::{router, routes};
router!(
"/reservation",
routes! {
get "/:id" => get_reservation::<T>,
get "/" => list_reservations::<T>,
post "/" => create_reservation::<T>,
patch "/" => update_reservation::<T>,
delete "/:id" => delete_reservation::<T>,
patch "/:id/check-in" => check_in::<T>,
patch "/:id/check-out" => check_out::<T>,
},
T: Clone, GetConnection
);
async fn get_reservation<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult<Reservation>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist, UserRole::Guest],
|user| async {
let result = Reservation::read(id, pool.get().await?.as_mut()).await?;
if user.role == UserRole::Admin as i16
|| user.role == UserRole::Receptionist as i16
|| result.user == user.email
{
ok!(result)
} else {
forbidden!()
}
},
)
.await
}
async fn list_reservations<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
) -> ResponseResult<Array<Reservation>>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Reservation::list(pool.get().await?.as_mut())
.await
.map(Array::from)
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}
async fn create_reservation<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(reservation)): Valid<Json<CreateReservation>>,
) -> ResponseResult<Reservation>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist, UserRole::Guest],
|_| async {
let interval = DateTimeInterval::new_safe(reservation.start, reservation.end).ok_or(
ResponseError::BadRequest("Start date must be before end date".into()),
)?;
let mut conn = pool.get().await?;
Reservation::room_available(reservation.room_id, interval, &mut conn).await?;
Reservation::insert(reservation, &mut conn)
.await
.map(Success::Created)
.map_err(Into::into)
},
)
.await
}
async fn update_reservation<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(reservation)): Valid<Json<Reservation>>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
let interval = DateTimeInterval::new_safe(reservation.start, reservation.end).ok_or(
ResponseError::BadRequest("Start date must be before end date".into()),
)?;
let mut conn = pool.get().await?;
Reservation::room_available(reservation.room_id, interval, &mut conn).await?;
Reservation::update(reservation, &mut conn)
.await
.map(|_| Success::Ok(()))
.map_err(Into::into)
},
)
.await
}
async fn delete_reservation<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Reservation::delete(id, pool.get().await?.as_mut())
.await
.map(|_| Success::Ok(()))
.map_err(Into::into)
},
)
.await
}
async fn check_in<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Reservation::check_in(id, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}
async fn check_out<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Reservation::check_out(id, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}

150
src/routes/room.rs Normal file
View File

@ -0,0 +1,150 @@
use crate::auth::{authenticate, AuthSession};
use crate::models::room::Room;
use crate::models::user::UserRole;
use crate::result::{ResponseResult, Success};
use crate::services::room_service::RoomQuery;
use crate::{ok, GetConnection};
use axum::extract::{Path, State};
use axum::Json;
use axum_valid::Valid;
use lib::axum::wrappers::Array;
use lib::diesel_crud_trait::{
DieselCrudCreate, DieselCrudDelete, DieselCrudList, DieselCrudRead, DieselCrudUpdate,
};
use lib::{router, routes};
router!(
"/room",
routes!(
get "/:id" => get_room::<T>,
post "/" => create_room::<T>,
patch "/" => update_room::<T>,
delete "/:id" => delete_room::<T>,
get "/" => list_rooms::<T>,
post "/query" => query_rooms::<T>
),
T: Clone, GetConnection
);
async fn get_room<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult<Room>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::read(id, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}
async fn create_room<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(room)): Valid<Json<Room>>,
) -> ResponseResult<Room>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::insert(room, pool.get().await?.as_mut())
.await
.map(Success::Created)
.map_err(Into::into)
},
)
.await
}
async fn update_room<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(room)): Valid<Json<Room>>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::update(room, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}
async fn delete_room<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult
where
Pool: GetConnection + Send + Sync + 'static,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::delete(id, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}
async fn list_rooms<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
) -> ResponseResult<Array<Room>>
where
Pool: GetConnection + Send + Sync + 'static,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::list(pool.get().await?.as_mut())
.await
.map(Array::from)
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}
async fn query_rooms<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Json(query): Json<RoomQuery>,
) -> ResponseResult<Array<Room>>
where
Pool: GetConnection + Send + Sync + 'static,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Receptionist],
|_| async {
Room::query_rooms(query, pool.get().await?.as_mut())
.await
.map(Array::from)
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}

155
src/routes/task.rs Normal file
View File

@ -0,0 +1,155 @@
use crate::auth::{authenticate, AuthSession};
use crate::models::task::{CreateTask, Task};
use crate::models::user::UserRole;
use crate::result::{ResponseResult, Success};
use crate::{ok, GetConnection};
use axum::extract::{Path, State};
use axum::Json;
use axum_valid::Valid;
use lib::diesel_crud_trait::{
DieselCrudCreate, DieselCrudDelete, DieselCrudRead, DieselCrudUpdate,
};
use lib::{router, routes};
router!(
"/task",
routes!(
get "/:id" => get_task::<T>,
post "/" => create_task::<T>,
patch "/" => update_task::<T>,
delete "/:id" => delete_task::<T>,
patch "/:id/description" => set_description::<T>,
patch "/:id/status" => set_status::<T>
),
T: Clone, GetConnection
);
#[derive(serde::Deserialize)]
struct Description {
description: String,
}
#[derive(serde::Deserialize)]
struct TaskStatus {
status: crate::models::task::TaskStatus,
}
async fn get_task<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult<Task>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::read(id, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
},
)
.await
}
async fn create_task<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(create_task)): Valid<Json<CreateTask>>,
) -> ResponseResult<Task>
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::insert(create_task, pool.get().await?.as_mut())
.await
.map(Success::Created)
.map_err(Into::into)
},
)
.await
}
async fn update_task<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Valid(Json(update_task)): Valid<Json<Task>>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::update(update_task, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}
async fn delete_task<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::delete(id, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}
async fn set_description<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
Json(Description { description }): Json<Description>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::set_description(id, description, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}
async fn set_status<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
Path(id): Path<i32>,
Json(TaskStatus { status }): Json<TaskStatus>,
) -> ResponseResult
where
Pool: GetConnection,
{
authenticate(
auth_session.user.as_ref(),
[UserRole::Admin, UserRole::Janitor, UserRole::Receptionist],
|_| async {
Task::set_status(id, status, pool.get().await?.as_mut()).await?;
ok!()
},
)
.await
}

50
src/routes/user.rs Normal file
View File

@ -0,0 +1,50 @@
use crate::auth::{authenticate, AuthSession};
use crate::models::user::{User, UserRole};
use crate::result::{ResponseResult, Success};
use crate::GetConnection;
use axum::extract::{Path, State};
use lib::axum::wrappers::Array;
use lib::diesel_crud_trait::{DieselCrudList, DieselCrudRead};
use lib::{router, routes};
router!(
"/user",
routes!(
get "/:email" => get_user::<T>,
get "/" => list_users::<T>
),
T: Clone, GetConnection
);
async fn get_user<Pool>(
auth_session: AuthSession<Pool>,
Path(email): Path<String>,
State(pool): State<Pool>,
) -> ResponseResult<User>
where
Pool: GetConnection,
{
authenticate(auth_session.user.as_ref(), [UserRole::Admin], |_| async {
User::read(email, pool.get().await?.as_mut())
.await
.map(Success::Ok)
.map_err(Into::into)
})
.await
}
async fn list_users<Pool>(
auth_session: AuthSession<Pool>,
State(pool): State<Pool>,
) -> ResponseResult<Array<User>>
where
Pool: GetConnection,
{
authenticate(auth_session.user.as_ref(), [UserRole::Admin], |_| async {
User::list(pool.get().await?.as_mut())
.await
.map(Array::from)
.map(Success::Ok)
.map_err(Into::into)
})
.await
}

77
src/schema.rs Normal file
View File

@ -0,0 +1,77 @@
// @generated automatically by Diesel CLI.
diesel::table! {
hotel (id) {
id -> Int4,
#[max_length = 255]
name -> Varchar,
#[max_length = 255]
address -> Varchar,
}
}
diesel::table! {
reservation (id) {
id -> Int4,
room_id -> Int4,
start -> Timestamp,
end -> Timestamp,
#[max_length = 255]
user -> Varchar,
checked_in -> Bool,
}
}
diesel::table! {
room (id) {
id -> Int4,
hotel_id -> Int4,
beds -> Int4,
size -> Int4,
}
}
diesel::table! {
session (id) {
#[max_length = 128]
id -> Varchar,
data -> Jsonb,
expiry_date -> Timestamp,
}
}
diesel::table! {
task (id) {
id -> Int4,
room_id -> Int4,
description -> Text,
#[max_length = 12]
status -> Varchar,
}
}
diesel::table! {
user (email) {
#[max_length = 255]
email -> Varchar,
#[max_length = 255]
hash -> Varchar,
#[max_length = 255]
salt -> Varchar,
role -> Int2,
}
}
diesel::joinable!(reservation -> room (room_id));
diesel::joinable!(reservation -> user (user));
diesel::joinable!(room -> hotel (hotel_id));
diesel::joinable!(task -> room (room_id));
diesel::allow_tables_to_appear_in_same_query!(
hotel,
reservation,
room,
session,
task,
user,
);

5
src/services/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod reservation_service;
pub mod room_service;
pub mod session_service;
pub mod task_service;
pub mod user_service;

View File

@ -0,0 +1,322 @@
use crate::models::reservation::Reservation;
use crate::schema::{reservation, room};
use diesel::expression_methods::ExpressionMethods;
use diesel::result::DatabaseErrorKind;
use diesel::{BoolExpressionMethods, QueryDsl};
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use lib::diesel_crud_trait::CrudError;
use lib::time::DateTimeInterval;
use serde::Serialize;
use thiserror::Error;
impl Reservation {
pub async fn check_in(id: i32, conn: &mut AsyncPgConnection) -> Result<(), ReservationError> {
Self::update_check_in(id, true, conn).await
}
pub async fn check_out(id: i32, conn: &mut AsyncPgConnection) -> Result<(), ReservationError> {
Self::update_check_in(id, false, conn).await
}
pub async fn room_available(
room_number: i32,
DateTimeInterval { start, end }: DateTimeInterval,
conn: &mut AsyncPgConnection,
) -> Result<(), ReservationError> {
let available_rooms: i64 = room::table
.left_outer_join(reservation::table)
.filter(room::id.eq(room_number))
.filter(
reservation::id
.is_null()
.or(reservation::end.le(start))
.or(reservation::start.ge(end)),
)
.count()
.get_result(conn)
.await?;
if available_rooms == 0 {
Err(ReservationError::RoomNotAvailable)
} else {
Ok(())
}
}
async fn update_check_in(
id: i32,
to: bool,
conn: &mut AsyncPgConnection,
) -> Result<(), ReservationError> {
let find_query = reservation::table.find(id);
let reservation: Reservation = find_query.get_result(conn).await?;
if to && reservation.checked_in {
return Err(ReservationError::AlreadyCheckedIn);
}
diesel::update(find_query)
.set(reservation::checked_in.eq(to))
.execute(conn)
.await?;
Ok(())
}
}
#[derive(Debug, PartialEq, Error, Serialize)]
pub enum ReservationError {
#[error("Reservation not found")]
NotFound,
#[error("Reservation already checked in")]
AlreadyCheckedIn,
#[error("Room does not exist")]
RoomNotFound,
#[error("User does not exist")]
UserNotFound,
#[error("Room is not available")]
RoomNotAvailable,
#[error("Database error: {0}")]
Other(String),
}
impl From<CrudError> for ReservationError {
fn from(value: CrudError) -> Self {
match value {
CrudError::NotFound => ReservationError::NotFound,
CrudError::PoolError(pool_error) => ReservationError::Other(pool_error),
CrudError::Other(diesel_error) => diesel_error.into(),
}
}
}
type DieselError = diesel::result::Error;
impl From<DieselError> for ReservationError {
fn from(error: DieselError) -> Self {
match &error {
DieselError::NotFound => ReservationError::NotFound,
DieselError::DatabaseError(DatabaseErrorKind::ForeignKeyViolation, info) => {
if info.constraint_name().unwrap_or_default().contains("room") {
ReservationError::RoomNotFound
} else if info.constraint_name().unwrap_or_default().contains("user") {
ReservationError::UserNotFound
} else {
ReservationError::Other(error.to_string())
}
}
_ => ReservationError::Other(error.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::hotel::{CreateHotel, Hotel};
use crate::models::reservation::CreateReservation;
use crate::models::room::Room;
use crate::models::user::{CreateUser, User};
use crate::schema::{hotel, room, user};
use crate::test::setup_test_transaction;
use chrono::{Duration, Utc};
use diesel::dsl::insert_into;
use diesel_async::AsyncPgConnection;
use lib::diesel_crud_trait::{DieselCrudCreate, DieselCrudRead};
use secrecy::SecretString;
#[rstest]
#[tokio::test]
async fn test_check_in(#[future] setup: Setup) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
Reservation::check_in(reservation.id, &mut conn)
.await
.unwrap();
assert!(
Reservation::read(reservation.id, &mut conn)
.await
.unwrap()
.checked_in
);
}
#[rstest]
#[tokio::test]
async fn test_check_out(#[future] setup: Setup) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
Reservation::check_out(reservation.id, &mut conn)
.await
.unwrap();
assert!(
!Reservation::read(reservation.id, &mut conn)
.await
.unwrap()
.checked_in
);
}
#[rstest]
#[case::available(
Duration::days(12),
Duration::days(14),
Ok(())
)]
#[case::start_and_end_in_interval(
Duration::days(2),
Duration::days(4),
Err(ReservationError::RoomNotAvailable)
)]
#[case::start_in_interval(
Duration::days(6),
Duration::days(10),
Err(ReservationError::RoomNotAvailable)
)]
#[case::end_in_interval(
Duration::days(-2),
Duration::days(2),
Err(ReservationError::RoomNotAvailable)
)]
#[case::start_before_and_end_after_interval(
Duration::days(-2),
Duration::days(11),
Err(ReservationError::RoomNotAvailable)
)]
#[tokio::test]
async fn test_room_available(
#[future] setup: Setup,
#[case] start: Duration,
#[case] end: Duration,
#[case] expected: Result<(), ReservationError>,
) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
let now = Utc::now().naive_utc();
let start = now + start;
let end = now + end;
assert_eq!(
Reservation::room_available(reservation.room_id, (start, end).into(), &mut conn).await,
expected
);
}
#[rstest]
#[tokio::test]
async fn test_room_available_no_reservations(#[future] setup: Setup) {
let Setup {
mut conn, hotel, ..
} = setup.await;
let room = Room::insert(
Room {
id: 2,
hotel_id: hotel.id,
beds: 1,
size: 1,
},
&mut conn,
)
.await
.unwrap();
let now = Utc::now().naive_utc();
let start = now;
let end = now + Duration::days(10);
assert_eq!(
Reservation::room_available(room.id, (start, end).into(), &mut conn).await,
Ok(())
);
}
#[fixture]
async fn setup() -> Setup {
let mut conn = setup_test_transaction().await.unwrap();
let hotel = insert_hotel(&mut conn).await;
let user = insert_user(&mut conn).await;
let room = insert_room(&mut conn, hotel.id).await;
let reservation = insert_reservation(&mut conn, room.id, user.email).await;
Setup {
conn,
hotel,
reservation,
}
}
struct Setup {
conn: AsyncPgConnection,
hotel: Hotel,
reservation: Reservation,
}
async fn insert_hotel(conn: &mut AsyncPgConnection) -> Hotel {
insert_into(hotel::table)
.values(CreateHotel::new(
None,
"test".to_string(),
"test".to_string(),
))
.get_result(conn)
.await
.unwrap()
}
async fn insert_room(conn: &mut AsyncPgConnection, hotel_id: i32) -> Room {
insert_into(room::table)
.values(Room {
id: 1,
hotel_id,
beds: 1,
size: 1,
})
.get_result(conn)
.await
.unwrap()
}
async fn insert_user(conn: &mut AsyncPgConnection) -> User {
insert_into(user::table)
.values(
User::hash_from_credentials(CreateUser {
email: "test_again@test.com".to_string(),
password: SecretString::new("test".to_string()),
role: None,
})
.unwrap(),
)
.get_result(conn)
.await
.unwrap()
}
async fn insert_reservation(
conn: &mut AsyncPgConnection,
room_id: i32,
user: String,
) -> Reservation {
let now = Utc::now().naive_utc();
insert_into(reservation::table)
.values(CreateReservation {
room_id,
user,
start: now,
end: now + Duration::days(7),
})
.get_result(conn)
.await
.unwrap()
}
}

View File

@ -0,0 +1,206 @@
use crate::error::AppError;
use crate::models::room::Room;
use crate::schema::{reservation, room};
use diesel::expression_methods::ExpressionMethods;
use diesel::{BoolExpressionMethods, QueryDsl};
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use lib::time::DateTimeInterval;
use serde::Deserialize;
#[cfg_attr(test, bon::builder)]
#[derive(Clone, Copy, PartialEq, Eq, Default, Deserialize)]
pub struct RoomQuery {
pub available: Option<DateTimeInterval>,
pub hotel_id: Option<i32>,
pub min_beds: Option<i32>,
pub min_size: Option<i32>,
}
impl Room {
pub async fn query_rooms(
RoomQuery {
available,
hotel_id,
min_beds,
min_size,
}: RoomQuery,
conn: &mut AsyncPgConnection,
) -> Result<Vec<Room>, AppError> {
let mut table = room::table.left_outer_join(reservation::table).into_boxed();
if let Some(DateTimeInterval { start, end }) = available {
table = table.filter(
reservation::id
.is_null()
.or(reservation::end.le(start))
.or(reservation::start.ge(end)),
)
}
if let Some(id) = hotel_id {
table = table.filter(room::hotel_id.eq(id));
}
if let Some(beds) = min_beds {
table = table.filter(room::beds.ge(beds));
}
if let Some(size) = min_size {
table = table.filter(room::size.ge(size));
}
table
.select(room::all_columns)
.get_results(conn)
.await
.map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::hotel::{CreateHotel, Hotel};
use crate::models::reservation::{CreateReservation, Reservation};
use crate::models::room::Room;
use crate::models::user::{CreateUser, User};
use crate::test::setup_test_transaction;
use diesel_async::AsyncPgConnection;
use lib::diesel_crud_trait::DieselCrudCreate;
use serde_json::json;
#[rstest]
#[case::all_none(RoomQuery::default(), &[1, 2, 3, 4])]
#[case::min_beds_are_2(
RoomQuery::builder()
.min_beds(2)
.build(),
&[3, 4]
)]
#[case::min_beds_are_greater_than_all(
RoomQuery::builder()
.hotel_id(5)
.build(),
&[]
)]
#[case::min_size_are_5(
RoomQuery::builder()
.min_size(5)
.build(),
&[2, 4]
)]
#[case::min_size_is_greater_than_all(
RoomQuery::builder()
.min_size(10)
.build(),
&[]
)]
#[case::min_size_and_min_beds(
RoomQuery::builder()
.min_size(5)
.min_beds(2)
.build(),
&[4]
)]
#[case::interval_when_no_reservations_in_interval(
RoomQuery::builder()
.available(DateTimeInterval {
start: chrono::Utc::now().naive_utc() + chrono::Duration::days(10),
end: chrono::Utc::now().naive_utc() + chrono::Duration::days(20)
})
.build(),
&[1, 2, 3, 4]
)]
#[case::interval_when_one_reservation_in_interval(
RoomQuery::builder()
.available(DateTimeInterval {
start: chrono::Utc::now().naive_utc(),
end: chrono::Utc::now().naive_utc() + chrono::Duration::days(10)
})
.build(),
&[2, 3, 4]
)]
#[case::all_filters(
RoomQuery::builder()
.available(DateTimeInterval {
start: chrono::Utc::now().naive_utc(),
end: chrono::Utc::now().naive_utc() + chrono::Duration::days(10)
})
.hotel_id(1)
.min_beds(2)
.min_size(1)
.build(),
&[3]
)]
#[tokio::test]
async fn test_query_rooms(
#[future] setup: Setup,
#[case] query: RoomQuery,
#[case] expected_ids: &[i32],
) {
let Setup {
mut conn, rooms, ..
} = setup.await;
let result = Room::query_rooms(query, &mut conn).await.unwrap();
assert_eq!(
result,
rooms
.into_iter()
.filter(|room| expected_ids.contains(&room.id))
.collect::<Vec<_>>()
);
}
#[fixture]
async fn setup() -> Setup {
let mut conn = setup_test_transaction().await.unwrap();
let hotels = insert_hotels(&mut conn).await;
let rooms = insert_rooms(&mut conn, &hotels).await;
let _reservation = insert_reservation(&mut conn, rooms[0].id).await;
Setup { conn, rooms }
}
struct Setup {
conn: AsyncPgConnection,
rooms: Vec<Room>,
}
async fn insert_hotels(conn: &mut AsyncPgConnection) -> Vec<Hotel> {
let hotels = vec![
CreateHotel::new(Some(1), "hotel1".into(), "hotel1".into()),
CreateHotel::new(Some(2), "hotel2".into(), "hotel2".into()),
];
Hotel::insert_many(&hotels, conn).await.unwrap()
}
async fn insert_rooms(conn: &mut AsyncPgConnection, hotels: &[Hotel]) -> Vec<Room> {
let rooms: Vec<Room> = json!(
[
{ "beds": 1, "size": 1 },
{ "beds": 1, "size": 5 },
{ "beds": 2, "size": 1 },
{ "beds": 2, "size": 5 }
]
)
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(index, room)| Room {
hotel_id: hotels[index % 2].id,
beds: room["beds"].as_i64().unwrap() as i32,
size: room["size"].as_i64().unwrap() as i32,
id: index as i32 + 1,
})
.collect();
Room::insert_many(&rooms, conn).await.unwrap();
rooms
}
async fn insert_reservation(conn: &mut AsyncPgConnection, room_id: i32) -> Reservation {
let user = User::insert(
User::hash_from_credentials(CreateUser::new("test@test.test", "test")).unwrap(),
conn,
)
.await
.unwrap();
let now = chrono::Utc::now().naive_utc();
let reservation =
CreateReservation::new(room_id, now, now + chrono::Duration::days(5), user.email);
Reservation::insert(reservation, conn).await.unwrap()
}
}

View File

@ -0,0 +1,102 @@
use crate::error::AppError;
use crate::models::session::Session;
use crate::GetConnection;
use axum::async_trait;
use derive_more::Constructor;
use lib::diesel_crud_trait::{DieselCrudCreate, DieselCrudDelete, DieselCrudRead};
use std::fmt::Debug;
use tower_sessions::session::{Id, Record};
use tower_sessions::{session_store, SessionStore};
#[derive(Clone, Constructor)]
pub struct SessionService<Pool>
where
Pool: GetConnection,
{
pool: Pool,
}
impl<Pool> Debug for SessionService<Pool>
where
Pool: GetConnection,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionService")
.field("pool", &self.pool.status())
.finish()
}
}
impl<Pool> SessionService<Pool>
where
Pool: GetConnection,
{
async fn create(&self, session: Session) -> Result<Session, AppError> {
Session::insert(session, self.pool.get().await?.as_mut())
.await
.map_err(Into::into)
}
async fn read(&self, session_id: String) -> Result<Session, AppError> {
Session::read(session_id, self.pool.get().await?.as_mut())
.await
.map_err(Into::into)
}
async fn delete(&self, session_id: String) -> Result<Session, AppError> {
Session::delete(session_id, self.pool.get().await?.as_mut())
.await
.map_err(Into::into)
}
}
#[async_trait]
impl<Pool> SessionStore for SessionService<Pool>
where
Pool: GetConnection + 'static,
{
async fn create(&self, session_record: &mut Record) -> session_store::Result<()> {
self.save(session_record).await
}
async fn save(&self, session_record: &Record) -> session_store::Result<()> {
let Ok(model) = Session::try_from(session_record.clone()) else {
return Err(session_store::Error::Backend(
"Failed to parse record to session model".to_string(),
));
};
Self::create(self, model).await.map_err(|error| {
session_store::Error::Backend(format!("Failed to save session: {}", error))
})?;
Ok(())
}
async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
match self.read(session_id.0.to_string()).await {
Ok(session) => match session.try_into_record() {
Ok(record) => Ok(Some(record)),
Err(error) => Err(session_store::Error::Decode(error.to_string())),
},
Err(AppError::NotFound) => Ok(None),
Err(error) => Err(session_store::Error::Backend(format!(
"Failed to load session: {error}",
))),
}
}
/// Session-fixation cycling attempts to delete the session with the old session ID.
/// If the session does not exist, it is considered a success.
async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
match Self::delete(self, session_id.0.to_string()).await {
Ok(_) | Err(AppError::NotFound) => Ok(()),
Err(error) => Err(session_store::Error::Backend(format!(
"Failed to delete session: {error}",
))),
}
}
}
#[cfg(test)]
mod tests {
// TODO
}

View File

@ -0,0 +1,37 @@
use crate::error::AppError;
use crate::models::task::{Task, TaskStatus};
use crate::schema::task;
use diesel::expression_methods::ExpressionMethods;
use diesel::QueryDsl;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
impl Task {
pub async fn set_description(
id: i32,
description: String,
conn: &mut AsyncPgConnection,
) -> Result<(), AppError> {
diesel::update(task::table.find(id))
.set(task::description.eq(description))
.execute(conn)
.await?;
Ok(())
}
pub async fn set_status(
id: i32,
status: TaskStatus,
conn: &mut AsyncPgConnection,
) -> Result<(), AppError> {
diesel::update(task::table.find(id))
.set(task::status.eq(status.to_string()))
.execute(conn)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
// TODO
}

View File

@ -0,0 +1,75 @@
use crate::auth::hash_password;
use crate::error::{AppError, ResponseError};
use crate::models::user::{CreateUser, User, UserCredentials};
use crate::GetConnection;
use axum::async_trait;
use axum_login::{AuthnBackend, UserId};
use base64ct::{Base64, Encoding};
use derive_more::Constructor;
use lib::diesel_crud_trait::{DieselCrudCreate, DieselCrudRead};
use secrecy::ExposeSecret;
#[derive(Clone, Constructor)]
pub struct UserService<Pool>
where
Pool: GetConnection,
{
pool: Pool,
}
impl<Pool> UserService<Pool>
where
Pool: GetConnection,
{
pub async fn insert(&self, create: CreateUser) -> Result<User, ResponseError> {
User::insert(
User::hash_from_credentials(create)?,
self.pool.get().await?.as_mut(),
)
.await
.map_err(Into::into)
}
async fn read(&self, email: String) -> Result<User, AppError> {
User::read(email, self.pool.get().await?.as_mut())
.await
.map_err(Into::into)
}
}
#[async_trait]
impl<Pool> AuthnBackend for UserService<Pool>
where
Pool: GetConnection,
{
type User = User;
type Credentials = UserCredentials;
type Error = AppError;
async fn authenticate(
&self,
UserCredentials { email, password }: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = self.read(email.clone()).await?;
let password = password.expose_secret();
let mut input_hash = [0; 32];
hash_password(password, user.salt.as_bytes(), &mut input_hash);
let mut user_hash = [0; 32];
Base64::decode(&user.hash, &mut user_hash)?;
if user.email == email && user_hash == input_hash {
Ok(Some(user))
} else {
Ok(None)
}
}
async fn get_user(&self, email: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
Ok(self.read(email.to_string()).await.ok())
}
}
#[cfg(test)]
mod tests {
// TODO
}

129
src/test.rs Normal file
View File

@ -0,0 +1,129 @@
use crate::config;
use crate::database::{create_pool, create_pool_from_url, GetConnection, PgPool};
use crate::error::AppError;
use axum::async_trait;
use axum::body::{to_bytes, Body};
use axum::http::header::CONTENT_TYPE;
use axum::http::Request;
use axum::response::Response;
use deadpool_diesel::postgres::BuildError;
use deadpool_diesel::Status;
use derive_more::Constructor;
use diesel_async::pooled_connection::deadpool::{Object, PoolError};
use diesel_async::{AsyncConnection, AsyncPgConnection};
use mime::APPLICATION_JSON;
use serde::{Deserialize, Serialize};
use serde_json::json;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::{ContainerAsync, TestcontainersError};
use thiserror::Error;
#[derive(Debug, PartialEq, Error)]
pub enum Error {
#[error(transparent)]
Connection(#[from] diesel::ConnectionError),
#[error(transparent)]
Database(#[from] diesel::result::Error),
}
pub async fn setup_test_transaction() -> Result<AsyncPgConnection, Error> {
let mut conn = AsyncPgConnection::establish(config::DATABASE_URL).await?;
conn.begin_test_transaction().await?;
Ok(conn)
}
pub(crate) async fn create_test_pool() -> Result<PoolStub, BuildError> {
let pool = create_pool()?;
Ok(PoolStub(pool))
}
#[derive(Debug, Error)]
pub(crate) enum ContainerError {
#[error(transparent)]
TestContainers(#[from] TestcontainersError),
#[error(transparent)]
BuildError(#[from] BuildError),
#[error(transparent)]
PoolError(#[from] PoolError),
#[error(transparent)]
DieselError(#[from] diesel::result::Error),
}
/// When the TestContainer is dropped, the container will be removed.
/// # Panics
/// If destructed and the container field is dropped, the container will be removed, and using the pool will cause panic.
#[derive(Constructor)]
pub(crate) struct TestContainer {
pub _container: ContainerAsync<Postgres>,
pub pool: PgPool,
}
pub(crate) async fn create_test_containers_pool<'a>() -> Result<TestContainer, ContainerError> {
let container = create_postgres_container().await?;
let connection_string = format!(
"postgres://postgres:postgres@127.0.0.1:{}/postgres",
container.get_host_port_ipv4(5432).await?
);
let pool = create_pool_from_url(connection_string)?;
run_migrations(pool.get().await?.as_mut()).await?;
Ok(TestContainer::new(container, pool))
}
pub(crate) async fn create_postgres_container(
) -> Result<ContainerAsync<Postgres>, TestcontainersError> {
Postgres::default().start().await
}
pub(crate) async fn run_migrations(
conn: &mut AsyncPgConnection,
) -> Result<(), diesel::result::Error> {
config::MIGRATIONS.run_pending_migrations(conn).await
}
#[derive(Clone)]
pub(crate) struct PoolStub(PgPool);
#[async_trait]
impl GetConnection for PoolStub {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError> {
let mut conn = self.0.get().await?;
conn.begin_test_transaction().await?;
Ok(conn)
}
fn status(&self) -> Status {
unimplemented!("PoolStub does not support status")
}
}
pub trait BuildJson {
fn json<T: Serialize>(self, body: T) -> Result<Request<Body>, axum::http::Error>;
}
impl BuildJson for axum::http::request::Builder {
fn json<T: Serialize>(self, body: T) -> Result<Request<Body>, axum::http::Error> {
self.header(CONTENT_TYPE, APPLICATION_JSON.as_ref())
.body(Body::new(json!(body).to_string()))
}
}
#[derive(Debug, Error)]
pub(crate) enum DeserializeError {
#[error(transparent)]
SerdeError(#[from] serde_json::Error),
#[error(transparent)]
AxumError(#[from] axum::Error),
}
#[async_trait]
pub trait DeserializeInto {
async fn deserialize_into<T: for<'de> Deserialize<'de>>(self) -> Result<T, DeserializeError>;
}
#[async_trait]
impl DeserializeInto for Response {
async fn deserialize_into<T: for<'de> Deserialize<'de>>(self) -> Result<T, DeserializeError> {
let body = to_bytes(self.into_body(), usize::MAX).await?;
serde_json::from_slice(&body).map_err(Into::into)
}
}