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:
30
crates/presentation/src/context.rs
Normal file
30
crates/presentation/src/context.rs
Normal 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,
|
||||
}
|
||||
20
crates/presentation/src/errors.rs
Normal file
20
crates/presentation/src/errors.rs
Normal 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 }))
|
||||
}
|
||||
40
crates/presentation/src/extractors.rs
Normal file
40
crates/presentation/src/extractors.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
36
crates/presentation/src/factory.rs
Normal file
36
crates/presentation/src/factory.rs
Normal 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 }
|
||||
}
|
||||
7
crates/presentation/src/lib.rs
Normal file
7
crates/presentation/src/lib.rs
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
62
crates/presentation/src/openapi.rs
Normal file
62
crates/presentation/src/openapi.rs
Normal 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()))
|
||||
}
|
||||
106
crates/presentation/src/routes/auth.rs
Normal file
106
crates/presentation/src/routes/auth.rs
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
6
crates/presentation/src/state.rs
Normal file
6
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use crate::context::AppContext;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub ctx: AppContext,
|
||||
}
|
||||
Reference in New Issue
Block a user