mirror of
https://github.com/kristoferssolo/echoes-of-ascension.git
synced 2025-10-21 18:50:34 +00:00
Finish initial setup
This commit is contained in:
parent
8b08f428a2
commit
4d1b5b5376
49
Cargo.lock
generated
49
Cargo.lock
generated
@ -97,23 +97,44 @@ 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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.18"
|
||||
@ -289,6 +310,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@ -1892,6 +1922,17 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@ -2419,21 +2460,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"app",
|
||||
"axum",
|
||||
"config 0.15.6",
|
||||
"leptos",
|
||||
"leptos_axum",
|
||||
"rand",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"sqlx",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-log 0.2.0",
|
||||
"unicode-segmentation",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
||||
@ -48,6 +48,9 @@ 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"
|
||||
|
||||
# See https://github.com/leptos-rs/cargo-leptos for documentation of all the parameters.
|
||||
|
||||
@ -55,7 +58,7 @@ rand = "0.8"
|
||||
# that are used together frontend (lib) & server (bin)
|
||||
[[workspace.metadata.leptos]]
|
||||
# this name is used for the wasm, js and css file names
|
||||
name = "start-axum-workspace"
|
||||
name = "echoes-of-ascension"
|
||||
|
||||
# the package in the workspace that contains the server binary (binary crate)
|
||||
bin-package = "server"
|
||||
|
||||
@ -21,8 +21,17 @@ 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
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
1
app/src/db/mod.rs
Normal file
1
app/src/db/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod users;
|
||||
27
app/src/db/users.rs
Normal file
27
app/src/db/users.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::models::user::{error::UserError, new_user::NewUser};
|
||||
|
||||
#[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> {
|
||||
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") => {
|
||||
UserError::UsernameTaken(new_user.username.as_ref().to_string())
|
||||
}
|
||||
_ => UserError::Database(e),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod models;
|
||||
pub mod server_fn;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
|
||||
use leptos::prelude::*;
|
||||
@ -32,7 +36,7 @@ pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/start-axum-workspace.css" />
|
||||
<Stylesheet id="leptos" href="/pkg/echoes-of-ascension.css" />
|
||||
|
||||
// sets the document title
|
||||
<Title text="Welcome to Leptos" />
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
pub mod response;
|
||||
pub mod user;
|
||||
|
||||
|
||||
35
app/src/models/response.rs
Normal file
35
app/src/models/response.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::user::new_user::NewUser;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub username: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl<T> From<T> for ErrorResponse
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
Self {
|
||||
error: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub code: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserRegistration {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserLogin {
|
||||
pub code: String,
|
||||
}
|
||||
25
app/src/models/user/error.rs
Normal file
25
app/src/models/user/error.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UserError {
|
||||
#[error("Username validation failed: {0}")]
|
||||
UsernameValidation(String),
|
||||
|
||||
#[error("Code hashing failed: {0}")]
|
||||
HashingError(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("Username already taken: {0}")]
|
||||
UsernameTaken(String),
|
||||
|
||||
#[error("Invalid code format")]
|
||||
InvalidCode,
|
||||
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod error;
|
||||
pub mod new_user;
|
||||
pub mod user_code;
|
||||
pub mod username;
|
||||
25
app/src/models/user/new_user.rs
Normal file
25
app/src/models/user/new_user.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{user_code::UserCode, username::Username};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterUserForm {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NewUser {
|
||||
pub username: Username,
|
||||
pub code: UserCode,
|
||||
}
|
||||
|
||||
impl TryFrom<RegisterUserForm> for NewUser {
|
||||
type Error = String;
|
||||
fn try_from(value: RegisterUserForm) -> Result<Self, Self::Error> {
|
||||
let username = value.username.try_into()?;
|
||||
Ok(Self {
|
||||
username,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
73
app/src/models/user/user_code.rs
Normal file
73
app/src/models/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)]
|
||||
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
|
||||
}
|
||||
}
|
||||
28
app/src/server_fn/auth.rs
Normal file
28
app/src/server_fn/auth.rs
Normal file
@ -0,0 +1,28 @@
|
||||
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(username: String) -> Result<RegisterResponse, ServerFnError<String>> {
|
||||
let state = use_context::<AppState>()
|
||||
.ok_or_else(|| ServerFnError::ServerError("AppState not found".into()))?;
|
||||
|
||||
let form = RegisterUserForm { username };
|
||||
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".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
1
app/src/server_fn/mod.rs
Normal file
1
app/src/server_fn/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
mod auth;
|
||||
34
app/src/startup.rs
Normal file
34
app/src/startup.rs
Normal file
@ -0,0 +1,34 @@
|
||||
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())
|
||||
}
|
||||
2
justfile
2
justfile
@ -1,7 +1,5 @@
|
||||
set dotenv-load
|
||||
|
||||
export RUSTC_WRAPPER:="sccache"
|
||||
|
||||
# List all available commands
|
||||
default:
|
||||
@just --list
|
||||
|
||||
@ -13,15 +13,7 @@ leptos_axum.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
thiserror.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-log.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde-aux.workspace = true
|
||||
config.workspace = true
|
||||
secrecy.workspace = true
|
||||
uuid.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
37
server/src/application.rs
Normal file
37
server/src/application.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use app::{
|
||||
config::Settings,
|
||||
startup::{get_connection_pool, App, ApplicationError},
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
|
||||
use crate::routes::route;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Server(JoinHandle<Result<(), std::io::Error>>);
|
||||
|
||||
impl Server {
|
||||
pub async fn build(config: Settings) -> Result<Self, ApplicationError> {
|
||||
let pool = get_connection_pool(&config.database);
|
||||
|
||||
// Get Leptos configuration but override the address
|
||||
let conf = get_configuration(None)?;
|
||||
|
||||
// Use application's address configuration
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
let app_state = App {
|
||||
pool,
|
||||
leptos_options: conf.leptos_options,
|
||||
}
|
||||
.into();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
|
||||
|
||||
Ok(Self(server))
|
||||
}
|
||||
|
||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||
self.0.await?
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
use super::{user_code::UserCode, username::Username};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NewUser {
|
||||
pub username: Username,
|
||||
pub code: UserCode,
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use rand::{thread_rng, Rng};
|
||||
use secrecy::SecretString;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserCode(SecretString);
|
||||
|
||||
impl UserCode {
|
||||
pub fn generate() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod routes;
|
||||
pub mod startup;
|
||||
@ -1,17 +1,23 @@
|
||||
use app::telemetry::{get_subscriber, init_subscriber};
|
||||
mod application;
|
||||
mod routes;
|
||||
|
||||
use app::{
|
||||
config::get_config,
|
||||
startup::ApplicationError,
|
||||
telemetry::{get_subscriber, init_subscriber},
|
||||
};
|
||||
use application::Server;
|
||||
use leptos::prelude::*;
|
||||
use server::config::get_config;
|
||||
use server::startup::{Application, ApplicationError};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), ApplicationError> {
|
||||
// Generate the list of routes in your Leptos App
|
||||
let subscriber = get_subscriber("echoes-of-ascension-backend", "info", std::io::stdout);
|
||||
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 = Application::build(config).await?;
|
||||
let application = Server::build(config).await?;
|
||||
application.run_until_stopped().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
8
server/src/routes/api/mod.rs
Normal file
8
server/src/routes/api/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
mod v1;
|
||||
|
||||
use app::startup::AppState;
|
||||
use axum::Router;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().nest("/v1", v1::routes())
|
||||
}
|
||||
41
server/src/routes/api/v1/auth.rs
Normal file
41
server/src/routes/api/v1/auth.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use app::db::users::insert_user;
|
||||
use app::models::{
|
||||
response::{ErrorResponse, RegisterResponse},
|
||||
user::{error::UserError, new_user::RegisterUserForm},
|
||||
};
|
||||
use app::startup::AppState;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
#[tracing::instrument(
|
||||
name = "Creating new user",
|
||||
skip(data, state),
|
||||
fields(
|
||||
username= %data.username,
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(data): Json<RegisterUserForm>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let new_user = data
|
||||
.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((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse::from(format!(
|
||||
"Username {} is already taken.",
|
||||
username
|
||||
))),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to register user: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse::from("Internal server error")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
8
server/src/routes/api/v1/mod.rs
Normal file
8
server/src/routes/api/v1/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
mod auth;
|
||||
|
||||
use app::startup::AppState;
|
||||
use axum::{routing::post, Router};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/register", post(auth::register))
|
||||
}
|
||||
@ -1,30 +1,39 @@
|
||||
mod api;
|
||||
mod health_check;
|
||||
mod user;
|
||||
use std::time::Duration;
|
||||
|
||||
use app::{shell, App};
|
||||
use app::{shell, startup::AppState, App};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::MatchedPath,
|
||||
http::{HeaderMap, Request},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use health_check::health_check;
|
||||
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use std::time::Duration;
|
||||
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
||||
use tracing::{info_span, Span};
|
||||
use user::register;
|
||||
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);
|
||||
|
||||
Router::new()
|
||||
.merge(leptos_routes(state.clone()))
|
||||
.merge(api_routes(state))
|
||||
.route("/health_check", get(health_check))
|
||||
// API routes with proper nesting
|
||||
.nest("/api", api::routes())
|
||||
.with_state(state)
|
||||
// Leptos setup
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
.with_state(leptos_options)
|
||||
// Tracing layer
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<_>| {
|
||||
@ -51,27 +60,3 @@ pub fn route(state: AppState) -> Router {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn leptos_routes(state: AppState) -> Router {
|
||||
let leptos_options = state.leptos_options.clone();
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
Router::new()
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
.with_state(leptos_options)
|
||||
}
|
||||
|
||||
fn api_routes(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.nest(
|
||||
"/api/v1",
|
||||
Router::new()
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/register", post(register)),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
mod register;
|
||||
pub use register::register;
|
||||
@ -1,56 +0,0 @@
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Form};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{domain::new_user::NewUser, startup::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FormData {
|
||||
username: String,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<FormData>,
|
||||
) -> impl IntoResponse {
|
||||
let new_user = match form.try_into() {
|
||||
Ok(subscriber) => subscriber,
|
||||
Err(_) => return StatusCode::BAD_REQUEST,
|
||||
};
|
||||
if insert_user(&state.pool, &new_user).await.is_err() {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[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<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO "user" (username, code)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
new_user.username.as_ref(),
|
||||
new_user.code.expose_secret()
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to execute query: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TryFrom<FormData> for NewUser {
|
||||
type Error = String;
|
||||
fn try_from(value: FormData) -> Result<Self, Self::Error> {
|
||||
let username = value.username.try_into()?;
|
||||
Ok(Self {
|
||||
username,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use leptos::config::{errors::LeptosConfigError, get_configuration, LeptosOptions};
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
use thiserror::Error;
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
|
||||
use crate::{
|
||||
config::{DatabaseSettings, Settings},
|
||||
routes::route,
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Application {
|
||||
port: u16,
|
||||
server: JoinHandle<Result<(), std::io::Error>>,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
pub async fn build(config: Settings) -> Result<Self, ApplicationError> {
|
||||
let pool = get_connection_pool(&config.database);
|
||||
|
||||
// Get Leptos configuration but override the address
|
||||
let conf = get_configuration(None)?;
|
||||
|
||||
// Use application's address configuration
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let app_state = App {
|
||||
pool,
|
||||
leptos_options: conf.leptos_options,
|
||||
}
|
||||
.into();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
|
||||
|
||||
Ok(Self { port, server })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||
self.server.await?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
|
||||
PgPoolOptions::new().connect_lazy_with(config.with_db())
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user