First working API.

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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