From 695f4378c6d9a60ddd3eaac62652f8ff3a29c147 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 21:04:15 +0200 Subject: [PATCH] refactor(api): OpenAPI response metadata, cached user sync, ProblemDetails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document every endpoint's responses (Produces/status codes/validation) so the generated OpenAPI/Scalar docs carry response schemas and status codes instead of empty responses - Cache recently-synced user ids (IMemoryCache, 15 min) in the JWT TokenValidated handler so user provisioning no longer hits the DB on every authenticated request - Add ProblemDetails + UseExceptionHandler for consistent RFC7807 errors - Add ToCreatedResult ErrorOr helper and use it in the create endpoints - Normalize the playlists route to lowercase (/playlists) Verified: booted the Api against RabbitMQ + Postgres in Docker and inspected /openapi/v1.json — operations now expose response schemas/status codes and the route is /playlists. Build: 0 warnings, 0 errors. --- .../AuthenticationExtensions.cs | 1 + .../Authentication/JwtBearerEventsHandler.cs | 14 ++++- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 58 ++++++++++++++----- .../Musify.Api/Endpoints/TrackEndpoints.cs | 42 ++++++++++---- backend/Musify.Api/Endpoints/UserEndpoints.cs | 19 +++--- .../Extensions/ErrorOrHttpExtensions.cs | 5 ++ backend/Musify.Api/Program.cs | 4 ++ 7 files changed, 109 insertions(+), 34 deletions(-) diff --git a/backend/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Musify.Api/Authentication/AuthenticationExtensions.cs index f3094c3..2f8416b 100644 --- a/backend/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -17,6 +17,7 @@ public static class AuthenticationExtensions services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService>().Value); + services.AddMemoryCache(); services.AddScoped(); services.ConfigureOptions(); diff --git a/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs index d10f4fb..02c091c 100644 --- a/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -1,12 +1,18 @@ using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Caching.Memory; using Musify.Application.Users; using System.Security.Claims; namespace Musify.Api.Authentication; -public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger logger) : JwtBearerEvents +public sealed class JwtBearerEventsHandler( + IMediator mediator, + IMemoryCache cache, + ILogger logger) : JwtBearerEvents { + private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); + public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) { var principal = tokenValidatedContext.Principal!; @@ -18,6 +24,10 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger>(); group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") - .WithSummary("Get A PlayList By Id."); + .WithSummary("Get A PlayList By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get Paginated PlayLists For A User."); + .WithSummary("Get Paginated PlayLists For A User.") + .Produces>(); group.MapGet("/{playlistId}/tracks", GetPlayListTracks) .WithName("GetPlayListTracks") - .WithSummary("Get Paginated Tracks Of A PlayList."); + .WithSummary("Get Paginated Tracks Of A PlayList.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create A New PlayList.") .AddEndpointFilter>() - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); group.MapPut("/{playlistId}", UpdatePlayList) .WithName("UpdatePlayList") .WithSummary("Update An Existing PlayList.") .AddEndpointFilter>() - .RequireAuthorization(); + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); group.MapDelete("/{playlistId}", DeletePlayList) .WithName("DeletePlayList") .WithSummary("Delete A PlayList.") - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); group.MapPost("/upload-picture", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .AddEndpointFilter>() - .RequireAuthorization(); + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) .WithName("AddTrackToPlayList") .WithSummary("Add A Track To A PlayList.") - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) .WithName("RemoveTrackFromPlayList") .WithSummary("Remove A Track From A PlayList.") - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); return app; } @@ -118,10 +149,7 @@ public static class PlayListEndpoints new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), cancellationToken); - if (result.IsError) - return result.ToHttpResult(); - - return Results.Created($"/playlists/{result.Value.Id}", result.Value); + return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); } private static async Task UpdatePlayList( diff --git a/backend/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Musify.Api/Endpoints/TrackEndpoints.cs index 4e39c11..5ed80d3 100644 --- a/backend/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,5 +1,7 @@ using Mediator; using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; +using Musify.Application.Shared; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -16,37 +18,58 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks."); + .WithSummary("Get Paginated Tracks.") + .Produces>(); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") - .WithSummary("Get A Track By Id."); + .WithSummary("Get A Track By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); group.MapGet("/{id}/stream", GetTrackStream) .WithName("GetTrackStream") .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") - .RequireAuthorization(); + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") - .WithSummary("Get Paginated Tracks For A User."); + .WithSummary("Get Paginated Tracks For A User.") + .Produces>(); group.MapPost("/", CreateTrack) .WithName("CreateTrack") .WithSummary("Create A New Track.") .AddEndpointFilter>() - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); group.MapDelete("/{trackId}", DeleteTrack) .WithName("DeleteTrack") .WithSummary("Delete A Track.") - .RequireAuthorization(); + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") .AddEndpointFilter>() - .RequireAuthorization(); + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); return app; } @@ -102,10 +125,7 @@ public static class TrackEndpoints new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), cancellationToken); - if (result.IsError) - return result.ToHttpResult(); - - return Results.Created($"/tracks/{result.Value.Id}", result.Value); + return result.ToCreatedResult(track => $"/tracks/{track.Id}"); } private static async Task DeleteTrack( diff --git a/backend/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Musify.Api/Endpoints/UserEndpoints.cs index 9ba490a..7e47543 100644 --- a/backend/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,5 +1,7 @@ using Mediator; using Musify.Application.Users; +using Musify.Application.Users.Responses; +using Musify.Application.Shared; using Musify.Api.DataTransferObjects.Users; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -15,16 +17,22 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .WithName("GetUsers") - .WithSummary("Get Paginated Users."); + .WithSummary("Get Paginated Users.") + .Produces>(); group.MapGet("/{id}", GetUserById) .WithName("GetUserById") - .WithSummary("Get A User By Id."); + .WithSummary("Get A User By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); group.MapPost("/", CreateUser) .WithName("CreateUser") .WithSummary("Create A New User.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status409Conflict); return app; } @@ -58,9 +66,6 @@ public static class UserEndpoints new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), cancellationToken); - if (result.IsError) - return result.ToHttpResult(); - - return Results.Created($"/users/{result.Value.Id}", result.Value); + return result.ToCreatedResult(user => $"/users/{user.Id}"); } } diff --git a/backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index f4e5133..33d2da5 100644 --- a/backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -15,6 +15,11 @@ public static class ErrorOrHttpExtensions : Results.Ok(result.Value); } + public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => + result.IsError + ? Problem(result.Errors) + : Results.Created(locationFactory(result.Value), result.Value); + private static IResult Problem(List errors) { if (errors.All(e => e.Type == ErrorType.Validation)) diff --git a/backend/Musify.Api/Program.cs b/backend/Musify.Api/Program.cs index 9adeadb..c7ab2bd 100644 --- a/backend/Musify.Api/Program.cs +++ b/backend/Musify.Api/Program.cs @@ -11,6 +11,8 @@ builder.Services .AddApplicationServices(builder.Configuration) .AddObservability(builder.Configuration); +builder.Services.AddProblemDetails(); + if (builder.Environment.IsDevelopment()) { builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => @@ -19,6 +21,8 @@ if (builder.Environment.IsDevelopment()) var app = builder.Build(); +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { app.UseCors(devCorsPolicy);