diff --git a/octo-fiesta/Middleware/GlobalExceptionHandler.cs b/octo-fiesta/Middleware/GlobalExceptionHandler.cs new file mode 100644 index 0000000..3b01cce --- /dev/null +++ b/octo-fiesta/Middleware/GlobalExceptionHandler.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace octo_fiesta.Middleware; + +/// +/// Global exception handler that catches unhandled exceptions and returns appropriate Subsonic API error responses +/// +public class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Unhandled exception occurred: {Message}", exception.Message); + + var (statusCode, subsonicErrorCode, errorMessage) = MapExceptionToResponse(exception); + + httpContext.Response.StatusCode = statusCode; + httpContext.Response.ContentType = "application/json"; + + var response = CreateSubsonicErrorResponse(subsonicErrorCode, errorMessage); + await httpContext.Response.WriteAsJsonAsync(response, cancellationToken); + + return true; + } + + /// + /// Maps exception types to HTTP status codes and Subsonic error codes + /// + private (int statusCode, int subsonicErrorCode, string message) MapExceptionToResponse(Exception exception) + { + return exception switch + { + // Not Found errors (404) + FileNotFoundException => (404, 70, "Resource not found"), + DirectoryNotFoundException => (404, 70, "Directory not found"), + + // Authentication errors (401) + UnauthorizedAccessException => (401, 40, "Wrong username or password"), + + // Bad Request errors (400) + ArgumentNullException => (400, 10, "Required parameter is missing"), + ArgumentException => (400, 10, "Invalid request"), + FormatException => (400, 10, "Invalid format"), + InvalidOperationException => (400, 10, "Operation not valid"), + + // External service errors (502) + HttpRequestException => (502, 0, "External service unavailable"), + TimeoutException => (504, 0, "Request timeout"), + + // Generic server error (500) + _ => (500, 0, "An internal server error occurred") + }; + } + + /// + /// Creates a Subsonic-compatible error response + /// Subsonic error codes: + /// 0 = Generic error + /// 10 = Required parameter missing + /// 20 = Incompatible Subsonic REST protocol version + /// 30 = Incompatible Subsonic REST protocol version (server) + /// 40 = Wrong username or password + /// 50 = User not authorized + /// 60 = Trial period for the Subsonic server is over + /// 70 = Requested data was not found + /// + private object CreateSubsonicErrorResponse(int code, string message) + { + return new Dictionary + { + ["subsonic-response"] = new + { + status = "failed", + version = "1.16.1", + error = new { code, message } + } + }; + } +} diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index a6fc2d9..c19611a 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -4,6 +4,7 @@ using octo_fiesta.Services.Deezer; using octo_fiesta.Services.Qobuz; using octo_fiesta.Services.Local; using octo_fiesta.Services.Validation; +using octo_fiesta.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +15,10 @@ builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +// Exception handling +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + // Configuration builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); @@ -66,6 +71,8 @@ builder.Services.AddCors(options => var app = builder.Build(); // Configure the HTTP request pipeline. +app.UseExceptionHandler(_ => { }); // Global exception handler + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/octo-fiesta/Services/Common/Error.cs b/octo-fiesta/Services/Common/Error.cs new file mode 100644 index 0000000..aa89217 --- /dev/null +++ b/octo-fiesta/Services/Common/Error.cs @@ -0,0 +1,140 @@ +namespace octo_fiesta.Services.Common; + +/// +/// Represents a typed error with code, message, and metadata +/// +public class Error +{ + /// + /// Unique error code identifier + /// + public string Code { get; } + + /// + /// Human-readable error message + /// + public string Message { get; } + + /// + /// Error type/category + /// + public ErrorType Type { get; } + + /// + /// Additional metadata about the error + /// + public Dictionary? Metadata { get; } + + private Error(string code, string message, ErrorType type, Dictionary? metadata = null) + { + Code = code; + Message = message; + Type = type; + Metadata = metadata; + } + + /// + /// Creates a Not Found error (404) + /// + public static Error NotFound(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "NOT_FOUND", message, ErrorType.NotFound, metadata); + } + + /// + /// Creates a Validation error (400) + /// + public static Error Validation(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "VALIDATION_ERROR", message, ErrorType.Validation, metadata); + } + + /// + /// Creates an Unauthorized error (401) + /// + public static Error Unauthorized(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "UNAUTHORIZED", message, ErrorType.Unauthorized, metadata); + } + + /// + /// Creates a Forbidden error (403) + /// + public static Error Forbidden(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "FORBIDDEN", message, ErrorType.Forbidden, metadata); + } + + /// + /// Creates a Conflict error (409) + /// + public static Error Conflict(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "CONFLICT", message, ErrorType.Conflict, metadata); + } + + /// + /// Creates an Internal Server Error (500) + /// + public static Error Internal(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "INTERNAL_ERROR", message, ErrorType.Internal, metadata); + } + + /// + /// Creates an External Service Error (502/503) + /// + public static Error ExternalService(string message, string? code = null, Dictionary? metadata = null) + { + return new Error(code ?? "EXTERNAL_SERVICE_ERROR", message, ErrorType.ExternalService, metadata); + } + + /// + /// Creates a custom error with specified type + /// + public static Error Custom(string code, string message, ErrorType type, Dictionary? metadata = null) + { + return new Error(code, message, type, metadata); + } +} + +/// +/// Categorizes error types for appropriate HTTP status code mapping +/// +public enum ErrorType +{ + /// + /// Validation error (400 Bad Request) + /// + Validation, + + /// + /// Resource not found (404 Not Found) + /// + NotFound, + + /// + /// Authentication required (401 Unauthorized) + /// + Unauthorized, + + /// + /// Insufficient permissions (403 Forbidden) + /// + Forbidden, + + /// + /// Resource conflict (409 Conflict) + /// + Conflict, + + /// + /// Internal server error (500 Internal Server Error) + /// + Internal, + + /// + /// External service error (502 Bad Gateway / 503 Service Unavailable) + /// + ExternalService +} diff --git a/octo-fiesta/Services/Common/Result.cs b/octo-fiesta/Services/Common/Result.cs new file mode 100644 index 0000000..178f758 --- /dev/null +++ b/octo-fiesta/Services/Common/Result.cs @@ -0,0 +1,99 @@ +namespace octo_fiesta.Services.Common; + +/// +/// Represents the result of an operation that can either succeed with a value or fail with an error. +/// This pattern allows explicit error handling without using exceptions for control flow. +/// +/// The type of the value returned on success +public class Result +{ + /// + /// Indicates whether the operation succeeded + /// + public bool IsSuccess { get; } + + /// + /// Indicates whether the operation failed + /// + public bool IsFailure => !IsSuccess; + + /// + /// The value returned on success (null if failed) + /// + public T? Value { get; } + + /// + /// The error that occurred on failure (null if succeeded) + /// + public Error? Error { get; } + + private Result(bool isSuccess, T? value, Error? error) + { + IsSuccess = isSuccess; + Value = value; + Error = error; + } + + /// + /// Creates a successful result with a value + /// + public static Result Success(T value) + { + return new Result(true, value, null); + } + + /// + /// Creates a failed result with an error + /// + public static Result Failure(Error error) + { + return new Result(false, default, error); + } + + /// + /// Implicit conversion from T to Result<T> for convenience + /// + public static implicit operator Result(T value) + { + return Success(value); + } + + /// + /// Implicit conversion from Error to Result<T> for convenience + /// + public static implicit operator Result(Error error) + { + return Failure(error); + } +} + +/// +/// Non-generic Result for operations that don't return a value +/// +public class Result +{ + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public Error? Error { get; } + + private Result(bool isSuccess, Error? error) + { + IsSuccess = isSuccess; + Error = error; + } + + public static Result Success() + { + return new Result(true, null); + } + + public static Result Failure(Error error) + { + return new Result(false, error); + } + + public static implicit operator Result(Error error) + { + return Failure(error); + } +}