refactor: layout

This commit is contained in:
Kristofers Solo 2025-01-26 16:59:56 +02:00
parent 2e9f4035ee
commit 493a636a7d
33 changed files with 276 additions and 276 deletions

19
Cargo.lock generated
View File

@ -97,29 +97,22 @@ checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
name = "app"
version = "0.1.0"
dependencies = [
"argon2",
"cfg-if",
"chrono",
"config 0.15.6",
"hex",
"http",
"leptos",
"leptos_axum",
"leptos_meta",
"leptos_router",
"password-hash",
"rand",
"secrecy",
"serde",
"serde-aux",
"sqlx",
"thiserror 2.0.11",
"tokio",
"tracing",
"tracing-bunyan-formatter",
"tracing-log 0.2.0",
"tracing-subscriber",
"unicode-segmentation",
"uuid",
]
@ -2459,14 +2452,26 @@ name = "server"
version = "0.1.0"
dependencies = [
"app",
"argon2",
"axum",
"config 0.15.6",
"hex",
"leptos",
"leptos_axum",
"leptos_router",
"password-hash",
"rand",
"secrecy",
"serde",
"serde-aux",
"sqlx",
"thiserror 2.0.11",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-log 0.2.0",
"unicode-segmentation",
"uuid",
]

View File

@ -22,23 +22,10 @@ tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2"
thiserror = "2.0"
tokio = { version = "1.43", features = [
"rt",
"macros",
"tracing",
"rt-multi-thread",
] }
tokio = { version = "1.43", features = ["rt", "macros", "tracing"] }
tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["full"] }
wasm-bindgen = "=0.2.100"
sqlx = { version = "0.8", features = [
"runtime-tokio",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
uuid = { version = "1.12", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
@ -128,9 +115,3 @@ pedantic = "warn"
nursery = "warn"
unwrap_used = "warn"
expect_used = "warn"
[workspace.package.metadata.nextest]
slow-timeout = { period = "120s", terminate-after = 3 }
[workspace.profile.dev.package.sqlx-macros]
opt-level = 3

View File

@ -19,19 +19,12 @@ tracing.workspace = true
tracing-subscriber.workspace = true
tracing-bunyan-formatter.workspace = true
tracing-log.workspace = true
sqlx.workspace = true
uuid.workspace = true
tokio.workspace = true
chrono.workspace = true
serde.workspace = true
secrecy.workspace = true
unicode-segmentation.workspace = true
rand.workspace = true
config.workspace = true
serde-aux.workspace = true
argon2.workspace = true
password-hash.workspace = true
hex.workspace = true
config.workspace = true
[features]
default = []

View File

@ -5,7 +5,7 @@ use leptos_router::{
StaticSegment,
};
use crate::components::homepage::HomePage;
use crate::components::{homepage::HomePage, register::RegisterPage};
#[component]
pub fn App() -> impl IntoView {
@ -23,6 +23,7 @@ pub fn App() -> impl IntoView {
<main>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage />
<Route path=StaticSegment("/register") view=RegisterPage />
</Routes>
</main>
</Router>

View File

@ -1,6 +1,6 @@
use leptos::prelude::*;
use crate::server_fn::auth::register_user;
use crate::{models::user::form::RegisterUserForm, server_fn::auth::register_user};
#[component]
pub fn RegisterPage() -> impl IntoView {
@ -34,14 +34,14 @@ pub fn RegisterPage() -> impl IntoView {
}
prop:value=username
/>
<div class="error">
{move || {
response
.get()
.and_then(|result| result.err())
.map(|err| err.to_string())
}}
</div>
// <div class="error">
// {move || {
// response
// .get()
// .and_then(|result| result.err())
// .map(|err| err.to_string())
// }}
// </div>
</div>
<button type="submit" disabled=pending>
{move || {

View File

@ -1,29 +1,7 @@
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
ConnectOptions,
};
use std::{fmt::Display, str::FromStr};
#[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")]
@ -37,51 +15,6 @@ pub enum Environment {
Production,
}
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("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 DatabaseSettings {
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)
}
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View File

@ -1,16 +1,14 @@
pub mod components;
pub mod config;
pub mod db;
pub mod models;
pub mod server_fn;
pub mod startup;
pub mod telemetry;
pub mod validation;
pub use components::app::App;
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
StaticSegment,
};
use leptos_meta::MetaTags;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@ -29,38 +27,3 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/echoes-of-ascension.css" />
// sets the document title
<Title text="Welcome to Leptos" />
// content for this welcome page
<Router>
<main>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage />
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let count = RwSignal::new(0);
let on_click = move |_| *count.write() += 1;
view! {
<h1>"Welcome to Leptos!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}

View File

@ -1,8 +1,5 @@
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use super::user::new_user::NewUser;
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterResponse {
pub username: String,
@ -24,12 +21,3 @@ where
}
}
}
impl From<NewUser> for RegisterResponse {
fn from(value: NewUser) -> Self {
Self {
username: value.username.as_ref().to_string(),
code: value.code.expose_secret().to_string(),
}
}
}

View File

@ -8,9 +8,6 @@ pub enum UserError {
#[error("Code hashing failed: {0}")]
HashingError(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Username already taken: {0}")]
UsernameTaken(String),

View File

@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterUserForm {
pub username: String,
}

View File

@ -1,4 +1,2 @@
pub mod error;
pub mod new_user;
pub mod user_code;
pub mod username;
pub mod form;

View File

@ -1,27 +1,11 @@
use crate::db::users::insert_user;
use crate::models::user::error::UserError;
use crate::models::user::new_user::RegisterUserForm;
use crate::{models::response::RegisterResponse, startup::AppState};
use leptos::{prelude::*, server};
#[server(RegisterUser, "/api/v1/users")]
pub async fn register_user(
form: RegisterUserForm,
) -> Result<RegisterResponse, ServerFnError<String>> {
let state = use_context::<AppState>()
.ok_or_else(|| ServerFnError::ServerError("AppState not found".into()))?;
use crate::models::{response::RegisterResponse, user::form::RegisterUserForm};
let new_user = form.try_into().map_err(|e| ServerFnError::ServerError(e))?;
match insert_user(&state.pool, &new_user).await {
Ok(_) => Ok(RegisterResponse::from(new_user)),
Err(UserError::UsernameTaken(username)) => Err(ServerFnError::ServerError(format!(
"Username {} is already taken",
username
))),
Err(e) => {
tracing::error!("Failed to register user: {}", e);
Err(ServerFnError::ServerError("Internal server error".into()))
}
}
#[server(RegisterUser, "/register")]
pub async fn register_user(form: RegisterUserForm) -> Result<RegisterResponse, ServerFnError> {
Ok(RegisterResponse {
username: form.username,
code: String::new(),
})
}

View File

@ -1,34 +0,0 @@
use std::sync::Arc;
use leptos::config::{errors::LeptosConfigError, LeptosOptions};
use sqlx::{postgres::PgPoolOptions, PgPool};
use thiserror::Error;
use crate::config::DatabaseSettings;
pub type AppState = Arc<App>;
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Leptos configuration error: {0}")]
LeptosConfig(#[from] LeptosConfigError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Server error: {0}")]
Server(String),
}
#[derive(Debug)]
pub struct App {
pub pool: PgPool,
pub leptos_options: LeptosOptions,
}
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

20
app/src/validation.rs Normal file
View File

@ -0,0 +1,20 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("Field cannot be empty: {0}")]
Empty(String),
#[error("Field too short: {0} (minimum {1} characters)")]
TooShort(String, usize),
#[error("Field too long: {0} (maximum {1} characters)")]
TooLong(String, usize),
#[error("Invalid format: {0}")]
InvalidFormat(String),
}
pub trait Validate {
fn validate(&self) -> Result<(), ValidationError>;
}

View File

@ -18,7 +18,7 @@ setup:
# Start development server with hot reload
dev: kill-server db-migrate
RUST_BACKTRACE=full cargo leptos watch | bunyan
cargo leptos watch | bunyan
# Run cargo check on both native and wasm targets
check:

View File

@ -9,14 +9,33 @@ edition = "2021"
app = { path = "../app", default-features = false, features = ["ssr"] }
leptos = { workspace = true, features = ["ssr"] }
leptos_axum.workspace = true
thiserror.workspace = true
axum.workspace = true
tokio.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
secrecy.workspace = true
serde.workspace = true
serde-aux.workspace = true
config.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-log.workspace = true
uuid.workspace = true
rand.workspace = true
argon2.workspace = true
unicode-segmentation.workspace = true
password-hash.workspace = true
hex.workspace = true
leptos_router.workspace = true
[lints]
workspace = true

70
server/src/config.rs Normal file
View File

@ -0,0 +1,70 @@
use app::config::{ApplicationSettings, Environment};
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,
}
impl DatabaseSettings {
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)
}
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("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>()
}

View File

@ -1,9 +1,10 @@
use app::models::user::error::UserError;
use sqlx::PgPool;
use crate::models::user::{error::UserError, new_user::NewUser};
use crate::{domain::user::NewUser, error::user::ServerUserError};
#[tracing::instrument(name = "Saving new user details in the database", skip(new_user, pool))]
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), UserError> {
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), ServerUserError> {
sqlx::query!(
r#"
INSERT INTO "user" (username, code)
@ -18,9 +19,9 @@ pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), UserEr
tracing::error!("Failed to execute query: {:?}", e);
match e {
sqlx::Error::Database(ref dbe) if dbe.constraint() == Some("user_username_key") => {
UserError::UsernameTaken(new_user.username.as_ref().to_string())
ServerUserError::User(UserError::UsernameTaken(new_user.username.as_ref().into()))
}
_ => UserError::Database(e),
_ => ServerUserError::Database(e),
}
})?;
Ok(())

3
server/src/domain/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod user;
mod user_code;
mod username;

View File

@ -1,13 +1,9 @@
use serde::{Deserialize, Serialize};
use app::models::{response::RegisterResponse, user::form::RegisterUserForm};
use secrecy::ExposeSecret;
use super::{user_code::UserCode, username::Username};
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterUserForm {
pub username: String,
}
#[derive(Debug, Default)]
#[derive(Debug, Clone, Default)]
pub struct NewUser {
pub username: Username,
pub code: UserCode,
@ -23,3 +19,12 @@ impl TryFrom<RegisterUserForm> for NewUser {
})
}
}
impl From<NewUser> for RegisterResponse {
fn from(value: NewUser) -> Self {
Self {
username: value.username.as_ref().into(),
code: value.code.expose_secret().into(),
}
}
}

View File

@ -1,3 +1,4 @@
use app::models::user::error::UserError;
use argon2::Argon2;
use password_hash::SaltString;
use std::ops::Deref;
@ -5,9 +6,7 @@ use std::ops::Deref;
use rand::{rngs::OsRng, thread_rng, Rng};
use secrecy::{ExposeSecret, SecretString};
use super::error::UserError;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct UserCode(SecretString);
impl UserCode {

View File

@ -2,7 +2,7 @@ use rand::{seq::SliceRandom, thread_rng, Rng};
use std::str::FromStr;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Username(String);
impl TryFrom<String> for Username {

4
server/src/error/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod server;
pub mod user;
pub use server::ServerError;

View File

@ -0,0 +1,15 @@
use leptos::config::errors::LeptosConfigError;
use sqlx;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ServerError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Leptos configuration error: {0}")]
LeptosConfig(#[from] LeptosConfigError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}

21
server/src/error/user.rs Normal file
View File

@ -0,0 +1,21 @@
use app::models::user::error::UserError;
use sqlx;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ServerUserError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("{0}")]
User(#[from] UserError),
}
impl From<ServerUserError> for UserError {
fn from(error: ServerUserError) -> Self {
match error {
ServerUserError::Database(e) => Self::Internal(e.to_string()),
ServerUserError::User(e) => e,
}
}
}

View File

@ -1,16 +1,19 @@
mod application;
mod config;
mod db;
pub mod domain;
mod error;
mod routes;
mod server;
mod startup;
use app::{
config::get_config,
startup::ApplicationError,
telemetry::{get_subscriber, init_subscriber},
};
use application::Server;
use app::telemetry::{get_subscriber, init_subscriber};
use config::get_config;
use error::ServerError;
use leptos::prelude::*;
use server::Server;
#[tokio::main]
async fn main() -> Result<(), ApplicationError> {
async fn main() -> Result<(), ServerError> {
// Generate the list of routes in your Leptos App
let subscriber = get_subscriber("echoes-of-ascension-server", "info", std::io::stdout);
init_subscriber(subscriber);

View File

@ -1,8 +1,9 @@
mod v1;
use app::startup::AppState;
use axum::Router;
use crate::startup::AppState;
pub fn routes() -> Router<AppState> {
Router::new().nest("/v1", v1::routes())
}

View File

@ -1,29 +1,29 @@
use app::db::users::insert_user;
use app::models::{
response::{ErrorResponse, RegisterResponse},
user::{error::UserError, new_user::RegisterUserForm},
user::{error::UserError, form::RegisterUserForm},
};
use app::startup::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use crate::{db::users::insert_user, error::user::ServerUserError, startup::AppState};
#[tracing::instrument(
name = "Creating new user",
skip(data, state),
skip(payload, state),
fields(
username= %data.username,
username= %payload.username,
)
)]
pub async fn register(
State(state): State<AppState>,
Json(data): Json<RegisterUserForm>,
Json(payload): Json<RegisterUserForm>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let new_user = data
let new_user = payload
.try_into()
.map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse::from(e))))?;
match insert_user(&state.pool, &new_user).await {
Ok(_) => Ok((StatusCode::CREATED, Json(RegisterResponse::from(new_user)))),
Err(UserError::UsernameTaken(username)) => Err((
Err(ServerUserError::User(UserError::UsernameTaken(username))) => Err((
StatusCode::CONFLICT,
Json(ErrorResponse::from(format!(
"Username {} is already taken.",

View File

@ -1,8 +1,9 @@
mod auth;
use app::startup::AppState;
use axum::{routing::post, Router};
use crate::startup::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/register", post(auth::register))
}

View File

@ -1,7 +1,7 @@
mod api;
mod health_check;
use app::{shell, startup::AppState, App};
use app::{shell, App};
use axum::{
body::Bytes,
extract::MatchedPath,
@ -17,19 +17,24 @@ 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 {
let leptos_options = state.leptos_options.clone();
let routes = generate_route_list(App);
let api_router = api::routes().with_state(state.clone());
Router::new()
.route("/health_check", get(health_check))
// API routes with proper nesting
.nest("/api", api::routes())
.with_state(state)
.nest("/api", api_router)
// Leptos setup
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
}
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options)

View File

@ -1,17 +1,18 @@
use app::{
config::Settings,
startup::{get_connection_pool, App, ApplicationError},
};
use leptos::prelude::*;
use tokio::{net::TcpListener, task::JoinHandle};
use crate::routes::route;
use crate::{
config::Settings,
error::ServerError,
routes::route,
startup::{get_connection_pool, App},
};
#[derive(Debug)]
pub struct Server(JoinHandle<Result<(), std::io::Error>>);
impl Server {
pub async fn build(config: Settings) -> Result<Self, ApplicationError> {
pub async fn build(config: Settings) -> Result<Self, ServerError> {
let pool = get_connection_pool(&config.database);
// Get Leptos configuration but override the address

17
server/src/startup.rs Normal file
View File

@ -0,0 +1,17 @@
use leptos::config::LeptosOptions;
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,
pub leptos_options: LeptosOptions,
}
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}