First commit

This commit is contained in:
continuist 2025-03-26 21:26:03 -04:00
parent 774d3d3b32
commit 10151b234f
57 changed files with 3697 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
backend/target

7
backend/.env Normal file
View 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"

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM todos WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "183ad1d8316ef2ae5ac6ae4811b8a2bdbaeabbe137a871e26741a419a1aa5b19"
}

View 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"
}

View 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"
}

View 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

File diff suppressed because it is too large Load diff

45
backend/Cargo.toml Normal file
View 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

View 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"

View file

@ -0,0 +1,2 @@
pub mod services;
pub mod state;

View file

@ -0,0 +1 @@
pub mod todo;

View 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() })
}
}

View 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>,
}

View 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"] }

View file

@ -0,0 +1,2 @@
pub mod models;
pub mod repositories;

View file

@ -0,0 +1 @@
pub mod todo;

View 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,
}
}
}

View 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
}

View file

@ -0,0 +1,2 @@
pub mod repository;
pub mod todo;

View 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(),
}
)
}
}

View 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)
}
}

View 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"

View file

@ -0,0 +1,3 @@
pub mod models;
pub mod repositories;
pub mod services;

View 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());
}
}

View 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::*;

View 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)
}
}

View 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
}
}

View 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()
}
}

View file

@ -0,0 +1,2 @@
pub mod todo;
pub mod repository;

View 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>,
}

View 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;
}

View file

@ -0,0 +1,2 @@
pub mod service;
pub mod todo;

View 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
}
}
}

View 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;
}

View 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"

View file

View file

@ -0,0 +1 @@
pub mod todo;

View 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())
}

View 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(|_| ())
}

View 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())
}

View 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())
}

View 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;

View 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())
}

View file

@ -0,0 +1,5 @@
pub mod config;
pub mod handlers;
pub mod models;
pub mod routes;
pub mod server;

View 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()
}
}

View file

@ -0,0 +1,3 @@
pub mod error;
pub mod response;
pub mod todo;

View 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()
}
}

View 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
})
}
}

View 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>),
)
}

View 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(())
}
}

View 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"] }

View 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))
}

View file

@ -0,0 +1,2 @@
pub mod config;
pub mod web;

View 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
}

View file

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE IF EXISTS todos;

View 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
View file

@ -0,0 +1,7 @@
use infrastructure::web;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
web::run().await
}