remove OIDC/Postgres, replace ApiError w/ AppError, move params to api-types

This commit is contained in:
2026-07-12 05:14:17 +02:00
parent c0e685a4ee
commit 9b18d3ff6d
42 changed files with 243 additions and 3367 deletions

View File

@@ -10,9 +10,7 @@ path = "src/main.rs"
[features]
default = ["sqlite", "auth-jwt", "jellyfin"]
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
postgres = ["dep:adapter-postgres", "infra-wiring/postgres"]
auth-jwt = ["adapter-auth/jwt"]
auth-oidc = ["adapter-auth/oidc"]
jellyfin = ["dep:adapter-jellyfin"]
local-files = ["dep:adapter-local-files", "dep:tokio-util"]
@@ -26,7 +24,6 @@ adapter-event-publisher = { workspace = true }
# Feature-gated adapters
adapter-sqlite = { workspace = true, optional = true }
adapter-postgres = { workspace = true, optional = true }
adapter-jellyfin = { workspace = true, optional = true }
adapter-local-files = { workspace = true, optional = true }

View File

@@ -1,144 +1,47 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use thiserror::Error;
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use domain::DomainError;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
pub struct AppError(pub DomainError);
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::UserNotFound(_)
| DomainError::ChannelNotFound(_)
| DomainError::NoActiveSchedule(_) => StatusCode::NOT_FOUND,
let status = match &self.0 {
DomainError::UserNotFound(_)
| DomainError::ChannelNotFound(_)
| DomainError::NoActiveSchedule(_)
| DomainError::NotFound(_) => StatusCode::NOT_FOUND,
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
DomainError::UserAlreadyExists(_) | DomainError::Conflict(_) => StatusCode::CONFLICT,
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
StatusCode::BAD_REQUEST
}
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
StatusCode::BAD_REQUEST
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
ApiError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
ApiError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
ErrorResponse {
error: "Forbidden".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "Unauthorized".to_string(),
details: Some(msg.clone()),
},
),
ApiError::NotFound(msg) => (
StatusCode::NOT_FOUND,
ErrorResponse {
error: "Not found".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Conflict(msg) => (
StatusCode::CONFLICT,
ErrorResponse {
error: "Conflict".to_string(),
details: Some(msg.clone()),
},
),
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(error_response)).into_response()
let body = api_types::ErrorResponse::new(self.0.to_string());
(status, Json(body)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::NotFound(msg.into())
}
pub fn conflict(msg: impl Into<String>) -> Self {
Self::Conflict(msg.into())
impl From<DomainError> for AppError {
fn from(e: DomainError) -> Self {
Self(e)
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
Self(DomainError::ValidationError(e.to_string()))
}
}

View File

@@ -1,14 +1,14 @@
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use domain::User;
use domain::{DomainError, User};
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::state::AppState;
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = ApiError;
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
@@ -25,9 +25,9 @@ impl FromRequestParts<AppState> for CurrentUser {
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (parts, state);
Err(ApiError::Unauthorized(
Err(AppError(DomainError::Unauthenticated(
"No authentication backend configured".to_string(),
))
)))
}
}
}
@@ -35,7 +35,7 @@ impl FromRequestParts<AppState> for CurrentUser {
pub struct OptionalCurrentUser(pub Option<User>);
impl FromRequestParts<AppState> for OptionalCurrentUser {
type Rejection = ApiError;
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
@@ -69,7 +69,7 @@ impl FromRequestParts<AppState> for OptionalCurrentUser {
pub struct AdminUser(pub User);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = ApiError;
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
@@ -77,64 +77,64 @@ impl FromRequestParts<AppState> for AdminUser {
) -> Result<Self, Self::Rejection> {
let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?;
if !user.is_admin() {
return Err(ApiError::Forbidden("Admin access required".to_string()));
return Err(AppError(DomainError::Forbidden("Admin access required".to_string())));
}
Ok(AdminUser(user))
}
}
#[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, AppError> {
use axum::http::header::AUTHORIZATION;
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
.ok_or_else(|| AppError(DomainError::Unauthenticated("Missing Authorization header".to_string())))?;
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid Authorization header encoding".to_string())))?;
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
AppError(DomainError::Unauthenticated("Authorization header must use Bearer scheme".to_string()))
})?;
validate_jwt_token(token, state).await
}
#[cfg(feature = "auth-jwt")]
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, ApiError> {
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, AppError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT validator not configured".to_string())))?;
let claims = validator.validate_access_token(token).map_err(|e| {
tracing::debug!("JWT validation failed: {:?}", e);
match e {
adapter_auth::JwtError::Expired => {
ApiError::Unauthorized("Token expired".to_string())
AppError(DomainError::Unauthenticated("Token expired".to_string()))
}
adapter_auth::JwtError::InvalidFormat => {
ApiError::Unauthorized("Invalid token format".to_string())
AppError(DomainError::Unauthenticated("Invalid token format".to_string()))
}
_ => ApiError::Unauthorized("Token validation failed".to_string()),
_ => AppError(DomainError::Unauthenticated("Token validation failed".to_string())),
}
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
let user = state
.auth_deps
.user_query
.find_by_id(domain::UserId::from(user_id))
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
Ok(user)
}

View File

@@ -192,25 +192,6 @@ fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
provider_config_query: w.provider_config_query,
})
}
#[cfg(feature = "postgres")]
DbPool::Postgres(pg_pool) => {
let w = adapter_postgres::wire(pg_pool.clone());
Ok(WireOutput {
user_command: w.user_command,
user_query: w.user_query,
channel_command: w.channel_command,
channel_query: w.channel_query,
schedule_command: w.schedule_command,
schedule_query: w.schedule_query,
library_command: w.library_command,
library_query: w.library_query,
activity_query: w.activity_query,
settings: w.settings,
provider_config_command: w.provider_config_command,
provider_config_query: w.provider_config_query,
})
}
_ => anyhow::bail!("database backend not compiled into this binary"),
}
}

View File

@@ -1,12 +1,11 @@
use axum::Json;
use axum::extract::{Query, State};
use serde::Deserialize;
use std::collections::HashMap;
use api_types::{ActivityEventResponse, SettingsResponse};
use api_types::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::AdminUser;
use crate::state::AppState;
@@ -15,7 +14,7 @@ const DEFAULT_ACTIVITY_LIMIT: u32 = 50;
pub async fn get_settings(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
) -> Result<Json<SettingsResponse>, ApiError> {
) -> Result<Json<SettingsResponse>, AppError> {
let pairs =
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
let settings: HashMap<String, String> = pairs.into_iter().collect();
@@ -26,7 +25,7 @@ pub async fn update_settings(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Json(body): Json<HashMap<String, String>>,
) -> Result<Json<SettingsResponse>, ApiError> {
) -> Result<Json<SettingsResponse>, AppError> {
let settings_vec: Vec<(String, String)> = body.into_iter().collect();
let cmd = UpdateSettingsCommand {
settings: settings_vec,
@@ -39,16 +38,11 @@ pub async fn update_settings(
Ok(Json(SettingsResponse { settings }))
}
#[derive(Debug, Deserialize)]
pub struct ActivityLogParams {
pub limit: Option<u32>,
}
pub async fn get_activity_log(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Query(params): Query<ActivityLogParams>,
) -> Result<Json<Vec<ActivityEventResponse>>, ApiError> {
) -> Result<Json<Vec<ActivityEventResponse>>, AppError> {
let query = GetActivityLogQuery {
limit: params.limit.unwrap_or(DEFAULT_ACTIVITY_LIMIT),
};

View File

@@ -3,8 +3,9 @@ use axum::extract::State;
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
use application::auth::{LoginCommand, RegisterCommand};
use domain::DomainError;
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::CurrentUser;
use crate::state::AppState;
@@ -14,7 +15,7 @@ const SECS_PER_HOUR: u64 = 3600;
pub async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<UserResponse>, ApiError> {
) -> Result<Json<UserResponse>, AppError> {
let cmd = RegisterCommand {
email: req.email,
password: req.password,
@@ -26,7 +27,7 @@ pub async fn register(
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, ApiError> {
) -> Result<Json<TokenResponse>, AppError> {
let cmd = LoginCommand {
email: req.email,
password: req.password,
@@ -41,13 +42,13 @@ pub async fn login(
}))
}
pub async fn logout() -> Result<Json<serde_json::Value>, ApiError> {
pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({"message": "logged out"})))
}
pub async fn me(
CurrentUser(user): CurrentUser,
) -> Result<Json<UserResponse>, ApiError> {
) -> Result<Json<UserResponse>, AppError> {
Ok(Json(UserResponse::from(user)))
}
@@ -55,7 +56,7 @@ pub async fn me(
pub async fn get_token(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, ApiError> {
) -> Result<Json<TokenResponse>, AppError> {
let cmd = LoginCommand {
email: req.email,
password: req.password,
@@ -74,29 +75,29 @@ pub async fn get_token(
pub async fn refresh_token(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<TokenResponse>, ApiError> {
) -> Result<Json<TokenResponse>, AppError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
tracing::debug!("Refresh token validation failed: {:?}", e);
ApiError::Unauthorized("Invalid refresh token".to_string())
AppError(DomainError::Unauthenticated("Invalid refresh token".to_string()))
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
let user = state
.auth_deps
.user_query
.find_by_id(domain::UserId::from(user_id))
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
Ok(Json(TokenResponse {
@@ -111,23 +112,23 @@ fn create_tokens(
user: &domain::User,
state: &AppState,
remember_me: bool,
) -> Result<(String, Option<String>), ApiError> {
) -> Result<(String, Option<String>), AppError> {
#[cfg(feature = "auth-jwt")]
{
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let access = validator
.create_token(user)
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))?;
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create token: {}", e))))?;
let refresh = if remember_me {
Some(
validator
.create_refresh_token(user)
.map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e)))?,
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create refresh token: {}", e))))?,
)
} else {
None
@@ -139,6 +140,6 @@ fn create_tokens(
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (user, state, remember_me);
Err(ApiError::Internal("JWT feature not enabled".to_string()))
Err(AppError(DomainError::InfrastructureError("JWT feature not enabled".to_string())))
}
}

View File

@@ -13,15 +13,16 @@ use application::config_snapshots::{
GetSnapshotQuery, ListSnapshotsQuery, PatchLabelCommand, RestoreSnapshotCommand,
SaveSnapshotCommand,
};
use domain::DomainError;
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::CurrentUser;
use crate::state::AppState;
pub async fn list_channels(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
) -> Result<Json<Vec<ChannelResponse>>, AppError> {
let channels =
application::channels::list::execute(&state.channel_query_deps, ListChannelsQuery).await?;
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
@@ -30,7 +31,7 @@ pub async fn list_channels(
pub async fn list_my_channels(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
) -> Result<Json<Vec<ChannelResponse>>, AppError> {
let query = ListByOwnerQuery {
owner_id: user.id(),
};
@@ -43,7 +44,7 @@ pub async fn create_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(req): Json<CreateChannelRequest>,
) -> Result<Json<ChannelResponse>, ApiError> {
) -> Result<Json<ChannelResponse>, AppError> {
let cmd = CreateChannelCommand {
owner_id: user.id(),
name: req.name,
@@ -57,13 +58,13 @@ pub async fn get_channel(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ChannelResponse>, ApiError> {
) -> Result<Json<ChannelResponse>, AppError> {
let query = GetChannelQuery {
channel_id: id.into(),
};
let channel = application::channels::get::execute(&state.channel_query_deps, query)
.await?
.ok_or_else(|| ApiError::not_found(format!("Channel {id} not found")))?;
.ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?;
Ok(Json(ChannelResponse::from(channel)))
}
@@ -72,12 +73,12 @@ pub async fn update_channel(
CurrentUser(user): CurrentUser,
Path(id): Path<uuid::Uuid>,
Json(req): Json<UpdateChannelRequest>,
) -> Result<Json<ChannelResponse>, ApiError> {
) -> Result<Json<ChannelResponse>, AppError> {
let schedule_config = req
.schedule_config
.map(|v| {
serde_json::from_value(v)
.map_err(|e| ApiError::validation(format!("Invalid schedule_config: {e}")))
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid schedule_config: {e}"))))
})
.transpose()?;
@@ -85,7 +86,7 @@ pub async fn update_channel(
.recycle_policy
.map(|v| {
serde_json::from_value(v)
.map_err(|e| ApiError::validation(format!("Invalid recycle_policy: {e}")))
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid recycle_policy: {e}"))))
})
.transpose()?;
@@ -107,7 +108,7 @@ pub async fn delete_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::http::StatusCode, ApiError> {
) -> Result<axum::http::StatusCode, AppError> {
let cmd = DeleteChannelCommand {
channel_id: id.into(),
owner_id: user.id(),
@@ -120,7 +121,7 @@ pub async fn save_snapshot(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
let cmd = SaveSnapshotCommand {
channel_id: id.into(),
label: None,
@@ -133,7 +134,7 @@ pub async fn list_snapshots(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<Vec<ConfigSnapshotResponse>>, ApiError> {
) -> Result<Json<Vec<ConfigSnapshotResponse>>, AppError> {
let query = ListSnapshotsQuery {
channel_id: id.into(),
};
@@ -145,14 +146,14 @@ pub async fn get_snapshot(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
let query = GetSnapshotQuery {
channel_id: id.into(),
snapshot_id: snapshot_id.into(),
};
let snap = application::config_snapshots::get::execute(&state.config_snapshot_deps, query)
.await?
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
.ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?;
Ok(Json(ConfigSnapshotResponse::from(snap)))
}
@@ -161,7 +162,7 @@ pub async fn patch_snapshot(
CurrentUser(_user): CurrentUser,
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
Json(req): Json<PatchSnapshotRequest>,
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
let cmd = PatchLabelCommand {
channel_id: id.into(),
snapshot_id: snapshot_id.into(),
@@ -170,7 +171,7 @@ pub async fn patch_snapshot(
let snap =
application::config_snapshots::patch_label::execute(&state.config_snapshot_deps, cmd)
.await?
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
.ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?;
Ok(Json(ConfigSnapshotResponse::from(snap)))
}
@@ -178,7 +179,7 @@ pub async fn restore_snapshot(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
) -> Result<Json<ChannelResponse>, ApiError> {
) -> Result<Json<ChannelResponse>, AppError> {
let cmd = RestoreSnapshotCommand {
channel_id: id.into(),
snapshot_id: snapshot_id.into(),

View File

@@ -3,14 +3,14 @@ use axum::extract::State;
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::state::AppState;
const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file";
pub async fn get_config(
State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, ApiError> {
) -> Result<Json<ConfigResponse>, AppError> {
let registry = &state.provider_registry;
let provider_ids = registry.provider_ids();
let primary_id = registry.primary_id().to_string();

View File

@@ -2,22 +2,24 @@ use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use crate::errors::ApiError;
use domain::DomainError;
use crate::errors::AppError;
use crate::state::AppState;
pub async fn stream_file(
State(_state): State<AppState>,
Path(_id): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
Err::<StatusCode, _>(ApiError::NotFound(
) -> Result<impl IntoResponse, AppError> {
Err::<StatusCode, _>(AppError(DomainError::NotFound(
"Local file streaming not yet wired in presentation crate".to_string(),
))
)))
}
pub async fn rescan(
State(_state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
Err::<StatusCode, _>(ApiError::NotFound(
) -> Result<impl IntoResponse, AppError> {
Err::<StatusCode, _>(AppError(DomainError::NotFound(
"Local file rescan not yet wired in presentation crate".to_string(),
))
)))
}

View File

@@ -1,27 +1,22 @@
use axum::extract::{Query, State};
use axum::http::header;
use axum::response::IntoResponse;
use serde::Deserialize;
use api_types::IptvParams;
use application::iptv::{GetM3uQuery, GetXmltvQuery};
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::OptionalCurrentUser;
use crate::state::AppState;
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8";
const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
#[derive(Debug, Deserialize)]
pub struct IptvParams {
pub token: Option<String>,
}
pub async fn m3u_playlist(
State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser,
Query(params): Query<IptvParams>,
) -> Result<impl IntoResponse, ApiError> {
) -> Result<impl IntoResponse, AppError> {
let query = GetM3uQuery {
base_url: state.config.base_url.clone(),
token: params.token,
@@ -33,7 +28,7 @@ pub async fn m3u_playlist(
pub async fn xmltv_epg(
State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser,
) -> Result<impl IntoResponse, ApiError> {
) -> Result<impl IntoResponse, AppError> {
let content = application::iptv::xmltv::execute(&state.iptv_deps, GetXmltvQuery).await?;
Ok(([(header::CONTENT_TYPE, XML_CONTENT_TYPE)], content))
}

View File

@@ -1,40 +1,28 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use api_types::{CollectionResponse, LibraryItemResponse, PaginatedResponse, SeasonResponse, ShowResponse};
use api_types::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
};
use application::library::{
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
ListShowsQuery, SearchItemsQuery, TriggerSyncCommand,
};
use domain::DomainError;
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::{AdminUser, CurrentUser};
use crate::state::AppState;
const DEFAULT_SEARCH_LIMIT: u32 = 50;
#[derive(Debug, Deserialize)]
pub struct LibrarySearchParams {
pub provider: Option<String>,
pub content_type: Option<String>,
#[serde(default, rename = "genres[]")]
pub genres: Vec<String>,
pub search_term: Option<String>,
pub collection_id: Option<String>,
#[serde(default, rename = "series_names[]")]
pub series_names: Vec<String>,
pub season_number: Option<u32>,
pub decade: Option<u16>,
pub offset: Option<u32>,
pub limit: Option<u32>,
}
pub async fn search_items(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<LibrarySearchParams>,
) -> Result<Json<PaginatedResponse<LibraryItemResponse>>, ApiError> {
) -> Result<Json<PaginatedResponse<LibraryItemResponse>>, AppError> {
let query = SearchItemsQuery {
provider_id: params.provider,
content_type: params.content_type,
@@ -58,11 +46,11 @@ pub async fn get_item(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<String>,
) -> Result<Json<LibraryItemResponse>, ApiError> {
) -> Result<Json<LibraryItemResponse>, AppError> {
let query = GetItemQuery { item_id: id.clone() };
let item = application::library::get_item::execute(&state.library_query_deps, query)
.await?
.ok_or_else(|| ApiError::not_found(format!("Library item {id} not found")))?;
.ok_or_else(|| AppError(DomainError::NotFound(format!("Library item {id} not found"))))?;
Ok(Json(LibraryItemResponse::from(item)))
}
@@ -70,7 +58,7 @@ pub async fn list_collections(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<ProviderParam>,
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
) -> Result<Json<Vec<CollectionResponse>>, AppError> {
let query = ListCollectionsQuery {
provider_id: params.provider,
};
@@ -84,24 +72,11 @@ pub async fn list_collections(
))
}
#[derive(Debug, Deserialize)]
pub struct ProviderParam {
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ShowsParams {
pub provider: Option<String>,
pub search_term: Option<String>,
#[serde(default, rename = "genres[]")]
pub genres: Vec<String>,
}
pub async fn list_shows(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<ShowsParams>,
) -> Result<Json<Vec<ShowResponse>>, ApiError> {
) -> Result<Json<Vec<ShowResponse>>, AppError> {
let query = ListShowsQuery {
provider_id: params.provider,
search_term: params.search_term,
@@ -111,17 +86,11 @@ pub async fn list_shows(
Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
}
#[derive(Debug, Deserialize)]
pub struct SeasonsParams {
pub series_name: String,
pub provider: Option<String>,
}
pub async fn list_seasons(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<SeasonsParams>,
) -> Result<Json<Vec<SeasonResponse>>, ApiError> {
) -> Result<Json<Vec<SeasonResponse>>, AppError> {
let query = ListSeasonsQuery {
series_name: params.series_name,
provider_id: params.provider,
@@ -133,17 +102,11 @@ pub async fn list_seasons(
))
}
#[derive(Debug, Deserialize)]
pub struct GenresParams {
pub content_type: Option<String>,
pub provider: Option<String>,
}
pub async fn list_genres(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<GenresParams>,
) -> Result<Json<Vec<String>>, ApiError> {
) -> Result<Json<Vec<String>>, AppError> {
let query = ListGenresQuery {
content_type: params.content_type,
provider_id: params.provider,
@@ -166,7 +129,7 @@ pub(crate) struct SyncStatusEntry {
pub async fn sync_status(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<Vec<SyncStatusEntry>>, ApiError> {
) -> Result<Json<Vec<SyncStatusEntry>>, AppError> {
let entries =
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
.await?;
@@ -187,15 +150,15 @@ pub async fn sync_status(
pub async fn trigger_sync(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
) -> Result<axum::http::StatusCode, ApiError> {
) -> Result<axum::http::StatusCode, AppError> {
let cmd = TriggerSyncCommand { provider_id: None };
application::library::sync::execute(&state.library_command_deps, cmd)
.await
.map_err(|e| {
if e.to_string().contains("already running") {
ApiError::conflict(e.to_string())
AppError(DomainError::Conflict(e.to_string()))
} else {
ApiError::from(e)
AppError::from(e)
}
})?;

View File

@@ -5,15 +5,16 @@ use api_types::{ProviderConfigRequest, ProviderConfigResponse};
use application::providers::{
DeleteProviderCommand, GetProviderQuery, ListProvidersQuery, UpsertProviderCommand,
};
use domain::DomainError;
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::AdminUser;
use crate::state::AppState;
pub async fn list_providers(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
) -> Result<Json<Vec<ProviderConfigResponse>>, ApiError> {
) -> Result<Json<Vec<ProviderConfigResponse>>, AppError> {
let providers =
application::providers::list::execute(&state.provider_deps, ListProvidersQuery).await?;
Ok(Json(
@@ -28,11 +29,11 @@ pub async fn get_provider(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<ProviderConfigResponse>, ApiError> {
) -> Result<Json<ProviderConfigResponse>, AppError> {
let query = GetProviderQuery { id: id.clone() };
let provider = application::providers::get::execute(&state.provider_deps, query)
.await?
.ok_or_else(|| ApiError::not_found(format!("Provider {id} not found")))?;
.ok_or_else(|| AppError(DomainError::NotFound(format!("Provider {id} not found"))))?;
Ok(Json(ProviderConfigResponse::from(provider)))
}
@@ -41,9 +42,9 @@ pub async fn upsert_provider(
AdminUser(_user): AdminUser,
Path(id): Path<String>,
Json(req): Json<ProviderConfigRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<serde_json::Value>, AppError> {
let config_json = serde_json::to_string(&req.config)
.map_err(|e| ApiError::validation(format!("Invalid config JSON: {e}")))?;
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid config JSON: {e}"))))?;
let cmd = UpsertProviderCommand {
id,
@@ -59,7 +60,7 @@ pub async fn delete_provider(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<axum::http::StatusCode, ApiError> {
) -> Result<axum::http::StatusCode, AppError> {
let cmd = DeleteProviderCommand { id };
application::providers::delete::execute(&state.provider_deps, cmd).await?;
Ok(axum::http::StatusCode::NO_CONTENT)

View File

@@ -11,7 +11,7 @@ use application::schedule::{
GetStreamUrlQuery, ListHistoryQuery,
};
use crate::errors::ApiError;
use crate::errors::AppError;
use crate::extractors::CurrentUser;
use crate::state::AppState;
@@ -19,7 +19,7 @@ pub async fn generate_schedule(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ScheduleResponse>, ApiError> {
) -> Result<Json<ScheduleResponse>, AppError> {
let cmd = GenerateScheduleCommand { channel_id: id };
let schedule = application::schedule::generate::execute(&state.schedule_deps, cmd).await?;
Ok(Json(ScheduleResponse::from(schedule)))
@@ -29,7 +29,7 @@ pub async fn get_active_schedule(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, ApiError> {
) -> Result<axum::response::Response, AppError> {
let query = GetActiveScheduleQuery { channel_id: id };
match application::schedule::get_active::execute(&state.schedule_deps, query).await? {
Some(schedule) => Ok(Json(ScheduleResponse::from(schedule)).into_response()),
@@ -40,7 +40,7 @@ pub async fn get_active_schedule(
pub async fn get_current_broadcast(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, ApiError> {
) -> Result<axum::response::Response, AppError> {
let query = GetCurrentBroadcastQuery { channel_id: id };
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
{
@@ -69,7 +69,7 @@ pub async fn get_current_broadcast(
pub async fn get_epg(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<Vec<SlotResponse>>, ApiError> {
) -> Result<Json<Vec<SlotResponse>>, AppError> {
let query = GetEpgQuery { channel_id: id };
let slots = application::schedule::get_epg::execute(&state.schedule_deps, query).await?;
Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
@@ -78,7 +78,7 @@ pub async fn get_epg(
pub async fn get_stream(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, ApiError> {
) -> Result<axum::response::Response, AppError> {
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
let broadcast =
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
@@ -107,7 +107,7 @@ pub async fn list_schedule_history(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<Vec<ScheduleHistoryEntry>>, ApiError> {
) -> Result<Json<Vec<ScheduleHistoryEntry>>, AppError> {
let query = ListHistoryQuery { channel_id: id };
let history =
application::schedule::list_history::execute(&state.schedule_deps, query).await?;