refactor(api): OpenAPI response metadata, cached user sync, ProblemDetails

- Document every endpoint's responses (Produces<T>/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.
This commit is contained in:
2026-07-05 21:04:15 +02:00
parent c4f5e6754c
commit 695f4378c6
7 changed files with 109 additions and 34 deletions
@@ -17,6 +17,7 @@ public static class AuthenticationExtensions
services.AddSingleton(serviceProvider => services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddMemoryCache();
services.AddScoped<JwtBearerEventsHandler>(); services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>(); services.ConfigureOptions<JwtBearerOptionsSetup>();
@@ -1,12 +1,18 @@
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Musify.Application.Users; using Musify.Application.Users;
using System.Security.Claims; using System.Security.Claims;
namespace Musify.Api.Authentication; namespace Musify.Api.Authentication;
public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents public sealed class JwtBearerEventsHandler(
IMediator mediator,
IMemoryCache cache,
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
{ {
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
{ {
var principal = tokenValidatedContext.Principal!; var principal = tokenValidatedContext.Principal!;
@@ -18,6 +24,10 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
return; return;
} }
var cacheKey = $"user-synced:{userId}";
if (cache.TryGetValue(cacheKey, out _))
return;
var username = principal.FindFirstValue(ClaimTypes.Name) var username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue(ClaimTypes.Email)
@@ -32,6 +42,8 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
await mediator.Send( await mediator.Send(
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
tokenValidatedContext.HttpContext.RequestAborted); tokenValidatedContext.HttpContext.RequestAborted);
cache.Set(cacheKey, true, UserSyncCacheTtl);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -1,5 +1,8 @@
using Mediator; using Mediator;
using Musify.Application.PlayLists; using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions; using Musify.Api.Extensions;
@@ -11,57 +14,85 @@ public static class PlayListEndpoints
{ {
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/playLists") var group = app.MapGroup("/playlists")
.WithTags("PlayLists"); .WithTags("PlayLists");
group.MapGet("/", GetPlayLists) group.MapGet("/", GetPlayLists)
.WithName("GetPlayLists") .WithName("GetPlayLists")
.WithSummary("Get Paginated PlayLists."); .WithSummary("Get Paginated PlayLists.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{id}", GetPlayListById) group.MapGet("/{id}", GetPlayListById)
.WithName("GetPlayListById") .WithName("GetPlayListById")
.WithSummary("Get A PlayList By Id."); .WithSummary("Get A PlayList By Id.")
.Produces<PlayListApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetPlayListsByUserId) group.MapGet("/users/{userId}", GetPlayListsByUserId)
.WithName("GetPlayListsByUserId") .WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User."); .WithSummary("Get Paginated PlayLists For A User.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{playlistId}/tracks", GetPlayListTracks) group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
.WithName("GetPlayListTracks") .WithName("GetPlayListTracks")
.WithSummary("Get Paginated Tracks Of A PlayList."); .WithSummary("Get Paginated Tracks Of A PlayList.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreatePlayList) group.MapPost("/", CreatePlayList)
.WithName("CreatePlayList") .WithName("CreatePlayList")
.WithSummary("Create A New PlayList.") .WithSummary("Create A New PlayList.")
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>() .AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{playlistId}", UpdatePlayList) group.MapPut("/{playlistId}", UpdatePlayList)
.WithName("UpdatePlayList") .WithName("UpdatePlayList")
.WithSummary("Update An Existing PlayList.") .WithSummary("Update An Existing PlayList.")
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>() .AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}", DeletePlayList) group.MapDelete("/{playlistId}", DeletePlayList)
.WithName("DeletePlayList") .WithName("DeletePlayList")
.WithSummary("Delete A PlayList.") .WithSummary("Delete A PlayList.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-picture", RequestPlayListPictureUpload) group.MapPost("/upload-picture", RequestPlayListPictureUpload)
.WithName("RequestPlayListPictureUpload") .WithName("RequestPlayListPictureUpload")
.WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.")
.AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>() .AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListPictureUploadResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList)
.WithName("AddTrackToPlayList") .WithName("AddTrackToPlayList")
.WithSummary("Add A Track To A PlayList.") .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) group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList") .WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.") .WithSummary("Remove A Track From A PlayList.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app; return app;
} }
@@ -118,10 +149,7 @@ public static class PlayListEndpoints
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
return result.ToHttpResult();
return Results.Created($"/playlists/{result.Value.Id}", result.Value);
} }
private static async Task<IResult> UpdatePlayList( private static async Task<IResult> UpdatePlayList(
+31 -11
View File
@@ -1,5 +1,7 @@
using Mediator; using Mediator;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions; using Musify.Api.Extensions;
@@ -16,37 +18,58 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks."); .WithSummary("Get Paginated Tracks.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
.WithSummary("Get A Track By Id."); .WithSummary("Get A Track By Id.")
.Produces<TrackApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/stream", GetTrackStream) group.MapGet("/{id}/stream", GetTrackStream)
.WithName("GetTrackStream") .WithName("GetTrackStream")
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId) group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId") .WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User."); .WithSummary("Get Paginated Tracks For A User.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapPost("/", CreateTrack) group.MapPost("/", CreateTrack)
.WithName("CreateTrack") .WithName("CreateTrack")
.WithSummary("Create A New Track.") .WithSummary("Create A New Track.")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>() .AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{trackId}", DeleteTrack) group.MapDelete("/{trackId}", DeleteTrack)
.WithName("DeleteTrack") .WithName("DeleteTrack")
.WithSummary("Delete A Track.") .WithSummary("Delete A Track.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/upload-urls", RequestTrackUploadUrls) group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls") .WithName("RequestTrackUploadUrls")
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>() .AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackUploadUrlsResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
return app; return app;
} }
@@ -102,10 +125,7 @@ public static class TrackEndpoints
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(track => $"/tracks/{track.Id}");
return result.ToHttpResult();
return Results.Created($"/tracks/{result.Value.Id}", result.Value);
} }
private static async Task<IResult> DeleteTrack( private static async Task<IResult> DeleteTrack(
+12 -7
View File
@@ -1,5 +1,7 @@
using Mediator; using Mediator;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Application.Shared;
using Musify.Api.DataTransferObjects.Users; using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
@@ -15,16 +17,22 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users."); .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
.WithSummary("Get A User By Id."); .WithSummary("Get A User By Id.")
.Produces<UserApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateUser) group.MapPost("/", CreateUser)
.WithName("CreateUser") .WithName("CreateUser")
.WithSummary("Create A New User.") .WithSummary("Create A New User.")
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>(); .AddEndpointFilter<ValidationFilter<CreateUserRequest>>()
.Produces<UserApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status409Conflict);
return app; return app;
} }
@@ -58,9 +66,6 @@ public static class UserEndpoints
new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(user => $"/users/{user.Id}");
return result.ToHttpResult();
return Results.Created($"/users/{result.Value.Id}", result.Value);
} }
} }
@@ -15,6 +15,11 @@ public static class ErrorOrHttpExtensions
: Results.Ok(result.Value); : Results.Ok(result.Value);
} }
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
result.IsError
? Problem(result.Errors)
: Results.Created(locationFactory(result.Value), result.Value);
private static IResult Problem(List<Error> errors) private static IResult Problem(List<Error> errors)
{ {
if (errors.All(e => e.Type == ErrorType.Validation)) if (errors.All(e => e.Type == ErrorType.Validation))
+4
View File
@@ -11,6 +11,8 @@ builder.Services
.AddApplicationServices(builder.Configuration) .AddApplicationServices(builder.Configuration)
.AddObservability(builder.Configuration); .AddObservability(builder.Configuration);
builder.Services.AddProblemDetails();
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
{ {
builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy =>
@@ -19,6 +21,8 @@ if (builder.Environment.IsDevelopment())
var app = builder.Build(); var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseCors(devCorsPolicy); app.UseCors(devCorsPolicy);