First commit
This commit is contained in:
parent
774d3d3b32
commit
10151b234f
57 changed files with 3697 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
backend/target
|
7
backend/.env
Normal file
7
backend/.env
Normal file
|
@ -0,0 +1,7 @@
|
|||
DATABASE_URL="postgres://postgres:password@localhost:5432/pylon"
|
||||
|
||||
# The lowest level of log event to be recorded.
|
||||
RUST_LOG="debug"
|
||||
|
||||
# The port on which the server should listen for requests.
|
||||
SERVER_PORT="8080"
|
14
backend/.sqlx/query-183ad1d8316ef2ae5ac6ae4811b8a2bdbaeabbe137a871e26741a419a1aa5b19.json
generated
Normal file
14
backend/.sqlx/query-183ad1d8316ef2ae5ac6ae4811b8a2bdbaeabbe137a871e26741a419a1aa5b19.json
generated
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM todos WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "183ad1d8316ef2ae5ac6ae4811b8a2bdbaeabbe137a871e26741a419a1aa5b19"
|
||||
}
|
40
backend/.sqlx/query-3adb46818539e755dc942a3e66ab0080e0deafd7377a13bd027dc670a7c045b5.json
generated
Normal file
40
backend/.sqlx/query-3adb46818539e755dc942a3e66ab0080e0deafd7377a13bd027dc670a7c045b5.json
generated
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, title, description, completed FROM todos WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "completed",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3adb46818539e755dc942a3e66ab0080e0deafd7377a13bd027dc670a7c045b5"
|
||||
}
|
38
backend/.sqlx/query-9c2f662465a7c8db8fa17a2002c0d03f33abbe3b549df521a6ae6ad51ac2eadb.json
generated
Normal file
38
backend/.sqlx/query-9c2f662465a7c8db8fa17a2002c0d03f33abbe3b549df521a6ae6ad51ac2eadb.json
generated
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM todos ORDER BY id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "completed",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9c2f662465a7c8db8fa17a2002c0d03f33abbe3b549df521a6ae6ad51ac2eadb"
|
||||
}
|
41
backend/.sqlx/query-fe4b98cadd468fd286a85771507ee5e31b64ce252b76ff4b66aa77837f37135a.json
generated
Normal file
41
backend/.sqlx/query-fe4b98cadd468fd286a85771507ee5e31b64ce252b76ff4b66aa77837f37135a.json
generated
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO todos\n (title, description)\n VALUES ($1, $2)\n RETURNING id, title, description, completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "completed",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fe4b98cadd468fd286a85771507ee5e31b64ce252b76ff4b66aa77837f37135a"
|
||||
}
|
2327
backend/Cargo.lock
generated
Normal file
2327
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
45
backend/Cargo.toml
Normal file
45
backend/Cargo.toml
Normal file
|
@ -0,0 +1,45 @@
|
|||
[package]
|
||||
name = "new-stack-backend"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "web"
|
||||
path = "src/bin/web.rs"
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/domain",
|
||||
"crates/application",
|
||||
"crates/db",
|
||||
"crates/http",
|
||||
"crates/infrastructure"
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Richard K. Schultz <schultz.ri@gmail.com>"]
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.81.0"
|
||||
|
||||
[dependencies]
|
||||
infrastructure = "=0.0.0"
|
||||
|
||||
anyhow = "1.0.94"
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
|
||||
[patch.crates-io]
|
||||
db = { path = "crates/db" }
|
||||
http = { path = "crates/http" }
|
||||
application = { path = "crates/application" }
|
||||
domain = { path = "crates/domain" }
|
||||
infrastructure = { path = "crates/infrastructure" }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
||||
strip = true
|
15
backend/crates/application/Cargo.toml
Normal file
15
backend/crates/application/Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
domain = "=0.0.0"
|
||||
|
||||
# External dependencies
|
||||
anyhow = "1.0.94"
|
||||
async-trait = "0.1.58"
|
||||
thiserror = "1.0"
|
2
backend/crates/application/src/lib.rs
Normal file
2
backend/crates/application/src/lib.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod services;
|
||||
pub mod state;
|
1
backend/crates/application/src/services/mod.rs
Normal file
1
backend/crates/application/src/services/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod todo;
|
68
backend/crates/application/src/services/todo.rs
Normal file
68
backend/crates/application/src/services/todo.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use domain::services::service::ServiceError;
|
||||
use domain::models::todo::{CreateTodo, Todo};
|
||||
use domain::repositories::repository::ResultPaging;
|
||||
use domain::repositories::todo::TodoRepository;
|
||||
use domain::services::service::ServiceResult;
|
||||
use domain::services::todo::TodoService;
|
||||
use domain::models::Id;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TodoServiceImpl<R>
|
||||
where
|
||||
R: TodoRepository
|
||||
{
|
||||
pub repository: R
|
||||
}
|
||||
|
||||
impl<R> TodoServiceImpl<R>
|
||||
where
|
||||
R: TodoRepository
|
||||
{
|
||||
pub fn new(repository: R) -> Self {
|
||||
Self {
|
||||
repository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> TodoService for TodoServiceImpl<R>
|
||||
where
|
||||
R: TodoRepository
|
||||
{
|
||||
async fn create(&self, create_todo: CreateTodo) -> ServiceResult<Todo> {
|
||||
let mut cloned = create_todo.clone();
|
||||
self.repository
|
||||
.create(&mut cloned)
|
||||
.await
|
||||
.map_err(|e| -> ServiceError { e.into() })
|
||||
}
|
||||
|
||||
async fn list(&self) -> ServiceResult<ResultPaging<Todo>> {
|
||||
self.repository
|
||||
.list()
|
||||
.await
|
||||
.map_err(|e| -> ServiceError { e.into() })
|
||||
}
|
||||
|
||||
async fn get(&self, id: Id) -> ServiceResult<Todo> {
|
||||
self.repository
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|e| -> ServiceError { e.into() })
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Id) -> ServiceResult<()> {
|
||||
self.repository
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(|e| -> ServiceError { e.into() })
|
||||
}
|
||||
|
||||
async fn update(&self, todo: Todo) -> ServiceResult<Todo> {
|
||||
let mut cloned = todo.clone();
|
||||
self.repository
|
||||
.update(&mut cloned)
|
||||
.await
|
||||
.map_err(|e| -> ServiceError { e.into() })
|
||||
}
|
||||
}
|
9
backend/crates/application/src/state.rs
Normal file
9
backend/crates/application/src/state.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use domain::services::todo::TodoService;
|
||||
|
||||
/// The global application state shared between all request handlers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState<S: TodoService> {
|
||||
pub todo_service: Arc<S>,
|
||||
}
|
22
backend/crates/db/Cargo.toml
Normal file
22
backend/crates/db/Cargo.toml
Normal file
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
domain = "=0.0.0"
|
||||
|
||||
# External dependencies
|
||||
anyhow = "1.0.94"
|
||||
async-trait = "0.1.58"
|
||||
serde = { version = "1", features = ["std", "derive"] }
|
||||
sqlx = { version = "0.8.2", default-features = false, features = [
|
||||
"runtime-tokio-native-tls",
|
||||
"macros",
|
||||
"postgres",
|
||||
"uuid",
|
||||
] }
|
||||
uuid = { version = "1.11.0", features = ["v7", "fast-rng", "serde"] }
|
2
backend/crates/db/src/lib.rs
Normal file
2
backend/crates/db/src/lib.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod models;
|
||||
pub mod repositories;
|
1
backend/crates/db/src/models/mod.rs
Normal file
1
backend/crates/db/src/models/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod todo;
|
46
backend/crates/db/src/models/todo.rs
Normal file
46
backend/crates/db/src/models/todo.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use domain::models::todo::{CreateTodo, Todo};
|
||||
|
||||
pub struct TodoPostgres {
|
||||
pub id: uuid::Uuid,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
// Factory method for creating a new TodoPostgres from a Todo
|
||||
impl From<Todo> for TodoPostgres {
|
||||
fn from(t: Todo) -> Self {
|
||||
TodoPostgres {
|
||||
id: t.id.id(),
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
completed: t.completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateTodoPostgres {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
// Factory method for creating a new Todo from a TodoPostgres
|
||||
impl Into<Todo> for TodoPostgres {
|
||||
fn into(self) -> Todo {
|
||||
Todo {
|
||||
id: self.id.into(),
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
completed: self.completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateTodo> for CreateTodoPostgres {
|
||||
fn from(t: CreateTodo) -> Self {
|
||||
CreateTodoPostgres {
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
}
|
||||
}
|
||||
}
|
26
backend/crates/db/src/postgres.rs
Normal file
26
backend/crates/db/src/postgres.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use sqlx::PgPool;
|
||||
|
||||
pub type DBConn = PgPool;
|
||||
|
||||
const UNIQUE_CONSTRAINT_VIOLATION_CODE: &str = "2067";
|
||||
|
||||
fn is_unique_constraint_violation(err: &sqlx::Error) -> bool {
|
||||
if let sqlx::Error::Database(db_err) = err {
|
||||
if let Some(code) = db_err.code() {
|
||||
if code == UNIQUE_CONSTRAINT_VIOLATION_CODE {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_row_not_found_error(err: &sqlx::Error) -> bool {
|
||||
if let sqlx::Error::RowNotFound = err {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
2
backend/crates/db/src/repositories/mod.rs
Normal file
2
backend/crates/db/src/repositories/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod repository;
|
||||
pub mod todo;
|
37
backend/crates/db/src/repositories/repository.rs
Normal file
37
backend/crates/db/src/repositories/repository.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use domain::repositories::repository::{RepositoryError, RepositoryObject, RepositoryOperation};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PostgresRepositoryError(RepositoryError);
|
||||
|
||||
impl PostgresRepositoryError {
|
||||
pub fn into_inner(self) -> RepositoryError {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(sqlx::Error, RepositoryOperation, RepositoryObject)> for PostgresRepositoryError {
|
||||
fn from(error_tuple: (sqlx::Error, RepositoryOperation, RepositoryObject)) -> PostgresRepositoryError {
|
||||
|
||||
let operation = match error_tuple.1 {
|
||||
RepositoryOperation::Create => "create",
|
||||
RepositoryOperation::GetOne => "get",
|
||||
RepositoryOperation::GetMany => "list",
|
||||
RepositoryOperation::Update => "update",
|
||||
RepositoryOperation::Delete => "delete"
|
||||
};
|
||||
|
||||
let object = match error_tuple.2 {
|
||||
RepositoryObject::Todo => "Todo"
|
||||
};
|
||||
|
||||
let message = format!("Could not {} {}: {}", operation, object, error_tuple.0);
|
||||
|
||||
PostgresRepositoryError(
|
||||
RepositoryError {
|
||||
operation: error_tuple.1,
|
||||
object: error_tuple.2,
|
||||
message: message.to_string(),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
106
backend/crates/db/src/repositories/todo.rs
Normal file
106
backend/crates/db/src/repositories/todo.rs
Normal file
|
@ -0,0 +1,106 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use domain::models::todo::{CreateTodo, Todo};
|
||||
use domain::repositories::repository::{RepositoryResult, ResultPaging, RepositoryOperation, RepositoryObject};
|
||||
use domain::repositories::todo::TodoRepository;
|
||||
use domain::models::Id;
|
||||
use crate::repositories::repository::PostgresRepositoryError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TodoPostgresRepository {
|
||||
pub pool: Arc<PgPool>
|
||||
}
|
||||
|
||||
|
||||
impl TodoPostgresRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
TodoPostgresRepository { pool }
|
||||
}
|
||||
}
|
||||
|
||||
impl TodoRepository for TodoPostgresRepository {
|
||||
|
||||
async fn create(&self, new_todo: &CreateTodo) -> RepositoryResult<Todo> {
|
||||
let title = &new_todo.title();
|
||||
let description = &new_todo.description();
|
||||
let record = sqlx::query_as!(Todo,
|
||||
r#"INSERT INTO todos
|
||||
(title, description)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, title, description, completed"#,
|
||||
title,
|
||||
description
|
||||
).fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|v| PostgresRepositoryError::from((v, RepositoryOperation::Create, RepositoryObject::Todo)).into_inner())?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn list(&self) -> RepositoryResult<ResultPaging<Todo>> {
|
||||
let records = sqlx::query_as!(Todo,
|
||||
"SELECT * FROM todos ORDER BY id"
|
||||
).fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|v| PostgresRepositoryError::from((v, RepositoryOperation::GetMany, RepositoryObject::Todo)).into_inner())?;
|
||||
|
||||
Ok(ResultPaging {
|
||||
total: records.len().try_into().unwrap(),
|
||||
items: records.into_iter().map(|v| v.into()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(&self, id: Id) -> RepositoryResult<Todo> {
|
||||
let id = id.id();
|
||||
let record = sqlx::query_as!(Todo,
|
||||
"SELECT id, title, description, completed FROM todos WHERE id = $1",
|
||||
id
|
||||
).fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|v| PostgresRepositoryError::from((v, RepositoryOperation::GetOne, RepositoryObject::Todo)).into_inner())?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Id) -> RepositoryResult<()> {
|
||||
let id = id.id();
|
||||
let _ = sqlx::query_as!(Todo,
|
||||
"DELETE FROM todos WHERE id = $1",
|
||||
id
|
||||
).execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|v| PostgresRepositoryError::from((v, RepositoryOperation::Delete, RepositoryObject::Todo)).into_inner())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self, todo: &Todo) -> RepositoryResult<Todo> {
|
||||
let id = todo.id().id();
|
||||
let title = todo.title();
|
||||
let description = todo.description();
|
||||
let completed = todo.completed();
|
||||
let record = sqlx::query_as!(
|
||||
Todo,
|
||||
r#"--sql
|
||||
with update_todo as (
|
||||
update todos
|
||||
set title = $2, description = $3, completed = $4
|
||||
where id = $1
|
||||
returning id, title, description, completed
|
||||
)
|
||||
select * from update_todo
|
||||
"#,
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
completed
|
||||
).fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|v| PostgresRepositoryError::from((v, RepositoryOperation::Update, RepositoryObject::Todo)).into_inner())?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
}
|
14
backend/crates/domain/Cargo.toml
Normal file
14
backend/crates/domain/Cargo.toml
Normal file
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# External dependencies
|
||||
anyhow = "1.0.86"
|
||||
async-trait = "0.1.58"
|
||||
uuid = { version = "1.11.0", features = ["v7", "fast-rng"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
3
backend/crates/domain/src/lib.rs
Normal file
3
backend/crates/domain/src/lib.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod models;
|
||||
pub mod repositories;
|
||||
pub mod services;
|
73
backend/crates/domain/src/models/id.rs
Normal file
73
backend/crates/domain/src/models/id.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
//! A generalised ID type for entities and aggregates.
|
||||
|
||||
use std::fmt;
|
||||
use std::fmt::Display;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Id(uuid::Uuid);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdParseError {}
|
||||
|
||||
impl Id {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
0: uuid::Uuid::now_v7(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(from: String) -> Result<Self, IdParseError> {
|
||||
uuid::Uuid::parse_str(from.as_str())
|
||||
.map_err(|_| IdParseError {})
|
||||
.map(|val| Self {
|
||||
0: val
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_string(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
pub fn id(self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Uuid> for Id {
|
||||
fn from(uuid: uuid::Uuid) -> Self {
|
||||
Self { 0: uuid }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Id {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl Copy for Id {}
|
||||
|
||||
impl PartialEq for Id {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Id {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_id() {
|
||||
let uuid_value = uuid::Uuid::now_v7();
|
||||
let id = Id::from_string(uuid_value.to_string()).expect("");
|
||||
assert_eq!(format!("{id}"), uuid_value.to_string());
|
||||
}
|
||||
}
|
13
backend/crates/domain/src/models/mod.rs
Normal file
13
backend/crates/domain/src/models/mod.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
// Value objects
|
||||
mod id;
|
||||
mod name;
|
||||
|
||||
// Entities
|
||||
pub mod todo;
|
||||
|
||||
// pub use self::id::{Id, IdParseError};
|
||||
// pub use self::name::{Name, NameError};
|
||||
// pub use self::todo::{InvalidTodoError, Todo};
|
||||
|
||||
pub use self::id::*;
|
||||
pub use self::name::*;
|
44
backend/crates/domain/src/models/name.rs
Normal file
44
backend/crates/domain/src/models/name.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use thiserror::Error;
|
||||
|
||||
const MAX_COUNT: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Name(String);
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq)]
|
||||
pub enum NameError {
|
||||
#[error("input cannot be empty")]
|
||||
Empty,
|
||||
#[error("input length ({0}) exceeds maximum {}", MAX_COUNT)]
|
||||
TooLong(usize),
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn new(raw: &str) -> Result<Self, NameError> {
|
||||
let len = raw.chars().count();
|
||||
if len == 0 {
|
||||
return Err(NameError::Empty);
|
||||
} else if len > MAX_COUNT {
|
||||
return Err(NameError::TooLong(len));
|
||||
}
|
||||
Ok(Self(raw.into()))
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Name {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Name {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
77
backend/crates/domain/src/models/todo.rs
Normal file
77
backend/crates/domain/src/models/todo.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
use crate::models::{Id, IdParseError};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Todo {
|
||||
pub id: Id,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CreateTodo {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InvalidCreateTodoError {
|
||||
#[error("title parse error")]
|
||||
Title,
|
||||
#[error("description parse error")]
|
||||
Description,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InvalidTodoError {
|
||||
#[error("id parse error")]
|
||||
Id
|
||||
}
|
||||
|
||||
impl Todo {
|
||||
|
||||
pub fn new(id: Id, title: String, description: String, completed: bool) -> Self {
|
||||
Self { id, title, description, completed }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Id {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
pub fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> String {
|
||||
self.description.clone()
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> bool {
|
||||
self.completed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl CreateTodo {
|
||||
|
||||
pub fn new(title: String, description: String) -> Self {
|
||||
Self { title, description }
|
||||
}
|
||||
|
||||
pub fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> String {
|
||||
self.description.clone()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl From<IdParseError> for InvalidTodoError {
|
||||
fn from(_: IdParseError) -> Self {
|
||||
InvalidTodoError::Id
|
||||
}
|
||||
}
|
40
backend/crates/domain/src/models/user.rs
Normal file
40
backend/crates/domain/src/models/user.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use crate::{Id, IdParseError, Name, NameError};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
id: Id,
|
||||
first_name: Name,
|
||||
last_name: Name,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InvalidUserError {
|
||||
#[error("uuid parse error")]
|
||||
Id(IdParseError),
|
||||
#[error(transparent)]
|
||||
Name(#[from] NameError),
|
||||
}
|
||||
|
||||
impl User {
|
||||
#[must_use]
|
||||
pub fn new(id: Id, first_name: Name, last_name: Name) -> Self {
|
||||
Self {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> Id {
|
||||
self.id
|
||||
}
|
||||
#[must_use]
|
||||
pub fn first_name(&self) -> String {
|
||||
self.first_name.to_string()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn last_name(&self) -> String {
|
||||
self.last_name.to_string()
|
||||
}
|
||||
}
|
2
backend/crates/domain/src/repositories/mod.rs
Normal file
2
backend/crates/domain/src/repositories/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod todo;
|
||||
pub mod repository;
|
30
backend/crates/domain/src/repositories/repository.rs
Normal file
30
backend/crates/domain/src/repositories/repository.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepositoryOperation {
|
||||
Create,
|
||||
GetOne,
|
||||
GetMany,
|
||||
Update,
|
||||
Delete
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepositoryObject {
|
||||
Todo
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RepositoryError {
|
||||
pub operation: RepositoryOperation,
|
||||
pub object: RepositoryObject,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub type RepositoryResult<T> = Result<T, RepositoryError>;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct ResultPaging<T> {
|
||||
pub total: u64,
|
||||
pub items: Vec<T>,
|
||||
}
|
13
backend/crates/domain/src/repositories/todo.rs
Normal file
13
backend/crates/domain/src/repositories/todo.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use std::future::Future;
|
||||
|
||||
use crate::repositories::repository::{ResultPaging, RepositoryResult};
|
||||
use crate::models::todo::{Todo, CreateTodo};
|
||||
use crate::models::Id;
|
||||
|
||||
pub trait TodoRepository: Clone + Send + Sync + 'static {
|
||||
fn create(self: &Self, new_todo: &CreateTodo) -> impl Future<Output = RepositoryResult<Todo>> + Send;
|
||||
fn list(self: &Self) -> impl Future<Output = RepositoryResult<ResultPaging<Todo>>> + Send;
|
||||
fn get(self: &Self, id: Id) -> impl Future<Output = RepositoryResult<Todo>> + Send;
|
||||
fn delete(self: &Self, id: Id) -> impl Future<Output = RepositoryResult<()>> + Send;
|
||||
fn update(self: &Self, todo: &Todo) -> impl Future<Output = RepositoryResult<Todo>> + Send;
|
||||
}
|
2
backend/crates/domain/src/services/mod.rs
Normal file
2
backend/crates/domain/src/services/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod service;
|
||||
pub mod todo;
|
22
backend/crates/domain/src/services/service.rs
Normal file
22
backend/crates/domain/src/services/service.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use crate::repositories::repository::RepositoryError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub type ServiceResult<T> = Result<T, ServiceError>;
|
||||
|
||||
impl std::fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<ServiceError> for RepositoryError {
|
||||
fn into(self) -> ServiceError {
|
||||
ServiceError {
|
||||
message: self.message
|
||||
}
|
||||
}
|
||||
}
|
14
backend/crates/domain/src/services/todo.rs
Normal file
14
backend/crates/domain/src/services/todo.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use std::future::Future;
|
||||
|
||||
use crate::models::Id;
|
||||
use crate::services::service::ServiceResult;
|
||||
use crate::models::todo::{CreateTodo, Todo};
|
||||
use crate::repositories::repository::ResultPaging;
|
||||
|
||||
pub trait TodoService: Send + Sync + Clone + 'static {
|
||||
fn create(&self, todo: CreateTodo) -> impl Future<Output = ServiceResult<Todo>> + Send;
|
||||
fn list(&self) -> impl Future<Output = ServiceResult<ResultPaging<Todo>>> + Send;
|
||||
fn get(&self, id: Id) -> impl Future<Output = ServiceResult<Todo>> + Send;
|
||||
fn delete(&self, id: Id) -> impl Future<Output = ServiceResult<()>> + Send;
|
||||
fn update(&self, todo: Todo) -> impl Future<Output = ServiceResult<Todo>> + Send;
|
||||
}
|
21
backend/crates/http/Cargo.toml
Normal file
21
backend/crates/http/Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "http"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
application = "=0.0.0"
|
||||
domain = "=0.0.0"
|
||||
|
||||
anyhow = "1.0.86"
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
#axum-macros = { version = "0.5.0" }
|
||||
serde = { version = "1", features = ["std", "derive"] }
|
||||
serde_json = "1.0.128"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.38", features = ["full"] }
|
||||
tower-http = { version = "0.5.2", features = ["trace"] }
|
||||
tracing = "0.1.40"
|
0
backend/crates/http/src/config.rs
Normal file
0
backend/crates/http/src/config.rs
Normal file
1
backend/crates/http/src/handlers/mod.rs
Normal file
1
backend/crates/http/src/handlers/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod todo;
|
27
backend/crates/http/src/handlers/todo/create.rs
Normal file
27
backend/crates/http/src/handlers/todo/create.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
|
||||
use application::state::AppState;
|
||||
use domain::models::todo::Todo;
|
||||
use domain::services::todo::TodoService;
|
||||
use crate::models::error::HttpError;
|
||||
use crate::models::response::HttpGetOneResponse;
|
||||
use crate::models::todo::HttpCreateTodoRequest;
|
||||
|
||||
/// Create a new [Todo].
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// - 201 Created: the [Todo] was successfully created.
|
||||
/// - 422 Unprocessable entity: An [Todo] with the same name already exists.
|
||||
pub async fn create<S: TodoService>(
|
||||
State(state): State<AppState<S>>,
|
||||
Json(req): Json<HttpCreateTodoRequest>,
|
||||
) -> Result<HttpGetOneResponse<Todo>, HttpError> {
|
||||
|
||||
state.todo_service
|
||||
.create(req.into())
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|todo: Todo| todo.into())
|
||||
}
|
25
backend/crates/http/src/handlers/todo/delete_by_id.rs
Normal file
25
backend/crates/http/src/handlers/todo/delete_by_id.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use axum::extract::{Path, State};
|
||||
|
||||
use application::state::AppState;
|
||||
use domain::models::Id;
|
||||
use domain::services::todo::TodoService;
|
||||
use crate::models::error::HttpError;
|
||||
|
||||
/// Create a new [Todo].
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// - 201 Created: the [Todo] was successfully created.
|
||||
/// - 422 Unprocessable entity: An [Todo] with the same name already exists.
|
||||
pub async fn delete_by_id<S: TodoService>(
|
||||
State(state): State<AppState<S>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<(), HttpError> {
|
||||
|
||||
state.todo_service
|
||||
.delete(Id::from_string(id)?)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|_| ())
|
||||
|
||||
}
|
24
backend/crates/http/src/handlers/todo/get_all.rs
Normal file
24
backend/crates/http/src/handlers/todo/get_all.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use axum::extract::State;
|
||||
|
||||
use application::state::AppState;
|
||||
use domain::models::todo::Todo;
|
||||
use domain::services::todo::TodoService;
|
||||
use crate::models::error::HttpError;
|
||||
use crate::models::response::HttpGetManyResponse;
|
||||
|
||||
/// Get all [Todo]s.
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// - 201 Created: the [Todo] was successfully created.
|
||||
/// - 422 Unprocessable entity: An [Todo] with the same name already exists.
|
||||
pub async fn get_all<S: TodoService>(
|
||||
State(state): State<AppState<S>>
|
||||
) -> Result<HttpGetManyResponse<Todo>, HttpError> {
|
||||
|
||||
state.todo_service
|
||||
.list()
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|result| result.into())
|
||||
}
|
26
backend/crates/http/src/handlers/todo/get_by_id.rs
Normal file
26
backend/crates/http/src/handlers/todo/get_by_id.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use axum::extract::{Path, State};
|
||||
|
||||
use application::state::AppState;
|
||||
use domain::models::Id;
|
||||
use domain::models::todo::Todo;
|
||||
use domain::services::todo::TodoService;
|
||||
use crate::models::error::HttpError;
|
||||
use crate::models::response::HttpGetOneResponse;
|
||||
|
||||
/// Create a new [Todo].
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// - 201 Created: the [Todo] was successfully created.
|
||||
/// - 422 Unprocessable entity: An [Todo] with the same name already exists.
|
||||
pub async fn get_by_id<S: TodoService>(
|
||||
State(state): State<AppState<S>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<HttpGetOneResponse<Todo>, HttpError> {
|
||||
|
||||
state.todo_service
|
||||
.get(Id::from_string(id)?)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|todo: Todo| todo.into())
|
||||
}
|
5
backend/crates/http/src/handlers/todo/mod.rs
Normal file
5
backend/crates/http/src/handlers/todo/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod create;
|
||||
pub mod delete_by_id;
|
||||
pub mod get_all;
|
||||
pub mod get_by_id;
|
||||
pub mod update_by_id;
|
27
backend/crates/http/src/handlers/todo/update_by_id.rs
Normal file
27
backend/crates/http/src/handlers/todo/update_by_id.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
|
||||
use application::state::AppState;
|
||||
use domain::models::todo::Todo;
|
||||
use domain::services::todo::TodoService;
|
||||
use crate::models::error::HttpError;
|
||||
use crate::models::response::HttpGetOneResponse;
|
||||
use crate::models::todo::HttpTodoRequest;
|
||||
|
||||
/// Update an existing [Todo].
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// - 201 Created: the [Todo] was successfully updated.
|
||||
/// - 422 Unprocessable entity: An [Todo] with the same name already exists.
|
||||
pub async fn update_by_id<S: TodoService>(
|
||||
State(state): State<AppState<S>>,
|
||||
Json(req): Json<HttpTodoRequest>,
|
||||
) -> Result<HttpGetOneResponse<Todo>, HttpError> {
|
||||
|
||||
state.todo_service
|
||||
.update(req.try_into()?)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|todo: Todo| todo.into())
|
||||
}
|
5
backend/crates/http/src/lib.rs
Normal file
5
backend/crates/http/src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod config;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod server;
|
39
backend/crates/http/src/models/error.rs
Normal file
39
backend/crates/http/src/models/error.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use domain::models::IdParseError;
|
||||
use domain::services::service::ServiceError;
|
||||
|
||||
/// The response body data field for an unsuccessful request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HttpError {
|
||||
pub message: String
|
||||
}
|
||||
|
||||
impl From<ServiceError> for HttpError {
|
||||
fn from(from: ServiceError) -> Self {
|
||||
Self {
|
||||
message: from.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a [IdParseError] into a [HttpError]
|
||||
impl From<IdParseError> for HttpError {
|
||||
fn from(_: IdParseError) -> Self {
|
||||
Self {
|
||||
message: "Not a valid ID".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for HttpError {
|
||||
fn into_response(self) -> Response {
|
||||
let http_status_code = StatusCode::INTERNAL_SERVER_ERROR;
|
||||
let body = Json(json!({"message": self.message }));
|
||||
(http_status_code, body).into_response()
|
||||
}
|
||||
}
|
3
backend/crates/http/src/models/mod.rs
Normal file
3
backend/crates/http/src/models/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod error;
|
||||
pub mod response;
|
||||
pub mod todo;
|
56
backend/crates/http/src/models/response.rs
Normal file
56
backend/crates/http/src/models/response.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use domain::repositories::repository::ResultPaging;
|
||||
|
||||
/// The response body data field for successful response for GET API calls that expect one item to be returned
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HttpGetOneResponse<T>(T);
|
||||
|
||||
impl<T> From<T> for HttpGetOneResponse<T> {
|
||||
fn from(from: T) -> Self {
|
||||
Self {
|
||||
0: from
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for HttpGetOneResponse<T>
|
||||
where
|
||||
T: Serialize
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
let http_status_code = StatusCode::FOUND;
|
||||
let body = Json(json!({"data": self }));
|
||||
(http_status_code, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Many Response
|
||||
///
|
||||
|
||||
/// The response body data field for successful response for GET API calls that expect lists of many items to be returned
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HttpGetManyResponse<T>(ResultPaging<T>);
|
||||
|
||||
impl<T> From<ResultPaging<T>> for HttpGetManyResponse<T> {
|
||||
fn from(from: ResultPaging<T>) -> Self {
|
||||
Self {
|
||||
0: from
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for HttpGetManyResponse<T>
|
||||
where
|
||||
T: Serialize
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
let http_status_code = StatusCode::FOUND;
|
||||
let body = Json(json!({"data": self }));
|
||||
(http_status_code, body).into_response()
|
||||
}
|
||||
}
|
52
backend/crates/http/src/models/todo.rs
Normal file
52
backend/crates/http/src/models/todo.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
use crate::models::error::HttpError;
|
||||
use domain::models::Id;
|
||||
use domain::models::todo::{CreateTodo, Todo};
|
||||
|
||||
/// Create Request
|
||||
///
|
||||
|
||||
/// The body of an [Todo] creation request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct HttpCreateTodoRequest {
|
||||
pub title: String,
|
||||
pub description: String
|
||||
}
|
||||
|
||||
/// Converts a [CreateTodoHttpRequest] request into a domain [CreateTodo] request
|
||||
impl From<HttpCreateTodoRequest> for CreateTodo {
|
||||
fn from(req: HttpCreateTodoRequest) -> Self {
|
||||
Self {
|
||||
title: req.title,
|
||||
description: req.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update Request
|
||||
///
|
||||
|
||||
/// The body of an [Todo] update request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct HttpTodoRequest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub completed: bool
|
||||
}
|
||||
|
||||
/// Converts a [UpdateTodoHttpRequest] request into a domain [Todo] request
|
||||
impl TryFrom<HttpTodoRequest> for Todo {
|
||||
type Error = HttpError;
|
||||
fn try_from(req: HttpTodoRequest) -> Result<Self, Self::Error> {
|
||||
Id::from_string(req.id)
|
||||
.map_err(|e| e.into())
|
||||
.map(|id| Todo {
|
||||
id,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
completed: req.completed
|
||||
})
|
||||
}
|
||||
}
|
24
backend/crates/http/src/routes.rs
Normal file
24
backend/crates/http/src/routes.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use crate::handlers::todo::create::create;
|
||||
use crate::handlers::todo::get_by_id::get_by_id;
|
||||
use crate::handlers::todo::get_all::get_all;
|
||||
use crate::handlers::todo::delete_by_id::delete_by_id;
|
||||
use crate::handlers::todo::update_by_id::update_by_id;
|
||||
use application::state::AppState;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use domain::services::todo::TodoService;
|
||||
|
||||
pub fn api_routes<S: TodoService>() -> Router<AppState<S>> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/todos",
|
||||
get(get_all::<S>)
|
||||
.post(create::<S>)
|
||||
.patch(update_by_id::<S>)
|
||||
)
|
||||
.route(
|
||||
"/todos/:id",
|
||||
get(get_by_id::<S>)
|
||||
.delete(delete_by_id::<S>),
|
||||
)
|
||||
}
|
62
backend/crates/http/src/server.rs
Normal file
62
backend/crates/http/src/server.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use domain::services::todo::TodoService;
|
||||
use tokio::net;
|
||||
use application::state::AppState;
|
||||
|
||||
use crate::routes::api_routes;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HttpServerConfig<'a> {
|
||||
pub port: &'a str,
|
||||
}
|
||||
|
||||
|
||||
pub struct HttpServer {
|
||||
router: axum::Router,
|
||||
listener: net::TcpListener,
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
/// Returns a new HTTP server bound to the port specified in `config`.
|
||||
pub async fn new(
|
||||
todo_service: impl TodoService,
|
||||
config: HttpServerConfig<'_>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let trace_layer = tower_http::trace::TraceLayer::new_for_http().make_span_with(
|
||||
|request: &axum::extract::Request<_>| {
|
||||
let uri = request.uri().to_string();
|
||||
tracing::info_span!("http_request", method = ?request.method(), uri)
|
||||
},
|
||||
);
|
||||
|
||||
// Construct dependencies to inject into handlers.
|
||||
let state = AppState {
|
||||
todo_service: Arc::new(todo_service),
|
||||
};
|
||||
|
||||
let router = axum::Router::new()
|
||||
.nest("/api", api_routes())
|
||||
.layer(trace_layer)
|
||||
.with_state(state);
|
||||
|
||||
let listener = net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
|
||||
.await
|
||||
.with_context(|| format!("failed to listen on {}", config.port))?;
|
||||
|
||||
Ok(Self { router, listener })
|
||||
}
|
||||
|
||||
/// Runs the HTTP server.
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
tracing::debug!("listening on {}", self.listener.local_addr().unwrap());
|
||||
|
||||
axum::serve(self.listener, self.router)
|
||||
.await
|
||||
.context("received error from running server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
25
backend/crates/infrastructure/Cargo.toml
Normal file
25
backend/crates/infrastructure/Cargo.toml
Normal file
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "infrastructure"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Workspace dependencies
|
||||
application = "=0.0.0"
|
||||
db = "=0.0.0"
|
||||
http = "=0.0.0"
|
||||
|
||||
# External dependencies
|
||||
anyhow = "1.0.94"
|
||||
dotenvy = "0.15.7"
|
||||
sqlx = { version = "0.8.2", default-features = false, features = [
|
||||
"runtime-tokio-native-tls",
|
||||
"macros",
|
||||
"postgres",
|
||||
"uuid",
|
||||
] }
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.40", features = ["full"] }
|
31
backend/crates/infrastructure/src/config.rs
Normal file
31
backend/crates/infrastructure/src/config.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use std::env;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
const DATABASE_URL_KEY: &str = "DATABASE_URL";
|
||||
|
||||
const SERVER_PORT_KEY: &str = "SERVER_PORT";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub server_port: String,
|
||||
pub database_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Config> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let server_port = load_env(SERVER_PORT_KEY)?;
|
||||
let database_url = load_env(DATABASE_URL_KEY)?;
|
||||
|
||||
Ok(Config {
|
||||
server_port,
|
||||
database_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_env(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).with_context(|| format!("failed to load environment variable {}", key))
|
||||
}
|
2
backend/crates/infrastructure/src/lib.rs
Normal file
2
backend/crates/infrastructure/src/lib.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod config;
|
||||
pub mod web;
|
29
backend/crates/infrastructure/src/web.rs
Normal file
29
backend/crates/infrastructure/src/web.rs
Normal file
|
@ -0,0 +1,29 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
use crate::config::Config;
|
||||
use db::repositories::todo::TodoPostgresRepository;
|
||||
use application::services::todo::TodoServiceImpl;
|
||||
use http::server::{HttpServer, HttpServerConfig};
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
|
||||
let config = Config::from_env()?;
|
||||
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
|
||||
let pool = PgPoolOptions::new().connect(&db_url).await?;
|
||||
|
||||
let db = Arc::new(pool);
|
||||
|
||||
let todo_repository = TodoPostgresRepository::new(db);
|
||||
let todo_service = TodoServiceImpl::new(todo_repository);
|
||||
|
||||
let server_config = HttpServerConfig {
|
||||
port: &config.server_port,
|
||||
};
|
||||
let http_server = HttpServer::new(todo_service, server_config).await?;
|
||||
http_server.run().await
|
||||
|
||||
}
|
2
backend/migrations/20241219150755_init.down.sql
Normal file
2
backend/migrations/20241219150755_init.down.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
-- Add down migration script here
|
||||
DROP TABLE IF EXISTS todos;
|
7
backend/migrations/20241219150755_init.up.sql
Normal file
7
backend/migrations/20241219150755_init.up.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
-- Add up migration script here
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v7(),
|
||||
title TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
completed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
7
backend/src/bin/web.rs
Normal file
7
backend/src/bin/web.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use infrastructure::web;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
web::run().await
|
||||
}
|
||||
|
Loading…
Add table
Reference in a new issue