First working API.
Simple auth by creating sessions and storing in db
This commit is contained in:
commit
5d5e6393ac
1
.env
Normal file
1
.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL="postgres://postgres:postgres@localhost:32784/postgres"
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
12
.idea/dataSources.xml
generated
Normal 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
15
.idea/hotel_service.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
9
.idea/sqldialects.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
3422
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
Normal file
54
Cargo.toml
Normal 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
27
Makefile.toml
Normal 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
7
README.md
Normal file
@ -0,0 +1,7 @@
|
||||
- [ ] Tests for handlers
|
||||
- [ ] Generic AsyncConnection or mocking
|
||||
- [ ] GitHub Actions
|
||||
- [ ] Dockerfile
|
||||
- [ ] OAuth2 ?
|
||||
- [ ] OpenAPI
|
||||
- [ ] Streaming
|
9
diesel.toml
Normal file
9
diesel.toml
Normal 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
18
http/auth.http
Normal 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"
|
||||
}
|
5
http/http-client.env.json
Normal file
5
http/http-client.env.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"dev": {
|
||||
"baseurl": "http://localhost:8000"
|
||||
}
|
||||
}
|
5
http/reservation.http
Normal file
5
http/reservation.http
Normal 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
0
migrations/.keep
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal 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();
|
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal file
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal 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;
|
6
migrations/2024-08-04-214620_init_tables/down.sql
Normal file
6
migrations/2024-08-04-214620_init_tables/down.sql
Normal 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;
|
51
migrations/2024-08-04-214620_init_tables/up.sql
Normal file
51
migrations/2024-08-04-214620_init_tables/up.sql
Normal 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
102
src/auth.rs
Normal 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
8
src/config.rs
Normal 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
35
src/database.rs
Normal 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
180
src/error.rs
Normal 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
100
src/main.rs
Normal 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
34
src/models/common.rs
Normal 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
33
src/models/hotel.rs
Normal 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
7
src/models/mod.rs
Normal 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
109
src/models/reservation.rs
Normal 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
34
src/models/room.rs
Normal 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
75
src/models/session.rs
Normal 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
78
src/models/task.rs
Normal 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
126
src/models/user.rs
Normal 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
85
src/result.rs
Normal 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
139
src/routes/auth.rs
Normal 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
54
src/routes/hotel.rs
Normal 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
6
src/routes/mod.rs
Normal 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
191
src/routes/reservation.rs
Normal 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
150
src/routes/room.rs
Normal 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
155
src/routes/task.rs
Normal 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
50
src/routes/user.rs
Normal 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
77
src/schema.rs
Normal 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
5
src/services/mod.rs
Normal 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;
|
322
src/services/reservation_service.rs
Normal file
322
src/services/reservation_service.rs
Normal 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()
|
||||
}
|
||||
}
|
206
src/services/room_service.rs
Normal file
206
src/services/room_service.rs
Normal 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()
|
||||
}
|
||||
}
|
102
src/services/session_service.rs
Normal file
102
src/services/session_service.rs
Normal 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
|
||||
}
|
37
src/services/task_service.rs
Normal file
37
src/services/task_service.rs
Normal 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
|
||||
}
|
75
src/services/user_service.rs
Normal file
75
src/services/user_service.rs
Normal 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
129
src/test.rs
Normal 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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user