feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs

- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
This commit is contained in:
2026-07-11 21:28:52 +02:00
parent 13031347cc
commit 7bd27d9b9c
50 changed files with 1604 additions and 213 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { workspace = true }

View File

@@ -0,0 +1,100 @@
use std::sync::Arc;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use domain::errors::DomainError;
use domain::models::GeneratedToken;
use domain::value_objects::UserId;
pub struct JwtAuthService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
ttl_seconds: i64,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
impl JwtAuthService {
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
ttl_seconds: ttl_seconds as i64,
}
}
}
#[async_trait]
impl domain::ports::AuthService for JwtAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
let claims = Claims {
sub: user_id.value().to_string(),
exp: expires_at.timestamp() as usize,
};
let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(GeneratedToken { token, expires_at })
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data =
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl domain::ports::PasswordHasher for Argon2PasswordHasher {
async fn hash(
&self,
plain_password: &str,
) -> Result<domain::value_objects::PasswordHash, DomainError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain_password.as_bytes(), &salt)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_string();
domain::value_objects::PasswordHash::new(hash)
}
async fn verify(
&self,
plain_password: &str,
hash: &domain::value_objects::PasswordHash,
) -> Result<bool, DomainError> {
let parsed = PasswordHash::new(hash.value())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain_password.as_bytes(), &parsed)
.is_ok())
}
}
pub fn create(
secret: &str,
ttl_seconds: u64,
) -> (
Arc<dyn domain::ports::AuthService>,
Arc<dyn domain::ports::PasswordHasher>,
) {
(
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
Arc::new(Argon2PasswordHasher),
)
}

View File

@@ -8,4 +8,5 @@ sqlx = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS refresh_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);

View File

@@ -1,5 +1,7 @@
mod refresh_sessions;
pub mod repository;
mod row;
mod search;
mod users;
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};

View File

@@ -0,0 +1,96 @@
use async_trait::async_trait;
use chrono::DateTime;
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::UserId;
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct RefreshSessionRow {
id: String,
user_id: String,
token: String,
expires_at: String,
created_at: String,
}
fn row_to_session(row: RefreshSessionRow) -> Result<RefreshSession, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let user_id = uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
Ok(RefreshSession {
id,
user_id: UserId::from_uuid(user_id),
token: row.token,
expires_at,
created_at,
})
}
#[async_trait]
impl domain::ports::RefreshSessionRepository for SqliteSongRepository {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(session.id.to_string())
.bind(session.user_id.value().to_string())
.bind(&session.token)
.bind(session.expires_at.to_rfc3339())
.bind(session.created_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
let row = sqlx::query_as::<_, RefreshSessionRow>(
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
)
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_session).transpose()
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let now = chrono::Utc::now().to_rfc3339();
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(result.rows_affected())
}
}

View File

@@ -0,0 +1,81 @@
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, PasswordHash, UserId, Username};
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct UserRow {
id: String,
email: String,
username: String,
password_hash: String,
}
fn row_to_user(row: UserRow) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(User::from_persistence(
UserId::from_uuid(id),
Email::new(&row.email)?,
Username::new(&row.username)?,
PasswordHash::new(row.password_hash)?,
))
}
#[async_trait]
impl domain::ports::UserRepository for SqliteSongRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
)
.bind(email.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
)
.bind(username.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(user.id().value().to_string())
.bind(user.email().value())
.bind(user.username().value())
.bind(user.password_hash().value())
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}

View File

@@ -5,3 +5,4 @@ edition = "2024"
[dependencies]
serde = { workspace = true }
utoipa = { version = "5", features = ["axum_extras"] }

View File

@@ -1,31 +1,70 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub struct ParseRequest {
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListQuery {
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub struct UpdateSongRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct GetSongQuery {
pub apply_capo: Option<bool>,
}
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize, ToSchema)]
pub struct LoginResponse {
pub token: String,
pub refresh_token: String,
pub user_id: String,
pub expires_at: String,
}
#[derive(Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}
#[derive(Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Serialize, ToSchema)]
pub struct RefreshResponse {
pub token: String,
pub refresh_token: String,
pub expires_at: String,
}
#[derive(Deserialize, ToSchema)]
pub struct LogoutRequest {
pub refresh_token: String,
}

View File

@@ -5,4 +5,5 @@ edition = "2024"
[dependencies]
uuid = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,18 @@
pub struct RegisterCommand {
pub email: String,
pub username: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RefreshCommand {
pub refresh_token: String,
}
pub struct LogoutCommand {
pub refresh_token: String,
}

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
pub struct RegisterDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub allow_registration: bool,
}
pub struct LoginDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct RefreshDeps {
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct LogoutDeps {
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
}

View File

@@ -0,0 +1,53 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::{Email, UserId};
use uuid::Uuid;
use super::commands::LoginCommand;
use super::deps::LoginDeps;
pub struct LoginResult {
pub access_token: String,
pub refresh_token: String,
pub user_id: UserId,
pub expires_at: String,
}
pub async fn execute(deps: &LoginDeps, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(&cmd.email)?;
let user = deps
.user_repo
.find_by_email(&email)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid credentials".into()))?;
let valid = deps
.password_hasher
.verify(&cmd.password, user.password_hash())
.await?;
if !valid {
return Err(DomainError::Unauthorized("invalid credentials".into()));
}
let generated = deps.auth_service.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let session = RefreshSession {
id: Uuid::new_v4(),
user_id: *user.id(),
token: refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&session).await?;
Ok(LoginResult {
access_token: generated.token,
refresh_token,
user_id: *user.id(),
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,8 @@
use domain::errors::DomainError;
use super::commands::LogoutCommand;
use super::deps::LogoutDeps;
pub async fn execute(deps: &LogoutDeps, cmd: LogoutCommand) -> Result<(), DomainError> {
deps.refresh_repo.revoke(&cmd.refresh_token).await
}

View File

@@ -0,0 +1,6 @@
pub mod commands;
pub mod deps;
pub mod login;
pub mod logout;
pub mod refresh;
pub mod register;

View File

@@ -0,0 +1,50 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use uuid::Uuid;
use super::commands::RefreshCommand;
use super::deps::RefreshDeps;
pub struct RefreshResult {
pub access_token: String,
pub refresh_token: String,
pub expires_at: String,
}
pub async fn execute(
deps: &RefreshDeps,
cmd: RefreshCommand,
) -> Result<RefreshResult, DomainError> {
let session = deps
.refresh_repo
.get_by_token(&cmd.refresh_token)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?;
if session.expires_at < Utc::now() {
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
return Err(DomainError::Unauthorized("refresh token expired".into()));
}
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
let generated = deps.auth_service.generate_token(&session.user_id).await?;
let new_refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let new_session = RefreshSession {
id: Uuid::new_v4(),
user_id: session.user_id,
token: new_refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&new_session).await?;
Ok(RefreshResult {
access_token: generated.token,
refresh_token: new_refresh_token,
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,34 @@
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, Password, Username};
use super::commands::RegisterCommand;
use super::deps::RegisterDeps;
pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> {
if !deps.allow_registration {
return Err(DomainError::Unauthorized("registration is disabled".into()));
}
let password = Password::new(&cmd.password)?;
let email = Email::new(&cmd.email)?;
let username = Username::new(&cmd.username)?;
if deps.user_repo.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError(
"email already registered".into(),
));
}
if deps.user_repo.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError(
"username already taken".into(),
));
}
let hash = deps.password_hasher.hash(password.value()).await?;
let user = User::new(email, username, hash);
deps.user_repo.save(&user).await?;
Ok(())
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod songs;
pub mod tabs;

View File

@@ -8,3 +8,5 @@ thiserror = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
email_address = "0.2"

View File

@@ -10,4 +10,10 @@ pub enum DomainError {
#[error("Infrastructure failure: {0}")]
InfrastructureError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
}

View File

@@ -6,12 +6,14 @@ pub mod value_objects;
pub use errors::DomainError;
pub use models::{
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
song_preview_chords,
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
SongSummary, StoredSong, User, song_preview_chords,
};
pub use ports::{
FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort,
TabSource,
AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
};
pub use services::{ChordTransposer, TransposeError};
pub use value_objects::{Chord, Note, SortField, SortOrder};
pub use value_objects::{
Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username,
};

View File

@@ -1,3 +1,7 @@
pub mod refresh_session;
pub mod song;
pub mod user;
pub use refresh_session::*;
pub use song::*;
pub use user::*;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::value_objects::UserId;
pub struct GeneratedToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
pub struct RefreshSession {
pub id: Uuid,
pub user_id: UserId,
pub token: String,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,50 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[derive(Debug, Clone)]
pub struct User {
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
}
impl User {
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
Self {
id: UserId::generate(),
email,
username,
password_hash,
}
}
pub fn from_persistence(
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
) -> Self {
Self {
id,
email,
username,
password_hash,
}
}
pub fn id(&self) -> &UserId {
&self.id
}
pub fn email(&self) -> &Email {
&self.email
}
pub fn username(&self) -> &Username {
&self.username
}
pub fn password_hash(&self) -> &PasswordHash {
&self.password_hash
}
}

View File

@@ -0,0 +1,34 @@
use async_trait::async_trait;
use crate::errors::DomainError;
use crate::models::{GeneratedToken, RefreshSession, User};
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[async_trait]
pub trait AuthService: Send + Sync {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RefreshSessionRepository: Send + Sync {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
async fn delete_expired(&self) -> Result<u64, DomainError>;
}

View File

@@ -1,5 +1,7 @@
pub mod auth;
pub mod repository;
pub mod tab_source;
pub use auth::*;
pub use repository::*;
pub use tab_source::*;

View File

@@ -0,0 +1,18 @@
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
impl UserId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
}

View File

@@ -1,7 +1,11 @@
mod chord;
mod ids;
mod note;
mod sorting;
mod user;
pub use chord::*;
pub use ids::*;
pub use note::*;
pub use sorting::*;
pub use user::*;

View File

@@ -0,0 +1,107 @@
use crate::errors::DomainError;
#[derive(Clone, PartialEq, Eq)]
pub struct Email(String);
impl Email {
pub fn new(email: &str) -> Result<Self, DomainError> {
let trimmed = email.trim().to_lowercase();
if !email_address::EmailAddress::is_valid(&trimmed) {
return Err(DomainError::ValidationError(format!(
"invalid email: {trimmed}"
)));
}
Ok(Self(trimmed))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Email({})", self.0)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Username(String);
impl Username {
pub fn new(username: &str) -> Result<Self, DomainError> {
let normalized = username.trim().to_lowercase();
if normalized.len() < 2 || normalized.len() > 30 {
return Err(DomainError::ValidationError(
"username must be 2-30 characters".into(),
));
}
if !normalized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(DomainError::ValidationError(
"username may only contain alphanumeric characters, underscores, and dashes".into(),
));
}
Ok(Self(normalized))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Username {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Username({})", self.0)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordHash(String);
impl PasswordHash {
pub fn new(hash: String) -> Result<Self, DomainError> {
if hash.is_empty() {
return Err(DomainError::ValidationError(
"password hash cannot be empty".into(),
));
}
Ok(Self(hash))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PasswordHash([REDACTED])")
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
impl Password {
pub fn new(password: &str) -> Result<Self, DomainError> {
if password.len() < 8 {
return Err(DomainError::ValidationError(
"password must be at least 8 characters".into(),
));
}
Ok(Self(password.to_string()))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Password([REDACTED])")
}
}

View File

@@ -1,14 +1,19 @@
use std::env;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct AppConfig {
pub database_url: String,
pub host: String,
pub port: u16,
pub cors_origins: CorsOrigins,
pub jwt_secret: String,
pub jwt_ttl_seconds: u64,
pub refresh_ttl_seconds: u64,
pub allow_registration: bool,
pub spa_dir: String,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum CorsOrigins {
Any,
List(Vec<String>),
@@ -40,11 +45,34 @@ impl AppConfig {
),
};
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET env var is required");
let jwt_ttl_seconds = env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(900);
let refresh_ttl_seconds = env::var("REFRESH_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_592_000);
let allow_registration = env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let spa_dir = env::var("SPA_DIR").unwrap_or_else(|_| "./app/build/client".into());
Self {
database_url,
host,
port,
cors_origins,
jwt_secret,
jwt_ttl_seconds,
refresh_ttl_seconds,
allow_registration,
spa_dir,
}
}

View File

@@ -9,10 +9,15 @@ tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-scalar = { version = "0.3", features = ["axum"], default-features = false }
api-types = { workspace = true }
application = { workspace = true }
auth = { workspace = true }
domain = { workspace = true }
infra-wiring = { workspace = true }
sqlite = { workspace = true }

View File

@@ -0,0 +1,30 @@
use std::sync::Arc;
use domain::ports::{
AuthService, PasswordHasher, RefreshSessionRepository, SongRepositoryPort, SongSearchPort,
TabFetcherPort, TabParserPort, UserRepository,
};
use infra_wiring::AppConfig;
#[derive(Clone)]
pub struct Repositories {
pub song_repo: Arc<dyn SongRepositoryPort>,
pub song_search: Arc<dyn SongSearchPort>,
pub user_repo: Arc<dyn UserRepository>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
}
#[derive(Clone)]
pub struct Services {
pub auth: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub tab_fetcher: Arc<dyn TabFetcherPort>,
pub tab_parser: Arc<dyn TabParserPort>,
}
#[derive(Clone)]
pub struct AppContext {
pub repos: Repositories,
pub services: Services,
pub config: AppConfig,
}

View File

@@ -0,0 +1,20 @@
use api_types::ErrorResponse;
use axum::{Json, http::StatusCode};
use domain::DomainError;
pub fn map_error(e: DomainError) -> (StatusCode, Json<ErrorResponse>) {
let (status, message) = match &e {
DomainError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
DomainError::InfrastructureError(_) => {
tracing::error!("{e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(ErrorResponse { error: message }))
}

View File

@@ -0,0 +1,40 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use domain::value_objects::UserId;
use crate::state::AppState;
#[allow(dead_code)]
pub struct AuthenticatedUser(pub UserId);
impl FromRequestParts<AppState> for AuthenticatedUser {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
let user_id = state
.ctx
.services
.auth
.validate_token(token)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthenticatedUser(user_id))
}
}

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use infra_wiring::AppConfig;
use sqlite::SqliteRepositoryFactory;
use ug_parser::{UgHtmlParser, UgTabFetcher};
use crate::context::{AppContext, Repositories, Services};
use crate::state::AppState;
pub async fn wire(config: AppConfig) -> AppState {
let repo = SqliteRepositoryFactory::create(&config.database_url)
.await
.expect("failed to connect to database");
let repo = Arc::new(repo);
let (auth_service, password_hasher) =
::auth::create(&config.jwt_secret, config.jwt_ttl_seconds);
let ctx = AppContext {
repos: Repositories {
song_repo: repo.clone(),
song_search: repo.clone(),
user_repo: repo.clone(),
refresh_repo: repo.clone(),
},
services: Services {
auth: auth_service,
password_hasher,
tab_fetcher: Arc::new(UgTabFetcher::new()),
tab_parser: Arc::new(UgHtmlParser),
},
config,
};
AppState { ctx }
}

View File

@@ -0,0 +1,7 @@
pub mod context;
pub mod errors;
pub mod extractors;
pub mod factory;
pub mod openapi;
pub mod routes;
pub mod state;

View File

@@ -1,20 +1,4 @@
mod routes;
use std::sync::Arc;
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
use application::tabs::deps::ParseTabDeps;
use axum::{
Router,
http::HeaderValue,
routing::{get, post},
};
use infra_wiring::{AppConfig, CorsOrigins};
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
use routes::tabs::{AppState, parse_tab};
use sqlite::SqliteRepositoryFactory;
use tower_http::cors::{Any, CorsLayer};
use ug_parser::{UgHtmlParser, UgTabFetcher};
use infra_wiring::AppConfig;
#[tokio::main]
async fn main() {
@@ -23,53 +7,8 @@ async fn main() {
let config = AppConfig::from_env();
tracing::info!(?config, "starting with config");
let repo = SqliteRepositoryFactory::create(&config.database_url)
.await
.expect("failed to connect to database");
let repo = Arc::new(repo);
let state = Arc::new(AppState {
song_commands: SongCommandDeps { repo: repo.clone() },
song_queries: SongQueryDeps {
repo: repo.clone(),
search: repo.clone(),
},
tab_parser: ParseTabDeps {
fetcher: Arc::new(UgTabFetcher::new()),
parser: Arc::new(UgHtmlParser),
},
});
let cors = match config.cors_origins {
CorsOrigins::Any => CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
CorsOrigins::List(ref origins) => {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| {
o.parse()
.unwrap_or_else(|_| panic!("invalid CORS origin: {o}"))
})
.collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods(Any)
.allow_headers(Any)
}
};
let app = Router::new()
.route("/tabs/parse", post(parse_tab))
.route("/songs", post(create_song).get(list_songs))
.route(
"/songs/{id}",
get(get_song).delete(delete_song).patch(update_song),
)
.layer(cors)
.with_state(state);
let state = presentation::factory::wire(config.clone()).await;
let app = presentation::openapi::serve(presentation::routes::build_router(state));
let addr = config.bind_addr();
let listener = tokio::net::TcpListener::bind(&addr)

View File

@@ -0,0 +1,62 @@
use axum::Router;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
use utoipa_scalar::{Scalar, Servable};
#[derive(OpenApi)]
#[openapi(
info(
title = "PocketChords API",
version = "0.1.0",
description = "Chord sheet management API"
),
modifiers(&SecurityAddon),
paths(
crate::routes::songs::list_songs,
crate::routes::songs::create_song,
crate::routes::songs::get_song,
crate::routes::songs::update_song,
crate::routes::songs::delete_song,
crate::routes::tabs::parse_tab,
crate::routes::auth::register,
crate::routes::auth::login,
crate::routes::auth::refresh,
crate::routes::auth::logout,
),
components(schemas(
api_types::ParseRequest,
api_types::ErrorResponse,
api_types::ListQuery,
api_types::UpdateSongRequest,
api_types::GetSongQuery,
api_types::LoginRequest,
api_types::LoginResponse,
api_types::RegisterRequest,
api_types::RefreshRequest,
api_types::RefreshResponse,
api_types::LogoutRequest,
))
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
pub fn serve(router: Router) -> Router {
router.merge(Scalar::with_url("/docs", ApiDoc::openapi()))
}

View File

@@ -0,0 +1,106 @@
use api_types::{
ErrorResponse, LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse,
RegisterRequest,
};
use application::auth::commands;
use application::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps};
use axum::{Json, extract::State, http::StatusCode};
use crate::errors::map_error;
use crate::state::AppState;
#[utoipa::path(post, path = "/api/auth/register", request_body = RegisterRequest, responses((status = 201, description = "Registered")))]
pub async fn register(
State(state): State<AppState>,
Json(body): Json<RegisterRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let deps = RegisterDeps {
user_repo: state.ctx.repos.user_repo.clone(),
password_hasher: state.ctx.services.password_hasher.clone(),
allow_registration: state.ctx.config.allow_registration,
};
let cmd = commands::RegisterCommand {
email: body.email,
username: body.username,
password: body.password,
};
application::auth::register::execute(&deps, cmd)
.await
.map(|()| StatusCode::CREATED)
.map_err(map_error)
}
#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, responses((status = 200, description = "Login successful", body = LoginResponse)))]
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
let deps = LoginDeps {
user_repo: state.ctx.repos.user_repo.clone(),
password_hasher: state.ctx.services.password_hasher.clone(),
auth_service: state.ctx.services.auth.clone(),
refresh_repo: state.ctx.repos.refresh_repo.clone(),
refresh_ttl_seconds: state.ctx.config.refresh_ttl_seconds,
};
let cmd = commands::LoginCommand {
email: body.email,
password: body.password,
};
application::auth::login::execute(&deps, cmd)
.await
.map(|result| {
Json(LoginResponse {
token: result.access_token,
refresh_token: result.refresh_token,
user_id: result.user_id.value().to_string(),
expires_at: result.expires_at,
})
})
.map_err(map_error)
}
#[utoipa::path(post, path = "/api/auth/refresh", request_body = RefreshRequest, responses((status = 200, description = "Token refreshed", body = RefreshResponse)))]
pub async fn refresh(
State(state): State<AppState>,
Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, (StatusCode, Json<ErrorResponse>)> {
let deps = RefreshDeps {
auth_service: state.ctx.services.auth.clone(),
refresh_repo: state.ctx.repos.refresh_repo.clone(),
refresh_ttl_seconds: state.ctx.config.refresh_ttl_seconds,
};
let cmd = commands::RefreshCommand {
refresh_token: body.refresh_token,
};
application::auth::refresh::execute(&deps, cmd)
.await
.map(|result| {
Json(RefreshResponse {
token: result.access_token,
refresh_token: result.refresh_token,
expires_at: result.expires_at,
})
})
.map_err(map_error)
}
#[utoipa::path(post, path = "/api/auth/logout", request_body = LogoutRequest, responses((status = 204, description = "Logged out")))]
pub async fn logout(
State(state): State<AppState>,
Json(body): Json<LogoutRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let deps = LogoutDeps {
refresh_repo: state.ctx.repos.refresh_repo.clone(),
};
let cmd = commands::LogoutCommand {
refresh_token: body.refresh_token,
};
application::auth::logout::execute(&deps, cmd)
.await
.map(|()| StatusCode::NO_CONTENT)
.map_err(map_error)
}

View File

@@ -1,2 +1,64 @@
pub mod auth;
pub mod songs;
pub mod tabs;
use axum::{
Router,
http::HeaderValue,
routing::{get, post},
};
use infra_wiring::CorsOrigins;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use crate::state::AppState;
pub fn build_router(state: AppState) -> Router<()> {
let api = Router::new()
.route("/tabs/parse", post(tabs::parse_tab))
.route("/songs", post(songs::create_song).get(songs::list_songs))
.route(
"/songs/{id}",
get(songs::get_song)
.delete(songs::delete_song)
.patch(songs::update_song),
)
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/refresh", post(auth::refresh))
.route("/auth/logout", post(auth::logout));
let cors = build_cors(&state.ctx.config.cors_origins);
let spa_dir = &state.ctx.config.spa_dir;
let spa_index = format!("{}/index.html", spa_dir);
let spa_service = ServeDir::new(spa_dir).fallback(ServeFile::new(spa_index));
Router::new()
.nest("/api", api)
.fallback_service(spa_service)
.layer(cors)
.with_state(state)
}
fn build_cors(origins: &CorsOrigins) -> CorsLayer {
match origins {
CorsOrigins::Any => CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
CorsOrigins::List(origins) => {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| {
o.parse()
.unwrap_or_else(|_| panic!("invalid CORS origin: {o}"))
})
.collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods(Any)
.allow_headers(Any)
}
}
}

View File

@@ -1,54 +1,51 @@
use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest};
use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand};
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
use application::songs::queries::{ListSongsQuery, SearchSongsQuery};
use application::tabs::commands::ParseTabCommand;
use application::tabs::deps::ParseTabDeps;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use domain::{ChordTransposer, DomainError, SortField, SortOrder};
use std::sync::Arc;
use domain::{ChordTransposer, SortField, SortOrder};
use uuid::Uuid;
use crate::routes::tabs::AppState;
use crate::errors::map_error;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
#[utoipa::path(post, path = "/api/songs", request_body = ParseRequest, responses((status = 200, description = "Song created"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))]
pub async fn create_song(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
_user: AuthenticatedUser,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
let tab_deps = ParseTabDeps {
fetcher: state.ctx.services.tab_fetcher.clone(),
parser: state.ctx.services.tab_parser.clone(),
};
let cmd = ParseTabCommand {
source: body.source,
html: body.html,
};
let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd)
let song = application::tabs::parse_tab::execute(&tab_deps, cmd)
.await
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: e.to_string(),
}),
)
})?;
.map_err(map_error)?;
let cmd = SaveSongCommand { song };
application::songs::save_song::execute(&state.song_commands, cmd)
let deps = SongCommandDeps {
repo: state.ctx.repos.song_repo.clone(),
};
application::songs::save_song::execute(&deps, SaveSongCommand { song })
.await
.map(Json)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
)
})
.map_err(map_error)
}
#[utoipa::path(get, path = "/api/songs", params(ListQuery), responses((status = 200, description = "List songs")))]
pub async fn list_songs(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
let sort = match params.sort.as_deref() {
@@ -61,30 +58,32 @@ pub async fn list_songs(
_ => SortOrder::Desc,
};
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
let query = SearchSongsQuery {
query: q,
sort,
order,
};
application::songs::search_songs::execute(&state.song_queries, query).await
} else {
let query = ListSongsQuery { sort, order };
application::songs::list_songs::execute(&state.song_queries, query).await
let deps = SongQueryDeps {
repo: state.ctx.repos.song_repo.clone(),
search: state.ctx.repos.song_search.clone(),
};
result.map(Json).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
application::songs::search_songs::execute(
&deps,
SearchSongsQuery {
query: q,
sort,
order,
},
)
})
.await
} else {
application::songs::list_songs::execute(&deps, ListSongsQuery { sort, order }).await
};
result.map(Json).map_err(map_error)
}
#[utoipa::path(patch, path = "/api/songs/{id}", request_body = UpdateSongRequest, responses((status = 200, description = "Song updated"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))]
pub async fn update_song(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
@@ -97,6 +96,9 @@ pub async fn update_song(
)
})?;
let deps = SongCommandDeps {
repo: state.ctx.repos.song_repo.clone(),
};
let cmd = UpdateSongMetaCommand {
id: uuid,
title: body.title,
@@ -104,27 +106,15 @@ pub async fn update_song(
original_key: body.original_key,
};
application::songs::update_meta::execute(&state.song_commands, cmd)
application::songs::update_meta::execute(&deps, cmd)
.await
.map(Json)
.map_err(|e| match e {
DomainError::NotFound => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Not found".into(),
}),
),
e => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
),
})
.map_err(map_error)
}
#[utoipa::path(get, path = "/api/songs/{id}", params(GetSongQuery), responses((status = 200, description = "Song details"), (status = 404, description = "Not found")))]
pub async fn get_song(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
Path(id): Path<String>,
Query(params): Query<GetSongQuery>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
@@ -137,8 +127,12 @@ pub async fn get_song(
)
})?;
let deps = SongQueryDeps {
repo: state.ctx.repos.song_repo.clone(),
search: state.ctx.repos.song_search.clone(),
};
let query = application::songs::queries::GetSongQuery { id: uuid };
let song = match application::songs::get_song::execute(&state.song_queries, query).await {
let song = match application::songs::get_song::execute(&deps, query).await {
Ok(Some(s)) => s,
Ok(None) => {
return Err((
@@ -148,14 +142,7 @@ pub async fn get_song(
}),
));
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
));
}
Err(e) => return Err(map_error(e)),
};
let song = if params.apply_capo.unwrap_or(false) {
@@ -171,8 +158,10 @@ pub async fn get_song(
Ok(Json(song))
}
#[utoipa::path(delete, path = "/api/songs/{id}", responses((status = 204, description = "Song deleted"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))]
pub async fn delete_song(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let uuid = Uuid::parse_str(&id).map_err(|_| {
@@ -184,20 +173,11 @@ pub async fn delete_song(
)
})?;
let cmd = DeleteSongCommand { id: uuid };
match application::songs::delete_song::execute(&state.song_commands, cmd).await {
Ok(()) => Ok(StatusCode::NO_CONTENT),
Err(DomainError::NotFound) => Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Not found".into(),
}),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
)),
}
let deps = SongCommandDeps {
repo: state.ctx.repos.song_repo.clone(),
};
application::songs::delete_song::execute(&deps, DeleteSongCommand { id: uuid })
.await
.map(|()| StatusCode::NO_CONTENT)
.map_err(map_error)
}

View File

@@ -1,34 +1,27 @@
use api_types::{ErrorResponse, ParseRequest};
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
use application::tabs::commands::ParseTabCommand;
use application::tabs::deps::ParseTabDeps;
use axum::{Json, extract::State, http::StatusCode};
use std::sync::Arc;
pub struct AppState {
pub song_commands: SongCommandDeps,
pub song_queries: SongQueryDeps,
pub tab_parser: ParseTabDeps,
}
use crate::errors::map_error;
use crate::state::AppState;
#[utoipa::path(post, path = "/api/tabs/parse", request_body = ParseRequest, responses((status = 200, description = "Parsed song")))]
pub async fn parse_tab(
State(state): State<Arc<AppState>>,
State(state): State<AppState>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
let deps = ParseTabDeps {
fetcher: state.ctx.services.tab_fetcher.clone(),
parser: state.ctx.services.tab_parser.clone(),
};
let cmd = ParseTabCommand {
source: body.source,
html: body.html,
};
application::tabs::parse_tab::execute(&state.tab_parser, cmd)
application::tabs::parse_tab::execute(&deps, cmd)
.await
.map(Json)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: e.to_string(),
}),
)
})
.map_err(map_error)
}

View File

@@ -0,0 +1,6 @@
use crate::context::AppContext;
#[derive(Clone)]
pub struct AppState {
pub ctx: AppContext,
}