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
@@ -1,5 +1,8 @@
using Mediator;
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.DataTransferObjects.PlayLists;
using Musify.Api.Extensions;
@@ -11,57 +14,85 @@ public static class PlayListEndpoints
{
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/playLists")
var group = app.MapGroup("/playlists")
.WithTags("PlayLists");
group.MapGet("/", GetPlayLists)
.WithName("GetPlayLists")
.WithSummary("Get Paginated PlayLists.");
.WithSummary("Get Paginated PlayLists.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{id}", 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)
.WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User.");
.WithSummary("Get Paginated PlayLists For A User.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{playlistId}/tracks", 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)
.WithName("CreatePlayList")
.WithSummary("Create A New PlayList.")
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
.RequireAuthorization();
.RequireAuthorization()
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{playlistId}", UpdatePlayList)
.WithName("UpdatePlayList")
.WithSummary("Update An Existing PlayList.")
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
.RequireAuthorization();
.RequireAuthorization()
.Produces<PlayListApplicationResponse>()
.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<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization();
.RequireAuthorization()
.Produces<PlayListPictureUploadResponse>()
.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<IResult> UpdatePlayList(
+31 -11
View File
@@ -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<PaginatedResponse<TrackApplicationResponse>>();
group.MapGet("/{id}", 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)
.WithName("GetTrackStream")
.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)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.");
.WithSummary("Get Paginated Tracks For A User.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapPost("/", CreateTrack)
.WithName("CreateTrack")
.WithSummary("Create A New Track.")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization();
.RequireAuthorization()
.Produces<TrackApplicationResponse>(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<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization();
.RequireAuthorization()
.Produces<TrackUploadUrlsResponse>()
.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<IResult> DeleteTrack(
+12 -7
View File
@@ -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<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById)
.WithName("GetUserById")
.WithSummary("Get A User By Id.");
.WithSummary("Get A User By Id.")
.Produces<UserApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateUser)
.WithName("CreateUser")
.WithSummary("Create A New User.")
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>()
.Produces<UserApplicationResponse>(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}");
}
}