mirror of
https://github.com/kristoferssolo/echoes-of-ascension.git
synced 2026-03-22 00:26:26 +00:00
refactor: make discrete backend and frontend
This commit is contained in:
42
backend/Cargo.toml
Normal file
42
backend/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
axum = "0.8"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
tower.workspace = true
|
||||
tower-http = { workspace = true, features = ["cors"] }
|
||||
serde.workspace = true
|
||||
serde_json = "1"
|
||||
uuid.workspace = true
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"macros",
|
||||
"postgres",
|
||||
"uuid",
|
||||
"chrono",
|
||||
"migrate",
|
||||
] }
|
||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
validator = "0.20"
|
||||
config = { version = "0.15", features = ["toml"], default-features = false }
|
||||
serde-aux = "4"
|
||||
unicode-segmentation = "1"
|
||||
rand = "0.8"
|
||||
argon2 = "0.5"
|
||||
password-hash = "0.5"
|
||||
hex = "0.4"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
||||
tracing-bunyan-formatter = { version = "0.3", default-features = false }
|
||||
tracing-log = "0.2"
|
||||
anyhow = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
9
backend/config/base.toml
Normal file
9
backend/config/base.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[application]
|
||||
port = 8000
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
username = "postgres"
|
||||
password = "password"
|
||||
database_name = "maze_ascension"
|
||||
5
backend/config/local.toml
Normal file
5
backend/config/local.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[application]
|
||||
host = "127.0.0.1"
|
||||
|
||||
[database]
|
||||
require_ssl = false
|
||||
5
backend/config/production.toml
Normal file
5
backend/config/production.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[application]
|
||||
host = "0.0.0.0"
|
||||
|
||||
[database]
|
||||
require_ssl = true
|
||||
11
backend/migrations/20250125123853_init.down.sql
Normal file
11
backend/migrations/20250125123853_init.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Add down migration script here
|
||||
-- Drop indexes first
|
||||
DROP INDEX IF EXISTS idx_scores_user_score;
|
||||
|
||||
DROP INDEX IF EXISTS idx_users_login;
|
||||
|
||||
-- Drop tables in reverse order of creation
|
||||
DROP TABLE IF EXISTS scores;
|
||||
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
28
backend/migrations/20250125123853_init.up.sql
Normal file
28
backend/migrations/20250125123853_init.up.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Add up migration script here
|
||||
-- Enable UUID support
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Users table with login codes
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
username varchar(255) NOT NULL UNIQUE,
|
||||
code varchar(255) NOT NULL UNIQUE,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Scores table with detailed game stats
|
||||
CREATE TABLE IF NOT EXISTS score (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid NOT NULL,
|
||||
score integer NOT NULL,
|
||||
floor_reached integer NOT NULL,
|
||||
play_time_seconds integer NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (id)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_user_login ON "user" (code);
|
||||
|
||||
CREATE INDEX idx_scores_user_score ON score (user_id, score DESC);
|
||||
|
||||
109
backend/src/config.rs
Normal file
109
backend/src/config.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use serde_aux::field_attributes::deserialize_number_from_string;
|
||||
use sqlx::{
|
||||
postgres::{PgConnectOptions, PgSslMode},
|
||||
ConnectOptions,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Settings {
|
||||
pub database: DatabaseSettings,
|
||||
pub application: ApplicationSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DatabaseSettings {
|
||||
pub username: String,
|
||||
pub password: SecretString,
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub database_name: String,
|
||||
pub require_ssl: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ApplicationSettings {
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Environment {
|
||||
Local,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl DatabaseSettings {
|
||||
#[must_use]
|
||||
pub fn without_db(&self) -> PgConnectOptions {
|
||||
let ssl_mode = if self.require_ssl {
|
||||
PgSslMode::Require
|
||||
} else {
|
||||
PgSslMode::Prefer
|
||||
};
|
||||
|
||||
PgConnectOptions::new()
|
||||
.host(&self.host)
|
||||
.username(&self.username)
|
||||
.password(self.password.expose_secret())
|
||||
.port(self.port)
|
||||
.ssl_mode(ssl_mode)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_db(&self) -> PgConnectOptions {
|
||||
self.without_db()
|
||||
.database(&self.database_name)
|
||||
.log_statements(tracing_log::log::LevelFilter::Trace)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_config() -> Result<Settings, config::ConfigError> {
|
||||
let base_path = std::env::current_dir().expect("Failed to determine current directory");
|
||||
let config_directory = base_path.join("backend").join("config");
|
||||
let env: Environment = std::env::var("APP_ENVIRONMENT")
|
||||
.unwrap_or_else(|_| "local".into())
|
||||
.try_into()
|
||||
.expect("Failed to parse APP_ENVIRONMENT");
|
||||
|
||||
let env_filename = format!("{}.toml", &env);
|
||||
|
||||
let settings = config::Config::builder()
|
||||
.add_source(config::File::from(config_directory.join("base.toml")))
|
||||
.add_source(config::File::from(config_directory.join(env_filename)))
|
||||
.add_source(
|
||||
config::Environment::with_prefix("APP")
|
||||
.prefix_separator("_")
|
||||
.separator("__"),
|
||||
)
|
||||
.build()?;
|
||||
settings.try_deserialize::<Settings>()
|
||||
}
|
||||
|
||||
impl Display for Environment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::Production => write!(f, "production"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Environment {
|
||||
type Error = String;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.to_lowercase().as_str() {
|
||||
"local" => Ok(Self::Local),
|
||||
"production" => Ok(Self::Production),
|
||||
other => Err(format!(
|
||||
"{other} is not supported environment. \
|
||||
Use either `local` or `production`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
backend/src/db/mod.rs
Normal file
1
backend/src/db/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod users;
|
||||
36
backend/src/db/users.rs
Normal file
36
backend/src/db/users.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sqlx::PgPool;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::user::{error::UserError, new_user::NewUser};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServerUserError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Database error: {0}")]
|
||||
User(#[from] UserError),
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Saving new user details in the database", skip(pool, new_user))]
|
||||
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), ServerUserError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO "user" (username, code)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
new_user.username.as_ref(),
|
||||
new_user.code.hash()?
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
match e {
|
||||
sqlx::Error::Database(ref dbe) if dbe.constraint() == Some("user_username_key") => {
|
||||
ServerUserError::User(UserError::UsernameTaken(new_user.username.to_string()))
|
||||
}
|
||||
_ => ServerUserError::Database(e),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
1
backend/src/domain/mod.rs
Normal file
1
backend/src/domain/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod user;
|
||||
22
backend/src/domain/user/error.rs
Normal file
22
backend/src/domain/user/error.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UserError {
|
||||
#[error("Username validation failed: {0}")]
|
||||
UsernameValidation(String),
|
||||
|
||||
#[error("Code hashing failed: {0}")]
|
||||
HashingError(String),
|
||||
|
||||
#[error("Username already taken: {0}")]
|
||||
UsernameTaken(String),
|
||||
|
||||
#[error("Invalid code format")]
|
||||
InvalidCode,
|
||||
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
4
backend/src/domain/user/mod.rs
Normal file
4
backend/src/domain/user/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod error;
|
||||
pub mod new_user;
|
||||
mod user_code;
|
||||
mod username;
|
||||
7
backend/src/domain/user/new_user.rs
Normal file
7
backend/src/domain/user/new_user.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use super::{user_code::UserCode, username::Username};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NewUser {
|
||||
pub username: Username,
|
||||
pub code: UserCode,
|
||||
}
|
||||
73
backend/src/domain/user/user_code.rs
Normal file
73
backend/src/domain/user/user_code.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use argon2::Argon2;
|
||||
use password_hash::SaltString;
|
||||
use std::ops::Deref;
|
||||
|
||||
use rand::{rngs::OsRng, thread_rng, Rng};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use super::error::UserError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserCode(SecretString);
|
||||
|
||||
impl UserCode {
|
||||
pub fn hash(&self) -> Result<String, UserError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
let mut output_key_material = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(
|
||||
self.expose_secret().as_bytes(),
|
||||
salt.as_str().as_bytes(),
|
||||
&mut output_key_material,
|
||||
)
|
||||
.map_err(|e| UserError::HashingError(e.to_string()))?;
|
||||
Ok(format!(
|
||||
"{}${}",
|
||||
salt.as_str(),
|
||||
hex::encode(output_key_material)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn verify(stored: &str, code: &str) -> Result<bool, UserError> {
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
// Split stored value into salt and hash
|
||||
let parts: Vec<&str> = stored.split('$').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(UserError::HashingError("Invalid hash format".to_string()));
|
||||
}
|
||||
|
||||
let salt = parts[0];
|
||||
let stored_hash =
|
||||
hex::decode(parts[1]).map_err(|e| UserError::HashingError(e.to_string()))?;
|
||||
|
||||
let mut output = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(code.as_bytes(), salt.as_bytes(), &mut output)
|
||||
.map_err(|e| UserError::HashingError(e.to_string()))?;
|
||||
|
||||
Ok(output.as_slice() == stored_hash.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserCode {
|
||||
fn default() -> Self {
|
||||
let mut rng = thread_rng();
|
||||
|
||||
let code = (0..16)
|
||||
.map(|_| rng.gen_range(0..10).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
Self(code.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for UserCode {
|
||||
type Target = SecretString;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
73
backend/src/domain/user/username.rs
Normal file
73
backend/src/domain/user/username.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use rand::{seq::SliceRandom, thread_rng, Rng};
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::error::UserError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl TryFrom<String> for Username {
|
||||
type Error = UserError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
let is_empty_or_whitespace = value.trim().is_empty();
|
||||
let is_too_long = value.graphemes(true).count() > 256;
|
||||
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
|
||||
let contains_forbidden_characters =
|
||||
value.chars().any(|c| forbidden_characters.contains(&c));
|
||||
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
|
||||
return Err(UserError::UsernameValidation(value));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Username {
|
||||
fn default() -> Self {
|
||||
let adjectives = [
|
||||
"swift", "bright", "clever", "brave", "mighty", "noble", "wise", "calm", "kind",
|
||||
"bold", "quick", "sharp", "smart", "keen", "fair",
|
||||
];
|
||||
|
||||
let nouns = [
|
||||
"wolf", "eagle", "lion", "hawk", "bear", "tiger", "fox", "owl", "deer", "seal",
|
||||
"raven", "crane", "dove", "swan", "falcon",
|
||||
];
|
||||
|
||||
let mut rng = thread_rng();
|
||||
|
||||
let adjective = adjectives.choose(&mut rng).unwrap_or(&"swift");
|
||||
let noun = nouns.choose(&mut rng).unwrap_or(&"wolf");
|
||||
|
||||
let number = rng.gen_range(100..1000);
|
||||
|
||||
let username = format!("{adjective}_{noun}_{number}");
|
||||
|
||||
Self(username)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Username {
|
||||
type Err = UserError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::try_from(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Username {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Username {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Username> for String {
|
||||
fn from(value: Username) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
88
backend/src/error/app.rs
Normal file
88
backend/src/error/app.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
// Authentication/Authorization errors
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Forbidden")]
|
||||
Forbidden,
|
||||
|
||||
// Validation errors
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
// Resource errors
|
||||
#[error("Resource not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("{resource} already exists: {id}")]
|
||||
AlreadyExists { resource: &'static str, id: String },
|
||||
|
||||
// Database errors
|
||||
#[error("Database error")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
// Internal errors
|
||||
#[error("Internal server error")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, error_message, details) = match self {
|
||||
// Auth errors
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string(), None),
|
||||
// Validation errors
|
||||
Self::Validation(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Validation error".to_string(),
|
||||
Some(msg),
|
||||
),
|
||||
// Resource errors
|
||||
Self::NotFound(resource) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"Resource not found".to_string(),
|
||||
Some(resource),
|
||||
),
|
||||
Self::AlreadyExists { resource, id } => (
|
||||
StatusCode::CONFLICT,
|
||||
format!("{resource} already exists"),
|
||||
Some(id),
|
||||
),
|
||||
// Database/Internal errors
|
||||
Self::Database(e) => {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Self::Internal(e) => {
|
||||
tracing::error!("Internal error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(ErrorResponse {
|
||||
error: error_message,
|
||||
details,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
1
backend/src/error/mod.rs
Normal file
1
backend/src/error/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod app;
|
||||
8
backend/src/lib.rs
Normal file
8
backend/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod domain;
|
||||
pub mod error;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
18
backend/src/main.rs
Normal file
18
backend/src/main.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use backend::{
|
||||
config::get_config,
|
||||
server::Server,
|
||||
telemetry::{get_subscriber, init_subscriber},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
// Generate the list of routes in your Leptos App
|
||||
let subscriber = get_subscriber("echoes-of-ascension-server", "info", std::io::stdout);
|
||||
init_subscriber(subscriber);
|
||||
|
||||
let config = get_config().expect("Failed to read configuation.");
|
||||
//
|
||||
let application = Server::build(config).await?;
|
||||
application.run_until_stopped().await?;
|
||||
Ok(())
|
||||
}
|
||||
9
backend/src/routes/api/mod.rs
Normal file
9
backend/src/routes/api/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod v1;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::startup::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().nest("/v1", v1::routes())
|
||||
}
|
||||
72
backend/src/routes/api/v1/auth.rs
Normal file
72
backend/src/routes/api/v1/auth.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use anyhow::anyhow;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
db::users::{insert_user, ServerUserError},
|
||||
domain::user::{error::UserError, new_user::NewUser},
|
||||
error::app::AppError,
|
||||
startup::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FormData {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Response {
|
||||
pub username: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Creating new user",
|
||||
skip(state, payload),
|
||||
fields(
|
||||
username= %payload.username,
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FormData>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let new_user = payload
|
||||
.try_into()
|
||||
.map_err(|e: UserError| AppError::Validation(e.to_string()))?;
|
||||
|
||||
match insert_user(&state.pool, &new_user).await {
|
||||
Ok(()) => Ok((StatusCode::CREATED, Json(Response::from(new_user)))),
|
||||
Err(ServerUserError::User(UserError::UsernameTaken(username))) => {
|
||||
Err(AppError::AlreadyExists {
|
||||
resource: "User",
|
||||
id: username,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to register user: {}", e);
|
||||
Err(AppError::Internal(anyhow!(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<FormData> for NewUser {
|
||||
type Error = UserError;
|
||||
fn try_from(value: FormData) -> Result<Self, Self::Error> {
|
||||
let username = value.username.try_into()?;
|
||||
Ok(Self {
|
||||
username,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NewUser> for Response {
|
||||
fn from(value: NewUser) -> Self {
|
||||
Self {
|
||||
username: value.username.into(),
|
||||
code: value.code.expose_secret().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/routes/api/v1/mod.rs
Normal file
9
backend/src/routes/api/v1/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod auth;
|
||||
|
||||
use axum::{routing::post, Router};
|
||||
|
||||
use crate::startup::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/register", post(auth::register))
|
||||
}
|
||||
5
backend/src/routes/health_check.rs
Normal file
5
backend/src/routes/health_check.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
pub async fn health_check() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
51
backend/src/routes/mod.rs
Normal file
51
backend/src/routes/mod.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
mod api;
|
||||
mod health_check;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::MatchedPath,
|
||||
http::{HeaderMap, Request},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use health_check::health_check;
|
||||
use std::time::Duration;
|
||||
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
||||
use tracing::{info_span, Span};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::startup::AppState;
|
||||
|
||||
pub fn route(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health_check", get(health_check))
|
||||
.nest("/api", api::routes())
|
||||
.with_state(state)
|
||||
// Tracing layer
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<_>| {
|
||||
let matched_path = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str);
|
||||
info_span!(
|
||||
"http-request",
|
||||
method = ?request.method(),
|
||||
matched_path,
|
||||
some_other_field = tracing::field::Empty,
|
||||
request_id=%Uuid::new_v4(),
|
||||
)
|
||||
})
|
||||
.on_request(|_request: &Request<_>, _span: &Span| {})
|
||||
.on_response(|_response: &Response<_>, _latency: Duration, _span: &Span| {})
|
||||
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
|
||||
.on_eos(
|
||||
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {},
|
||||
)
|
||||
.on_failure(
|
||||
|_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {},
|
||||
),
|
||||
)
|
||||
}
|
||||
38
backend/src/server.rs
Normal file
38
backend/src/server.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
|
||||
use crate::{
|
||||
config::Settings,
|
||||
routes::route,
|
||||
startup::{get_connection_pool, App},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Server {
|
||||
port: u16,
|
||||
server: JoinHandle<Result<(), std::io::Error>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn build(config: Settings) -> Result<Self, std::io::Error> {
|
||||
let pool = get_connection_pool(&config.database);
|
||||
|
||||
// Use application's address configuration
|
||||
let addr = format!("{}:{}", config.application.host, config.application.port);
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let app_state = App { pool }.into();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
|
||||
|
||||
Ok(Self { port, server })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||
self.server.await?
|
||||
}
|
||||
}
|
||||
17
backend/src/startup.rs
Normal file
17
backend/src/startup.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::DatabaseSettings;
|
||||
|
||||
pub type AppState = Arc<App>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
|
||||
PgPoolOptions::new().connect_lazy_with(config.with_db())
|
||||
}
|
||||
25
backend/src/telemetry.rs
Normal file
25
backend/src/telemetry.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use tracing::{subscriber::set_global_default, Subscriber};
|
||||
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
|
||||
|
||||
pub fn get_subscriber<Sink>(
|
||||
name: &str,
|
||||
env_filter: &str,
|
||||
sink: Sink,
|
||||
) -> impl Subscriber + Sync + Send
|
||||
where
|
||||
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
|
||||
{
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| env_filter.into());
|
||||
let formatting_layer = BunyanFormattingLayer::new(name.into(), sink);
|
||||
Registry::default()
|
||||
.with(env_filter)
|
||||
.with(JsonStorageLayer)
|
||||
.with(formatting_layer)
|
||||
}
|
||||
|
||||
pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
|
||||
LogTracer::init().expect("Failed to set logger");
|
||||
set_global_default(subscriber).expect("Failed to set subscriber.");
|
||||
}
|
||||
Reference in New Issue
Block a user