From 8b08f428a2c77201a34d0bc62c92b708297866f8 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Sat, 25 Jan 2025 16:46:38 +0200 Subject: [PATCH] feat(user): add registration endpoint --- Cargo.lock | 2 + Cargo.toml | 2 + migrations/20250125123853_init.up.sql | 10 ++--- server/Cargo.toml | 2 + server/src/db/mod.rs | 2 - server/src/db/user.rs | 13 ------ server/src/domain/mod.rs | 3 ++ server/src/domain/new_user.rs | 7 ++++ server/src/domain/user_code.rs | 33 +++++++++++++++ server/src/domain/username.rs | 59 +++++++++++++++++++++++++++ server/src/lib.rs | 2 +- server/src/routes/mod.rs | 8 +++- server/src/routes/user/mod.rs | 2 + server/src/routes/user/register.rs | 56 +++++++++++++++++++++++++ 14 files changed, 178 insertions(+), 23 deletions(-) delete mode 100644 server/src/db/mod.rs delete mode 100644 server/src/db/user.rs create mode 100644 server/src/domain/mod.rs create mode 100644 server/src/domain/new_user.rs create mode 100644 server/src/domain/user_code.rs create mode 100644 server/src/domain/username.rs create mode 100644 server/src/routes/user/mod.rs create mode 100644 server/src/routes/user/register.rs diff --git a/Cargo.lock b/Cargo.lock index 4ee1252..c8fa4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2422,6 +2422,7 @@ dependencies = [ "config 0.15.6", "leptos", "leptos_axum", + "rand", "secrecy", "serde", "serde-aux", @@ -2432,6 +2433,7 @@ dependencies = [ "tower-http", "tracing", "tracing-log 0.2.0", + "unicode-segmentation", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 3d03151..cc15f37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ 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" # See https://github.com/leptos-rs/cargo-leptos for documentation of all the parameters. diff --git a/migrations/20250125123853_init.up.sql b/migrations/20250125123853_init.up.sql index 0f2932d..a82fe10 100644 --- a/migrations/20250125123853_init.up.sql +++ b/migrations/20250125123853_init.up.sql @@ -3,7 +3,7 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Users table with login codes -CREATE TABLE IF NOT EXISTS users ( +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, @@ -11,18 +11,18 @@ CREATE TABLE IF NOT EXISTS users ( ); -- Scores table with detailed game stats -CREATE TABLE IF NOT EXISTS scores ( +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 users (id) + FOREIGN KEY (user_id) REFERENCES "user" (id) ); -- Indexes for performance -CREATE INDEX idx_users_login ON users (code); +CREATE INDEX idx_user_login ON "user" (code); -CREATE INDEX idx_scores_user_score ON scores (user_id, score DESC); +CREATE INDEX idx_scores_user_score ON score (user_id, score DESC); diff --git a/server/Cargo.toml b/server/Cargo.toml index 8b9394f..b43b1ad 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,3 +23,5 @@ serde-aux.workspace = true config.workspace = true secrecy.workspace = true uuid.workspace = true +unicode-segmentation.workspace = true +rand.workspace = true diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs deleted file mode 100644 index 36dafd6..0000000 --- a/server/src/db/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod user; - diff --git a/server/src/db/user.rs b/server/src/db/user.rs deleted file mode 100644 index 73b1b8f..0000000 --- a/server/src/db/user.rs +++ /dev/null @@ -1,13 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum UserError { - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - #[error("Username already taken")] - UsernameTaken, - #[error("User not found")] - NotFound, -} - -pub async fn create_user() {} diff --git a/server/src/domain/mod.rs b/server/src/domain/mod.rs new file mode 100644 index 0000000..1186991 --- /dev/null +++ b/server/src/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod new_user; +pub mod user_code; +pub mod username; diff --git a/server/src/domain/new_user.rs b/server/src/domain/new_user.rs new file mode 100644 index 0000000..6092521 --- /dev/null +++ b/server/src/domain/new_user.rs @@ -0,0 +1,7 @@ +use super::{user_code::UserCode, username::Username}; + +#[derive(Debug, Default)] +pub struct NewUser { + pub username: Username, + pub code: UserCode, +} diff --git a/server/src/domain/user_code.rs b/server/src/domain/user_code.rs new file mode 100644 index 0000000..28bf1d9 --- /dev/null +++ b/server/src/domain/user_code.rs @@ -0,0 +1,33 @@ +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::(); + + Self(code.into()) + } +} + +impl Deref for UserCode { + type Target = SecretString; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/server/src/domain/username.rs b/server/src/domain/username.rs new file mode 100644 index 0000000..f990c73 --- /dev/null +++ b/server/src/domain/username.rs @@ -0,0 +1,59 @@ +use rand::{seq::SliceRandom, thread_rng, Rng}; +use std::str::FromStr; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug)] +pub struct Username(String); + +impl TryFrom for Username { + type Error = String; + fn try_from(value: String) -> Result { + 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(format!("{} is not a valid subscriber name.", 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(); + let noun = nouns.choose(&mut rng).unwrap(); + + let number = rng.gen_range(100..1000); + + let username = format!("{}_{}_{}", adjective, noun, number); + + Self(username) + } +} + +impl FromStr for Username { + type Err = String; + fn from_str(s: &str) -> Result { + Self::try_from(s.to_owned()) + } +} + +impl AsRef for Username { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 36f192d..d70ca6e 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,4 +1,4 @@ pub mod config; -pub mod db; +pub mod domain; pub mod routes; pub mod startup; diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index e52205b..6b0c4c3 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,4 +1,5 @@ mod health_check; +mod user; use std::time::Duration; use app::{shell, App}; @@ -7,7 +8,7 @@ use axum::{ extract::MatchedPath, http::{HeaderMap, Request}, response::Response, - routing::get, + routing::{get, post}, Router, }; use health_check::health_check; @@ -15,6 +16,7 @@ use health_check::health_check; use leptos_axum::{generate_route_list, LeptosRoutes}; use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; use tracing::{info_span, Span}; +use user::register; use uuid::Uuid; use crate::startup::AppState; @@ -67,7 +69,9 @@ fn api_routes(state: AppState) -> Router { Router::new() .nest( "/api/v1", - Router::new().route("/health_check", get(health_check)), + Router::new() + .route("/health_check", get(health_check)) + .route("/register", post(register)), ) .with_state(state) } diff --git a/server/src/routes/user/mod.rs b/server/src/routes/user/mod.rs new file mode 100644 index 0000000..56d7d42 --- /dev/null +++ b/server/src/routes/user/mod.rs @@ -0,0 +1,2 @@ +mod register; +pub use register::register; diff --git a/server/src/routes/user/register.rs b/server/src/routes/user/register.rs new file mode 100644 index 0000000..dd14345 --- /dev/null +++ b/server/src/routes/user/register.rs @@ -0,0 +1,56 @@ +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, + Form(form): Form, +) -> 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 for NewUser { + type Error = String; + fn try_from(value: FormData) -> Result { + let username = value.username.try_into()?; + Ok(Self { + username, + ..Default::default() + }) + } +}