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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 21:04:15 +02:00
co-authored by Claude Opus 4.8
parent 1d40abcab7
commit a40de35a57
7 changed files with 109 additions and 34 deletions
@@ -17,6 +17,7 @@ public static class AuthenticationExtensions
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddMemoryCache();
services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>();
@@ -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<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)
{
var principal = tokenValidatedContext.Principal!;
@@ -18,6 +24,10 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
return;
}
var cacheKey = $"user-synced:{userId}";
if (cache.TryGetValue(cacheKey, out _))
return;
var username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email)
@@ -32,6 +42,8 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
await mediator.Send(
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
tokenValidatedContext.HttpContext.RequestAborted);
cache.Set(cacheKey, true, UserSyncCacheTtl);
}
catch (Exception exception)
{