mirror of
https://github.com/kristoferssolo/echoes-of-ascension.git
synced 2025-10-21 18:50:34 +00:00
feat(migrations): migrate old code
This commit is contained in:
parent
e0b7b20982
commit
ff62ce1761
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO \"user\" (username, code)\n VALUES ($1, $2)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Varchar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf"
|
||||||
|
}
|
||||||
11
Cargo.toml
11
Cargo.toml
@ -16,6 +16,7 @@ axum = "0.8"
|
|||||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
config = { version = "0.15", features = ["toml"], default-features = false }
|
config = { version = "0.15", features = ["toml"], default-features = false }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", default-features = false, features = [
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
"tls-rustls",
|
"tls-rustls",
|
||||||
@ -31,7 +32,7 @@ tokio = { version = "1.39", features = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
] }
|
] }
|
||||||
uuid = { version = "1.8", features = ["v4", "serde"] }
|
uuid = { version = "1.13", features = ["v4", "serde"] }
|
||||||
tracing = { version = "0.1", features = ["log"] }
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
@ -44,6 +45,14 @@ reqwest = { version = "0.12", default-features = false, features = [
|
|||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
] }
|
] }
|
||||||
askama = { version = "0.12", features = ["with-axum"] }
|
askama = { version = "0.12", features = ["with-axum"] }
|
||||||
|
validator = "0.20"
|
||||||
|
unicode-segmentation = "1"
|
||||||
|
rand = "0.8"
|
||||||
|
argon2 = "0.5"
|
||||||
|
password-hash = "0.5"
|
||||||
|
hex = "0.4"
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
11
migrations/20250125123853_init.down.sql
Normal file
11
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
migrations/20250125123853_init.up.sql
Normal file
28
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);
|
||||||
|
|
||||||
2
scripts/init_db
Normal file → Executable file
2
scripts/init_db
Normal file → Executable file
@ -16,7 +16,7 @@ fi
|
|||||||
|
|
||||||
DB_USER="${POSTGRES_USER:=postgres}"
|
DB_USER="${POSTGRES_USER:=postgres}"
|
||||||
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
|
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
|
||||||
DB_NAME="${POSTGRES_DB:=newsletter}"
|
DB_NAME="${POSTGRES_DB:=echoes-of-ascension}"
|
||||||
DB_PORT="${POSTGRES_PORT:=5432}"
|
DB_PORT="${POSTGRES_PORT:=5432}"
|
||||||
DB_HOST="${POSTGRES_HOST:=localhost}"
|
DB_HOST="${POSTGRES_HOST:=localhost}"
|
||||||
|
|
||||||
|
|||||||
@ -22,3 +22,5 @@
|
|||||||
//!
|
//!
|
||||||
//! pub struct UserId(pub String);
|
//! pub struct UserId(pub String);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
pub mod user;
|
||||||
|
|||||||
3
src/domain/user/mod.rs
Normal file
3
src/domain/user/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod new_user;
|
||||||
|
mod user_code;
|
||||||
|
mod username;
|
||||||
7
src/domain/user/new_user.rs
Normal file
7
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
src/domain/user/user_code.rs
Normal file
73
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 crate::errors::user::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
src/domain/user/username.rs
Normal file
73
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 crate::errors::user::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
src/errors/app.rs
Normal file
88
src/errors/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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod user;
|
||||||
22
src/errors/user.rs
Normal file
22
src/errors/user.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),
|
||||||
|
}
|
||||||
@ -23,3 +23,5 @@
|
|||||||
//! Ok(())
|
//! Ok(())
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
pub mod user;
|
||||||
|
|||||||
36
src/repositories/user.rs
Normal file
36
src/repositories/user.rs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{domain::user::new_user::NewUser, errors::user::UserError};
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
9
src/routes/api/mod.rs
Normal file
9
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())
|
||||||
|
}
|
||||||
71
src/routes/api/v1/auth.rs
Normal file
71
src/routes/api/v1/auth.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::user::new_user::NewUser,
|
||||||
|
errors::{app::AppError, user::UserError},
|
||||||
|
repositories::user::{insert_user, ServerUserError},
|
||||||
|
startup::AppState,
|
||||||
|
};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[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
src/routes/api/v1/mod.rs
Normal file
9
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))
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
mod api;
|
||||||
mod health_check;
|
mod health_check;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
@ -19,6 +20,7 @@ use uuid::Uuid;
|
|||||||
pub fn route(state: AppState) -> Router {
|
pub fn route(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health_check", get(health_check))
|
.route("/health_check", get(health_check))
|
||||||
|
.nest("/api", api::routes())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user