From af75e277c4eacb7ce4c1d00d6cd892a904a20093 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:35:29 +0200 Subject: [PATCH 01/33] Add .gitattributes and .gitignore. --- .gitattributes | 63 +++++++++ .gitignore | 363 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9491a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,363 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file From 73d20c63f6936fd437ec738e0dbcd140084044f2 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:35:31 +0200 Subject: [PATCH 02/33] Add project files. --- Musify.WebApi.slnx | 3 + WebApi/AppSettings.Development.json | 17 +++ WebApi/AppSettings.json | 94 +++++++++++++ .../PlayLists/CreatePlayListRequest.cs | 6 + .../RequestPlayListPictureUploadRequest.cs | 6 + .../PlayLists/UpdatePlayListRequest.cs | 6 + .../Tracks/CreateTrackRequest.cs | 6 + .../Tracks/RequestTrackUploadUrlsRequest.cs | 9 ++ .../Users/CreateUserRequest.cs | 7 + WebApi/Endpoints/PlayListEndpoints.cs | 130 ++++++++++++++++++ WebApi/Endpoints/TrackEndpoints.cs | 119 ++++++++++++++++ WebApi/Endpoints/UserEndpoints.cs | 78 +++++++++++ WebApi/Extensions/ResultHttpExtensions.cs | 35 +++++ .../Extensions/ServiceCollectionExtensions.cs | 50 +++++++ WebApi/Options/OpenTelemetryOptions.cs | 11 ++ WebApi/Program.cs | 24 ++++ WebApi/Properties/launchSettings.json | 14 ++ WebApi/WebApi.csproj | 44 ++++++ WebApi/WebApi.http | 6 + 19 files changed, 665 insertions(+) create mode 100644 Musify.WebApi.slnx create mode 100644 WebApi/AppSettings.Development.json create mode 100644 WebApi/AppSettings.json create mode 100644 WebApi/DataTransferObjects/PlayLists/CreatePlayListRequest.cs create mode 100644 WebApi/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs create mode 100644 WebApi/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs create mode 100644 WebApi/DataTransferObjects/Tracks/CreateTrackRequest.cs create mode 100644 WebApi/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs create mode 100644 WebApi/DataTransferObjects/Users/CreateUserRequest.cs create mode 100644 WebApi/Endpoints/PlayListEndpoints.cs create mode 100644 WebApi/Endpoints/TrackEndpoints.cs create mode 100644 WebApi/Endpoints/UserEndpoints.cs create mode 100644 WebApi/Extensions/ResultHttpExtensions.cs create mode 100644 WebApi/Extensions/ServiceCollectionExtensions.cs create mode 100644 WebApi/Options/OpenTelemetryOptions.cs create mode 100644 WebApi/Program.cs create mode 100644 WebApi/Properties/launchSettings.json create mode 100644 WebApi/WebApi.csproj create mode 100644 WebApi/WebApi.http diff --git a/Musify.WebApi.slnx b/Musify.WebApi.slnx new file mode 100644 index 0000000..c239259 --- /dev/null +++ b/Musify.WebApi.slnx @@ -0,0 +1,3 @@ + + + diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json new file mode 100644 index 0000000..16ecf29 --- /dev/null +++ b/WebApi/AppSettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "Default": "" + }, + "Storage": { + "BucketName": "", + "Routes": { + "Uploads": "" + } + } +} \ No newline at end of file diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json new file mode 100644 index 0000000..721a2b5 --- /dev/null +++ b/WebApi/AppSettings.json @@ -0,0 +1,94 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "" + }, + "PlayListConfiguration": { + "Routes": { + "ParentFolders": "PlayLists", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures", + "PresetOriginalPicture": "PresetOriginalPicture.webp", + "PresetSmallPicture": "PresetSmallPicture.webp", + "PresetMediumPicture": "PresetMediumPicture.webp", + "PresetLargePicture": "PresetLargePicture.webp" + }, + "PicturesSizes": { + "SmallPictureWidth": 128, + "SmallPictureHeight": 128, + "MediumPictureWidth": 256, + "MediumPictureHeight": 256, + "LargePictureWidth": 512, + "LargePictureHeight": 512 + } + }, + "Keycloak": { + "Address": "1", + "Realm": "1", + "Username": "1", + "Password": "1" + }, + "Database": { + "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" + }, + "ApplicationStorage": { + "BucketName": "webapi-storage" + }, + "InfrastructureStorage": { + "Address": "http://localhost:8333", + "AccessKey": "admin_access_key", + "SecretAccessKey": "admin_secret_key", + "ForcePathStyle": true, + "UseHttp": true + }, + "Messaging": { + "Address": "rabbitmq://127.0.0.1:5672", + "Username": "guest", + "Password": "guest" + }, + "OpenTelemetry": { + "OtlpEndpoint": "http://localhost:4317" + }, + "TrackConfiguration": { + "Routes": { + "ParentFolder": "Tracks", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures", + "PresetOriginalPicture": "PresetOriginalPicture.webp", + "PresetSmallPicture": "PresetSmallPicture.webp", + "PresetMediumPicture": "PresetMediumPicture.webp", + "PresetLargePicture": "PresetLargePicture.webp", + "OriginalAudiosFolder": "OriginalAudios", + "ProcessedAudioFolder": "ProcessedAudios" + }, + "PicturesSizes": { + "SmallPictureWidth": 128, + "SmallPictureHeight": 128, + "MediumPictureWidth": 256, + "MediumPictureHeight": 256, + "LargePictureWidth": 512, + "LargePictureHeight": 512 + } + }, + "AudioTranscoder": { + "Routes": { + "WorkingDirectory": "D:\\tempsFilesDev" + }, + "TranscodingTimeout": "00:05:00" + }, + "Workers": { + "Routes": { + "TemporaryFilesDirectory": "" + } + } +} \ No newline at end of file diff --git a/WebApi/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/WebApi/DataTransferObjects/PlayLists/CreatePlayListRequest.cs new file mode 100644 index 0000000..f6a5e62 --- /dev/null +++ b/WebApi/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -0,0 +1,6 @@ +namespace WebApi.DataTransferObjects.PlayLists; + +public record CreatePlayListRequest( + string Name, + string Description, + Guid? PictureIntentId); diff --git a/WebApi/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs b/WebApi/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs new file mode 100644 index 0000000..4998aa1 --- /dev/null +++ b/WebApi/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs @@ -0,0 +1,6 @@ +namespace WebApi.DataTransferObjects.PlayLists; + +public record RequestPlayListPictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); diff --git a/WebApi/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/WebApi/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs new file mode 100644 index 0000000..083a51d --- /dev/null +++ b/WebApi/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -0,0 +1,6 @@ +namespace WebApi.DataTransferObjects.PlayLists; + +public record UpdatePlayListRequest( + string? NewName, + string? NewDescription, + Guid? NewPictureIntentId); diff --git a/WebApi/DataTransferObjects/Tracks/CreateTrackRequest.cs b/WebApi/DataTransferObjects/Tracks/CreateTrackRequest.cs new file mode 100644 index 0000000..cedac41 --- /dev/null +++ b/WebApi/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -0,0 +1,6 @@ +namespace WebApi.DataTransferObjects.Tracks; + +public record CreateTrackRequest( + string Title, + Guid PictureIntentId, + Guid AudioIntentId); diff --git a/WebApi/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/WebApi/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs new file mode 100644 index 0000000..64144a6 --- /dev/null +++ b/WebApi/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs @@ -0,0 +1,9 @@ +namespace WebApi.DataTransferObjects.Tracks; + +public record RequestTrackUploadUrlsRequest( + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null); diff --git a/WebApi/DataTransferObjects/Users/CreateUserRequest.cs b/WebApi/DataTransferObjects/Users/CreateUserRequest.cs new file mode 100644 index 0000000..5937975 --- /dev/null +++ b/WebApi/DataTransferObjects/Users/CreateUserRequest.cs @@ -0,0 +1,7 @@ +namespace WebApi.DataTransferObjects.Users; + +public record CreateUserRequest( + Guid Id, + string Name, + string? FirstName, + string? SecondName); diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs new file mode 100644 index 0000000..b58e981 --- /dev/null +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -0,0 +1,130 @@ +using Mediator; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Queries; +using WebApi.DataTransferObjects.PlayLists; +using WebApi.Extensions; + +namespace WebApi.Endpoints; + +public static class PlayListEndpoints +{ + public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/playlists") + .WithTags("PlayLists"); + + group.MapGet("/", GetPlayLists) + .WithName("GetPlayLists") + .WithSummary("Get paginated playlists."); + + group.MapGet("/{id:guid}", GetPlayListById) + .WithName("GetPlayListById") + .WithSummary("Get a playlist by ID."); + + group.MapGet("/users/{userId:guid}", GetPlayListsByUserId) + .WithName("GetPlayListsByUserId") + .WithSummary("Get paginated playlists for a user."); + + group.MapPost("/users/{userId:guid}", CreatePlayList) + .WithName("CreatePlayList") + .WithSummary("Create a new playlist."); + + group.MapPut("/{playlistId:guid}/users/{userId:guid}", UpdatePlayList) + .WithName("UpdatePlayList") + .WithSummary("Update an existing playlist."); + + group.MapDelete("/{playlistId:guid}/users/{userId:guid}", DeletePlayList) + .WithName("DeletePlayList") + .WithSummary("Delete a playlist."); + + group.MapPost("/upload-picture/users/{userId:guid}", RequestPlayListPictureUpload) + .WithName("RequestPlayListPictureUpload") + .WithSummary("Request a pre-signed URL to upload a playlist picture."); + + return app; + } + + private static async Task GetPlayLists( + IMediator mediator, + int pageNumber = 1, + int pageSize = 10, + CancellationToken ct = default) + { + var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), ct); + return result.ToHttpResult(); + } + + private static async Task GetPlayListById( + IMediator mediator, + Guid id, + CancellationToken ct) + { + var result = await mediator.Send(new GetPlayListByIdQuery(id), ct); + return result.ToHttpResult(); + } + + private static async Task GetPlayListsByUserId( + IMediator mediator, + Guid userId, + string? name, + int pageNumber = 1, + int pageSize = 10, + CancellationToken ct = default) + { + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), ct); + return result.ToHttpResult(); + } + + private static async Task CreatePlayList( + IMediator mediator, + Guid userId, + CreatePlayListRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new CreatePlayListCommand(userId, request.Name, request.Description, request.PictureIntentId), + ct); + + if (!result.IsSuccess) + return result.ToHttpResult(); + + return Results.Created($"/api/playlists/{result.Value.Id}", result.Value); + } + + private static async Task UpdatePlayList( + IMediator mediator, + Guid userId, + Guid playlistId, + UpdatePlayListRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new UpdatePlayListCommand(userId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), + ct); + + return result.ToHttpResult(); + } + + private static async Task DeletePlayList( + IMediator mediator, + Guid userId, + Guid playlistId, + CancellationToken ct) + { + var result = await mediator.Send(new DeletePlayListCommand(userId, playlistId), ct); + return result.ToHttpResult(); + } + + private static async Task RequestPlayListPictureUpload( + IMediator mediator, + Guid userId, + RequestPlayListPictureUploadRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new RequestPlayListPictureUploadCommand(userId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + ct); + + return result.ToHttpResult(); + } +} diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs new file mode 100644 index 0000000..d576341 --- /dev/null +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -0,0 +1,119 @@ +using Mediator; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Queries; +using WebApi.DataTransferObjects.Tracks; +using WebApi.Extensions; + +namespace WebApi.Endpoints; + +public static class TrackEndpoints +{ + public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/tracks") + .WithTags("Tracks"); + + group.MapGet("/", GetTracks) + .WithName("GetTracks") + .WithSummary("Get paginated tracks."); + + group.MapGet("/{id:guid}", GetTrackById) + .WithName("GetTrackById") + .WithSummary("Get a track by ID."); + + group.MapGet("/users/{userId:guid}", GetTracksByUserId) + .WithName("GetTracksByUserId") + .WithSummary("Get paginated tracks for a user."); + + group.MapPost("/users/{userId:guid}", CreateTrack) + .WithName("CreateTrack") + .WithSummary("Create a new track."); + + group.MapDelete("/{trackId:guid}/users/{userId:guid}", DeleteTrack) + .WithName("DeleteTrack") + .WithSummary("Delete a track."); + + group.MapPost("/upload-urls/users/{userId:guid}", RequestTrackUploadUrls) + .WithName("RequestTrackUploadUrls") + .WithSummary("Request pre-signed URLs to upload track picture and audio."); + + return app; + } + + private static async Task GetTracks( + IMediator mediator, + int pageNumber = 1, + int pageSize = 10, + CancellationToken ct = default) + { + var result = await mediator.Send(new GetTracksQuery(pageNumber, pageSize), ct); + return result.ToHttpResult(); + } + + private static async Task GetTrackById( + IMediator mediator, + Guid id, + CancellationToken ct) + { + var result = await mediator.Send(new GetTrackByIdQuery(id), ct); + return result.ToHttpResult(); + } + + private static async Task GetTracksByUserId( + IMediator mediator, + Guid userId, + string? name, + int pageNumber = 1, + int pageSize = 10, + CancellationToken ct = default) + { + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), ct); + return result.ToHttpResult(); + } + + private static async Task CreateTrack( + IMediator mediator, + Guid userId, + CreateTrackRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new CreateTrackCommand(userId, request.Title, request.PictureIntentId, request.AudioIntentId), + ct); + + if (!result.IsSuccess) + return result.ToHttpResult(); + + return Results.Created($"/api/tracks/{result.Value.Id}", result.Value); + } + + private static async Task DeleteTrack( + IMediator mediator, + Guid userId, + Guid trackId, + CancellationToken ct) + { + var result = await mediator.Send(new DeleteTrackCommand(userId, trackId), ct); + return result.ToHttpResult(); + } + + private static async Task RequestTrackUploadUrls( + IMediator mediator, + Guid userId, + RequestTrackUploadUrlsRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new RequestTrackUploadUrlsCommand( + userId, + request.PictureFileType, + request.PictureContentType, + request.AudioFileType, + request.AudioContentType, + request.ExpectedPictureSizeBytes, + request.ExpectedAudioSizeBytes), + ct); + + return result.ToHttpResult(); + } +} diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..099d961 --- /dev/null +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -0,0 +1,78 @@ +using Mediator; +using Musify.Application.Users.Commands; +using Musify.Application.Users.Queries; +using WebApi.DataTransferObjects.Users; +using WebApi.Extensions; + +namespace WebApi.Endpoints; + +public static class UserEndpoints +{ + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/users") + .WithTags("Users"); + + group.MapGet("/", GetUsers) + .WithName("GetUsers") + .WithSummary("Get paginated users."); + + group.MapGet("/{id:guid}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get a user by ID."); + + group.MapGet("/keycloak/{id:guid}", GetUserByKeycloak) + .WithName("GetUserByKeycloak") + .WithSummary("Get a user by Keycloak ID."); + + group.MapPost("/", CreateUser) + .WithName("CreateUser") + .WithSummary("Create a new user."); + + return app; + } + + private static async Task GetUsers( + IMediator mediator, + int pageNumber = 1, + int pageSize = 10, + string? usernameSearch = null, + CancellationToken ct = default) + { + var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), ct); + return result.ToHttpResult(); + } + + private static async Task GetUserById( + IMediator mediator, + Guid id, + CancellationToken ct) + { + var result = await mediator.Send(new GetUserByIdQuery(id), ct); + return result.ToHttpResult(); + } + + private static async Task GetUserByKeycloak( + IMediator mediator, + Guid id, + CancellationToken ct) + { + var result = await mediator.Send(new GetUserByKeycloakQuery(id), ct); + return result.ToHttpResult(); + } + + private static async Task CreateUser( + IMediator mediator, + CreateUserRequest request, + CancellationToken ct) + { + var result = await mediator.Send( + new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), + ct); + + if (!result.IsSuccess) + return result.ToHttpResult(); + + return Results.Created($"/api/users/{result.Value.Id}", result.Value); + } +} diff --git a/WebApi/Extensions/ResultHttpExtensions.cs b/WebApi/Extensions/ResultHttpExtensions.cs new file mode 100644 index 0000000..7b8a939 --- /dev/null +++ b/WebApi/Extensions/ResultHttpExtensions.cs @@ -0,0 +1,35 @@ +using Ardalis.Result; + +namespace WebApi.Extensions; + +public static class ResultHttpExtensions +{ + public static IResult ToHttpResult(this Result result) => + result.Status switch + { + ResultStatus.Ok or ResultStatus.NoContent => Results.NoContent(), + ResultStatus.NotFound => Results.NotFound(result.Errors.FirstOrDefault()), + ResultStatus.Invalid => Results.ValidationProblem(ToValidationDictionary(result.ValidationErrors)), + ResultStatus.Unauthorized => Results.Unauthorized(), + ResultStatus.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), + ResultStatus.Conflict => Results.Conflict(result.Errors.FirstOrDefault()), + _ => Results.Problem(string.Join(", ", result.Errors)) + }; + + public static IResult ToHttpResult(this Result result) => + result.Status switch + { + ResultStatus.Ok => Results.Ok(result.Value), + ResultStatus.NotFound => Results.NotFound(result.Errors.FirstOrDefault()), + ResultStatus.Invalid => Results.ValidationProblem(ToValidationDictionary(result.ValidationErrors)), + ResultStatus.Unauthorized => Results.Unauthorized(), + ResultStatus.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), + ResultStatus.Conflict => Results.Conflict(result.Errors.FirstOrDefault()), + _ => Results.Problem(string.Join(", ", result.Errors)) + }; + + private static Dictionary ToValidationDictionary(IEnumerable errors) => + errors + .GroupBy(e => e.Identifier) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); +} diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..e4fd603 --- /dev/null +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using Mediator; +using Musify.Application; +using Musify.Infrastructure.MassTransit; +using Musify.Infrastructure.Persistence; +using Musify.Infrastructure.Services; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using WebApi.Options; + +namespace WebApi.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddApplicationConfigurations(configuration); + services.AddDatabase(configuration); + services.AddKeycloakService(configuration); + services.AddStorageService(configuration); + services.AddMassTransitClient(configuration); + services.AddMediator(); + services.AddOpenApi(); + + return services; + } + + public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) + { + var otlpEndpoint = new Uri(configuration + .GetRequiredSection(OpenTelemetryOptions.SectionName) + .GetRequiredSection(nameof(OpenTelemetryOptions.OtlpEndpoint)) + .Value!); + + services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(o => o.Endpoint = otlpEndpoint)) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(o => o.Endpoint = otlpEndpoint)); + + services.Configure(logging => + logging.AddOtlpExporter(o => o.Endpoint = otlpEndpoint)); + + return services; + } +} diff --git a/WebApi/Options/OpenTelemetryOptions.cs b/WebApi/Options/OpenTelemetryOptions.cs new file mode 100644 index 0000000..938e30a --- /dev/null +++ b/WebApi/Options/OpenTelemetryOptions.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebApi.Options; + +public sealed class OpenTelemetryOptions +{ + public const string SectionName = "OpenTelemetry"; + + [Required] + public string OtlpEndpoint { get; init; } = string.Empty; +} diff --git a/WebApi/Program.cs b/WebApi/Program.cs new file mode 100644 index 0000000..d52c69b --- /dev/null +++ b/WebApi/Program.cs @@ -0,0 +1,24 @@ +using Scalar.AspNetCore; +using WebApi.Endpoints; +using WebApi.Extensions; + +var builder = WebApplication.CreateBuilder(); + +builder.Services + .AddApplicationServices(builder.Configuration) + .AddObservability(builder.Configuration); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app + .MapPlayListEndpoints() + .MapTrackEndpoints() + .MapUserEndpoints(); + +app.Run(); diff --git a/WebApi/Properties/launchSettings.json b/WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..a96b644 --- /dev/null +++ b/WebApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5111", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj new file mode 100644 index 0000000..dc08b91 --- /dev/null +++ b/WebApi/WebApi.csproj @@ -0,0 +1,44 @@ + + + + net10.0 + enable + enable + 1c3fa41c-6f07-433b-af79-1f67fad338fd + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Application.dll + + + ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Domain.dll + + + ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Infrastructure.dll + + + + diff --git a/WebApi/WebApi.http b/WebApi/WebApi.http new file mode 100644 index 0000000..213749e --- /dev/null +++ b/WebApi/WebApi.http @@ -0,0 +1,6 @@ +@WebApi_HostAddress = http://localhost:5111 + +GET {{WebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### From d52b55bc27d35f41afd396892e3133d59c41706d Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:37:46 +0200 Subject: [PATCH 03/33] Update packages --- WebApi/WebApi.csproj | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index dc08b91..086f19f 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -9,23 +9,22 @@ - - - - + + + - - - - + + + + - - - - - - - + + + + + + + From 258f3850ed264819b506ceef885fdc97a561f79d Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:44:28 +0200 Subject: [PATCH 04/33] Update endpoints --- WebApi/Endpoints/PlayListEndpoints.cs | 36 +++++++++++++-------------- WebApi/Endpoints/TrackEndpoints.cs | 32 ++++++++++++------------ WebApi/Endpoints/UserEndpoints.cs | 20 +++++++-------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index b58e981..46095a3 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -10,7 +10,7 @@ public static class PlayListEndpoints { public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/playlists") + var group = app.MapGroup("/playlists") .WithTags("PlayLists"); group.MapGet("/", GetPlayLists) @@ -46,32 +46,32 @@ public static class PlayListEndpoints private static async Task GetPlayLists( IMediator mediator, + CancellationToken cancellationToken, int pageNumber = 1, - int pageSize = 10, - CancellationToken ct = default) + int pageSize = 10) { - var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), ct); + var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } private static async Task GetPlayListById( IMediator mediator, Guid id, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new GetPlayListByIdQuery(id), ct); + var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken); return result.ToHttpResult(); } private static async Task GetPlayListsByUserId( IMediator mediator, Guid userId, - string? name, + CancellationToken cancellationToken, + string? name = null, int pageNumber = 1, - int pageSize = 10, - CancellationToken ct = default) + int pageSize = 10) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), ct); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -79,11 +79,11 @@ public static class PlayListEndpoints IMediator mediator, Guid userId, CreatePlayListRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new CreatePlayListCommand(userId, request.Name, request.Description, request.PictureIntentId), - ct); + cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); @@ -96,11 +96,11 @@ public static class PlayListEndpoints Guid userId, Guid playlistId, UpdatePlayListRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new UpdatePlayListCommand(userId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), - ct); + cancellationToken); return result.ToHttpResult(); } @@ -109,9 +109,9 @@ public static class PlayListEndpoints IMediator mediator, Guid userId, Guid playlistId, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new DeletePlayListCommand(userId, playlistId), ct); + var result = await mediator.Send(new DeletePlayListCommand(userId, playlistId), cancellationToken); return result.ToHttpResult(); } @@ -119,11 +119,11 @@ public static class PlayListEndpoints IMediator mediator, Guid userId, RequestPlayListPictureUploadRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new RequestPlayListPictureUploadCommand(userId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - ct); + cancellationToken); return result.ToHttpResult(); } diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index d576341..5e182b1 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -10,7 +10,7 @@ public static class TrackEndpoints { public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/tracks") + var group = app.MapGroup("/tracks") .WithTags("Tracks"); group.MapGet("/", GetTracks) @@ -42,32 +42,32 @@ public static class TrackEndpoints private static async Task GetTracks( IMediator mediator, + CancellationToken cancellationToken, int pageNumber = 1, - int pageSize = 10, - CancellationToken ct = default) + int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(pageNumber, pageSize), ct); + var result = await mediator.Send(new GetTracksQuery(pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } private static async Task GetTrackById( IMediator mediator, Guid id, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackByIdQuery(id), ct); + var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); return result.ToHttpResult(); } private static async Task GetTracksByUserId( IMediator mediator, Guid userId, - string? name, + CancellationToken cancellationToken, + string? name = null, int pageNumber = 1, - int pageSize = 10, - CancellationToken ct = default) + int pageSize = 10) { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), ct); + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -75,11 +75,11 @@ public static class TrackEndpoints IMediator mediator, Guid userId, CreateTrackRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new CreateTrackCommand(userId, request.Title, request.PictureIntentId, request.AudioIntentId), - ct); + cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); @@ -91,9 +91,9 @@ public static class TrackEndpoints IMediator mediator, Guid userId, Guid trackId, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new DeleteTrackCommand(userId, trackId), ct); + var result = await mediator.Send(new DeleteTrackCommand(userId, trackId), cancellationToken); return result.ToHttpResult(); } @@ -101,7 +101,7 @@ public static class TrackEndpoints IMediator mediator, Guid userId, RequestTrackUploadUrlsRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new RequestTrackUploadUrlsCommand( @@ -112,7 +112,7 @@ public static class TrackEndpoints request.AudioContentType, request.ExpectedPictureSizeBytes, request.ExpectedAudioSizeBytes), - ct); + cancellationToken); return result.ToHttpResult(); } diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 099d961..5c8ec83 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -10,7 +10,7 @@ public static class UserEndpoints { public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/users") + var group = app.MapGroup("/users") .WithTags("Users"); group.MapGet("/", GetUsers) @@ -34,41 +34,41 @@ public static class UserEndpoints private static async Task GetUsers( IMediator mediator, + CancellationToken cancellationToken, int pageNumber = 1, int pageSize = 10, - string? usernameSearch = null, - CancellationToken ct = default) + string? usernameSearch = null) { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), ct); + var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); return result.ToHttpResult(); } private static async Task GetUserById( IMediator mediator, Guid id, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new GetUserByIdQuery(id), ct); + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); return result.ToHttpResult(); } private static async Task GetUserByKeycloak( IMediator mediator, Guid id, - CancellationToken ct) + CancellationToken cancellationToken) { - var result = await mediator.Send(new GetUserByKeycloakQuery(id), ct); + var result = await mediator.Send(new GetUserByKeycloakQuery(id), cancellationToken); return result.ToHttpResult(); } private static async Task CreateUser( IMediator mediator, CreateUserRequest request, - CancellationToken ct) + CancellationToken cancellationToken) { var result = await mediator.Send( new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), - ct); + cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); From 2f7f996faf3687faeec627fc82f20f85623e6d36 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:49:18 +0200 Subject: [PATCH 05/33] Remove guid as is unvalid --- WebApi/Endpoints/PlayListEndpoints.cs | 14 +++++++------- WebApi/Endpoints/TrackEndpoints.cs | 12 ++++++------ WebApi/Endpoints/UserEndpoints.cs | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index 46095a3..ab440d3 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -17,27 +17,27 @@ public static class PlayListEndpoints .WithName("GetPlayLists") .WithSummary("Get paginated playlists."); - group.MapGet("/{id:guid}", GetPlayListById) + group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") .WithSummary("Get a playlist by ID."); - group.MapGet("/users/{userId:guid}", GetPlayListsByUserId) + group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") .WithSummary("Get paginated playlists for a user."); - group.MapPost("/users/{userId:guid}", CreatePlayList) + group.MapPost("/users/{userId}", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create a new playlist."); - group.MapPut("/{playlistId:guid}/users/{userId:guid}", UpdatePlayList) + group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) .WithName("UpdatePlayList") .WithSummary("Update an existing playlist."); - group.MapDelete("/{playlistId:guid}/users/{userId:guid}", DeletePlayList) + group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) .WithName("DeletePlayList") .WithSummary("Delete a playlist."); - group.MapPost("/upload-picture/users/{userId:guid}", RequestPlayListPictureUpload) + group.MapPost("/upload-picture/users/{userId}", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") .WithSummary("Request a pre-signed URL to upload a playlist picture."); @@ -88,7 +88,7 @@ public static class PlayListEndpoints if (!result.IsSuccess) return result.ToHttpResult(); - return Results.Created($"/api/playlists/{result.Value.Id}", result.Value); + return Results.Created($"/playlists/{result.Value.Id}", result.Value); } private static async Task UpdatePlayList( diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 5e182b1..61171fe 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -17,23 +17,23 @@ public static class TrackEndpoints .WithName("GetTracks") .WithSummary("Get paginated tracks."); - group.MapGet("/{id:guid}", GetTrackById) + group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") .WithSummary("Get a track by ID."); - group.MapGet("/users/{userId:guid}", GetTracksByUserId) + group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get paginated tracks for a user."); - group.MapPost("/users/{userId:guid}", CreateTrack) + group.MapPost("/users/{userId}", CreateTrack) .WithName("CreateTrack") .WithSummary("Create a new track."); - group.MapDelete("/{trackId:guid}/users/{userId:guid}", DeleteTrack) + group.MapDelete("/{trackId}/users/{userId}", DeleteTrack) .WithName("DeleteTrack") .WithSummary("Delete a track."); - group.MapPost("/upload-urls/users/{userId:guid}", RequestTrackUploadUrls) + group.MapPost("/upload-urls/users/{userId}", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request pre-signed URLs to upload track picture and audio."); @@ -84,7 +84,7 @@ public static class TrackEndpoints if (!result.IsSuccess) return result.ToHttpResult(); - return Results.Created($"/api/tracks/{result.Value.Id}", result.Value); + return Results.Created($"/tracks/{result.Value.Id}", result.Value); } private static async Task DeleteTrack( diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 5c8ec83..85d8326 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -17,11 +17,11 @@ public static class UserEndpoints .WithName("GetUsers") .WithSummary("Get paginated users."); - group.MapGet("/{id:guid}", GetUserById) + group.MapGet("/{id}", GetUserById) .WithName("GetUserById") .WithSummary("Get a user by ID."); - group.MapGet("/keycloak/{id:guid}", GetUserByKeycloak) + group.MapGet("/keycloak/{id}", GetUserByKeycloak) .WithName("GetUserByKeycloak") .WithSummary("Get a user by Keycloak ID."); @@ -73,6 +73,6 @@ public static class UserEndpoints if (!result.IsSuccess) return result.ToHttpResult(); - return Results.Created($"/api/users/{result.Value.Id}", result.Value); + return Results.Created($"/users/{result.Value.Id}", result.Value); } } From 76b5c0b49d1193f86cfe16439cc8ed334e605dd2 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 21:53:46 +0200 Subject: [PATCH 06/33] validators --- WebApi/Endpoints/PlayListEndpoints.cs | 4 ++- WebApi/Endpoints/TrackEndpoints.cs | 4 ++- WebApi/Endpoints/UserEndpoints.cs | 4 ++- .../Extensions/ServiceCollectionExtensions.cs | 3 ++ WebApi/Filters/ValidationFilter.cs | 28 +++++++++++++++++ .../CreatePlayListRequestValidator.cs | 18 +++++++++++ ...stPlayListPictureUploadRequestValidator.cs | 20 +++++++++++++ .../UpdatePlayListRequestValidator.cs | 18 +++++++++++ .../Tracks/CreateTrackRequestValidator.cs | 20 +++++++++++++ .../RequestTrackUploadUrlsRequestValidator.cs | 30 +++++++++++++++++++ .../Users/CreateUserRequestValidator.cs | 25 ++++++++++++++++ WebApi/WebApi.csproj | 1 + 12 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 WebApi/Filters/ValidationFilter.cs create mode 100644 WebApi/Validators/PlayLists/CreatePlayListRequestValidator.cs create mode 100644 WebApi/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs create mode 100644 WebApi/Validators/PlayLists/UpdatePlayListRequestValidator.cs create mode 100644 WebApi/Validators/Tracks/CreateTrackRequestValidator.cs create mode 100644 WebApi/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs create mode 100644 WebApi/Validators/Users/CreateUserRequestValidator.cs diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index ab440d3..a85560c 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -3,6 +3,7 @@ using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Queries; using WebApi.DataTransferObjects.PlayLists; using WebApi.Extensions; +using WebApi.Filters; namespace WebApi.Endpoints; @@ -11,7 +12,8 @@ public static class PlayListEndpoints public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/playlists") - .WithTags("PlayLists"); + .WithTags("PlayLists") + .AddEndpointFilter(); group.MapGet("/", GetPlayLists) .WithName("GetPlayLists") diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 61171fe..c44e8f5 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -3,6 +3,7 @@ using Musify.Application.Tracks.Commands; using Musify.Application.Tracks.Queries; using WebApi.DataTransferObjects.Tracks; using WebApi.Extensions; +using WebApi.Filters; namespace WebApi.Endpoints; @@ -11,7 +12,8 @@ public static class TrackEndpoints public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/tracks") - .WithTags("Tracks"); + .WithTags("Tracks") + .AddEndpointFilter(); group.MapGet("/", GetTracks) .WithName("GetTracks") diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 85d8326..d123eab 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -3,6 +3,7 @@ using Musify.Application.Users.Commands; using Musify.Application.Users.Queries; using WebApi.DataTransferObjects.Users; using WebApi.Extensions; +using WebApi.Filters; namespace WebApi.Endpoints; @@ -11,7 +12,8 @@ public static class UserEndpoints public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/users") - .WithTags("Users"); + .WithTags("Users") + .AddEndpointFilter(); group.MapGet("/", GetUsers) .WithName("GetUsers") diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index e4fd603..c152a47 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Mediator; using Musify.Application; using Musify.Infrastructure.MassTransit; @@ -6,6 +7,7 @@ using Musify.Infrastructure.Services; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; +using WebApi.DataTransferObjects.PlayLists; using WebApi.Options; namespace WebApi.Extensions; @@ -21,6 +23,7 @@ public static class ServiceCollectionExtensions services.AddMassTransitClient(configuration); services.AddMediator(); services.AddOpenApi(); + services.AddValidatorsFromAssemblyContaining(); return services; } diff --git a/WebApi/Filters/ValidationFilter.cs b/WebApi/Filters/ValidationFilter.cs new file mode 100644 index 0000000..2f57d1f --- /dev/null +++ b/WebApi/Filters/ValidationFilter.cs @@ -0,0 +1,28 @@ +using FluentValidation; + +namespace WebApi.Filters; + +public sealed class ValidationFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + foreach (var argument in context.Arguments) + { + if (argument is null) + continue; + + var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + + if (context.HttpContext.RequestServices.GetService(validatorType) is not IValidator validator) + continue; + + var validationContext = new ValidationContext(argument); + var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted); + + if (!result.IsValid) + return Results.ValidationProblem(result.ToDictionary()); + } + + return await next(context); + } +} diff --git a/WebApi/Validators/PlayLists/CreatePlayListRequestValidator.cs b/WebApi/Validators/PlayLists/CreatePlayListRequestValidator.cs new file mode 100644 index 0000000..83866f1 --- /dev/null +++ b/WebApi/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using WebApi.DataTransferObjects.PlayLists; + +namespace WebApi.Validators.PlayLists; + +public sealed class CreatePlayListRequestValidator : AbstractValidator +{ + public CreatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(50); + + RuleFor(x => x.Description) + .NotEmpty() + .MaximumLength(256); + } +} diff --git a/WebApi/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/WebApi/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs new file mode 100644 index 0000000..4729014 --- /dev/null +++ b/WebApi/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using WebApi.DataTransferObjects.PlayLists; + +namespace WebApi.Validators.PlayLists; + +public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator +{ + public RequestPlayListPictureUploadRequestValidator() + { + RuleFor(x => x.FileType) + .NotEmpty(); + + RuleFor(x => x.ContentType) + .NotEmpty(); + + RuleFor(x => x.ExpectedSizeBytes) + .GreaterThan(0) + .When(x => x.ExpectedSizeBytes.HasValue); + } +} diff --git a/WebApi/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/WebApi/Validators/PlayLists/UpdatePlayListRequestValidator.cs new file mode 100644 index 0000000..855023f --- /dev/null +++ b/WebApi/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using WebApi.DataTransferObjects.PlayLists; + +namespace WebApi.Validators.PlayLists; + +public sealed class UpdatePlayListRequestValidator : AbstractValidator +{ + public UpdatePlayListRequestValidator() + { + RuleFor(x => x.NewName) + .MaximumLength(50) + .When(x => x.NewName is not null); + + RuleFor(x => x.NewDescription) + .MaximumLength(256) + .When(x => x.NewDescription is not null); + } +} diff --git a/WebApi/Validators/Tracks/CreateTrackRequestValidator.cs b/WebApi/Validators/Tracks/CreateTrackRequestValidator.cs new file mode 100644 index 0000000..4e1be9b --- /dev/null +++ b/WebApi/Validators/Tracks/CreateTrackRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using WebApi.DataTransferObjects.Tracks; + +namespace WebApi.Validators.Tracks; + +public sealed class CreateTrackRequestValidator : AbstractValidator +{ + public CreateTrackRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(50); + + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + + RuleFor(x => x.AudioIntentId) + .NotEmpty(); + } +} diff --git a/WebApi/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/WebApi/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs new file mode 100644 index 0000000..95c21f6 --- /dev/null +++ b/WebApi/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using WebApi.DataTransferObjects.Tracks; + +namespace WebApi.Validators.Tracks; + +public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator +{ + public RequestTrackUploadUrlsRequestValidator() + { + RuleFor(x => x.PictureFileType) + .NotEmpty(); + + RuleFor(x => x.PictureContentType) + .NotEmpty(); + + RuleFor(x => x.AudioFileType) + .NotEmpty(); + + RuleFor(x => x.AudioContentType) + .NotEmpty(); + + RuleFor(x => x.ExpectedPictureSizeBytes) + .GreaterThan(0) + .When(x => x.ExpectedPictureSizeBytes.HasValue); + + RuleFor(x => x.ExpectedAudioSizeBytes) + .GreaterThan(0) + .When(x => x.ExpectedAudioSizeBytes.HasValue); + } +} diff --git a/WebApi/Validators/Users/CreateUserRequestValidator.cs b/WebApi/Validators/Users/CreateUserRequestValidator.cs new file mode 100644 index 0000000..c862481 --- /dev/null +++ b/WebApi/Validators/Users/CreateUserRequestValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using WebApi.DataTransferObjects.Users; + +namespace WebApi.Validators.Users; + +public sealed class CreateUserRequestValidator : AbstractValidator +{ + public CreateUserRequestValidator() + { + RuleFor(x => x.Id) + .NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(48); + + RuleFor(x => x.FirstName) + .MaximumLength(48) + .When(x => x.FirstName is not null); + + RuleFor(x => x.SecondName) + .MaximumLength(48) + .When(x => x.SecondName is not null); + } +} diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index 086f19f..6034122 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -10,6 +10,7 @@ + From 1deab7731be489c065c067751e181a21da38ee34 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 22:06:09 +0200 Subject: [PATCH 07/33] Change on filter --- WebApi/Endpoints/PlayListEndpoints.cs | 12 +++++++----- WebApi/Endpoints/TrackEndpoints.cs | 9 +++++---- WebApi/Endpoints/UserEndpoints.cs | 6 +++--- WebApi/Filters/ValidationFilter.cs | 26 +++++++++----------------- 4 files changed, 24 insertions(+), 29 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index a85560c..da800c5 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -12,8 +12,7 @@ public static class PlayListEndpoints public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/playlists") - .WithTags("PlayLists") - .AddEndpointFilter(); + .WithTags("PlayLists"); group.MapGet("/", GetPlayLists) .WithName("GetPlayLists") @@ -29,11 +28,13 @@ public static class PlayListEndpoints group.MapPost("/users/{userId}", CreatePlayList) .WithName("CreatePlayList") - .WithSummary("Create a new playlist."); + .WithSummary("Create a new playlist.") + .AddEndpointFilter>(); group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) .WithName("UpdatePlayList") - .WithSummary("Update an existing playlist."); + .WithSummary("Update an existing playlist.") + .AddEndpointFilter>(); group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) .WithName("DeletePlayList") @@ -41,7 +42,8 @@ public static class PlayListEndpoints group.MapPost("/upload-picture/users/{userId}", 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>(); return app; } diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index c44e8f5..4ac35e1 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -12,8 +12,7 @@ public static class TrackEndpoints public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/tracks") - .WithTags("Tracks") - .AddEndpointFilter(); + .WithTags("Tracks"); group.MapGet("/", GetTracks) .WithName("GetTracks") @@ -29,7 +28,8 @@ public static class TrackEndpoints group.MapPost("/users/{userId}", CreateTrack) .WithName("CreateTrack") - .WithSummary("Create a new track."); + .WithSummary("Create a new track.") + .AddEndpointFilter>(); group.MapDelete("/{trackId}/users/{userId}", DeleteTrack) .WithName("DeleteTrack") @@ -37,7 +37,8 @@ public static class TrackEndpoints group.MapPost("/upload-urls/users/{userId}", 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>(); return app; } diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index d123eab..daec332 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -12,8 +12,7 @@ public static class UserEndpoints public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/users") - .WithTags("Users") - .AddEndpointFilter(); + .WithTags("Users"); group.MapGet("/", GetUsers) .WithName("GetUsers") @@ -29,7 +28,8 @@ public static class UserEndpoints group.MapPost("/", CreateUser) .WithName("CreateUser") - .WithSummary("Create a new user."); + .WithSummary("Create a new user.") + .AddEndpointFilter>(); return app; } diff --git a/WebApi/Filters/ValidationFilter.cs b/WebApi/Filters/ValidationFilter.cs index 2f57d1f..fe8e7e7 100644 --- a/WebApi/Filters/ValidationFilter.cs +++ b/WebApi/Filters/ValidationFilter.cs @@ -2,27 +2,19 @@ using FluentValidation; namespace WebApi.Filters; -public sealed class ValidationFilter : IEndpointFilter +public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - foreach (var argument in context.Arguments) - { - if (argument is null) - continue; + var argument = context.Arguments.OfType().FirstOrDefault(); - var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + if (argument is null) + return await next(context); - if (context.HttpContext.RequestServices.GetService(validatorType) is not IValidator validator) - continue; + var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); - var validationContext = new ValidationContext(argument); - var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted); - - if (!result.IsValid) - return Results.ValidationProblem(result.ToDictionary()); - } - - return await next(context); + return result.IsValid + ? await next(context) + : Results.ValidationProblem(result.ToDictionary()); } -} +} \ No newline at end of file From 49edd2b69f105b867b8893e4742efffdd9d7d740 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 23:14:32 +0200 Subject: [PATCH 08/33] Change --- WebApi/Endpoints/PlayListEndpoints.cs | 18 +++++++++--------- WebApi/Endpoints/TrackEndpoints.cs | 2 +- WebApi/Endpoints/UserEndpoints.cs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index da800c5..6f9c824 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -11,38 +11,38 @@ 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."); group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") - .WithSummary("Get a playlist by ID."); + .WithSummary("Get a playLists by ID."); group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get paginated playlists for a user."); + .WithSummary("Get paginated playLists for a user."); group.MapPost("/users/{userId}", CreatePlayList) .WithName("CreatePlayList") - .WithSummary("Create a new playlist.") + .WithSummary("Create a new playLists.") .AddEndpointFilter>(); group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) .WithName("UpdatePlayList") - .WithSummary("Update an existing playlist.") + .WithSummary("Update an existing playLists.") .AddEndpointFilter>(); group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) .WithName("DeletePlayList") - .WithSummary("Delete a playlist."); + .WithSummary("Delete a playLists."); group.MapPost("/upload-picture/users/{userId}", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") - .WithSummary("Request a pre-signed URL to upload a playlist picture.") + .WithSummary("Request a pre-signed URL to upload a playLists picture.") .AddEndpointFilter>(); return app; @@ -71,7 +71,7 @@ public static class PlayListEndpoints IMediator mediator, Guid userId, CancellationToken cancellationToken, - string? name = null, + string? name, int pageNumber = 1, int pageSize = 10) { diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 4ac35e1..deadd8b 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -66,7 +66,7 @@ public static class TrackEndpoints IMediator mediator, Guid userId, CancellationToken cancellationToken, - string? name = null, + string? name, int pageNumber = 1, int pageSize = 10) { diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index daec332..2858702 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -37,9 +37,9 @@ public static class UserEndpoints private static async Task GetUsers( IMediator mediator, CancellationToken cancellationToken, + string? usernameSearch, int pageNumber = 1, - int pageSize = 10, - string? usernameSearch = null) + int pageSize = 10) { var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); return result.ToHttpResult(); From b3879c1baa36408c9dcfdcd3c0b661966c95cfaa Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 23:16:30 +0200 Subject: [PATCH 09/33] Remove keycloak --- WebApi/Endpoints/UserEndpoints.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 2858702..92d7d09 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -22,10 +22,6 @@ public static class UserEndpoints .WithName("GetUserById") .WithSummary("Get a user by ID."); - group.MapGet("/keycloak/{id}", GetUserByKeycloak) - .WithName("GetUserByKeycloak") - .WithSummary("Get a user by Keycloak ID."); - group.MapPost("/", CreateUser) .WithName("CreateUser") .WithSummary("Create a new user.") @@ -54,15 +50,6 @@ public static class UserEndpoints return result.ToHttpResult(); } - private static async Task GetUserByKeycloak( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserByKeycloakQuery(id), cancellationToken); - return result.ToHttpResult(); - } - private static async Task CreateUser( IMediator mediator, CreateUserRequest request, From f694571e1a63fbe0d7bc0baf7e8ba7455202575c Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 28 May 2026 23:49:09 +0200 Subject: [PATCH 10/33] Fixes and config --- WebApi/AppSettings.Development.json | 29 ++++-- WebApi/AppSettings.json | 88 +++++++++++-------- WebApi/Extensions/ResultHttpExtensions.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 7 +- WebApi/WebApi.csproj | 5 +- 5 files changed, 84 insertions(+), 46 deletions(-) diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index 16ecf29..db29d78 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -5,13 +5,30 @@ "Microsoft.AspNetCore": "Warning" } }, - "ConnectionStrings": { - "Default": "" + "Database": { + "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" }, - "Storage": { - "BucketName": "", + "MassTransit": { + "Address": "rabbitmq://127.0.0.1:5672", + "Username": "guest", + "Password": "guest" + }, + "InfrastructureStorage": { + "Address": "http://localhost:8333", + "AccessKey": "admin_access_key", + "SecretAccessKey": "admin_secret_key", + "ForcePathStyle": true, + "UseHttp": true + }, + "ApplicationStorage": { + "Bucket": "webapi-storage" + }, + "OpenTelemetry": { + "OtlpEndpoint": "http://localhost:4317" + }, + "Workers": { "Routes": { - "Uploads": "" + "TemporaryFilesDirectory": "D:\\tempsFilesDev" } } -} \ No newline at end of file +} diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index 721a2b5..f80dc37 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -6,11 +6,30 @@ } }, "AllowedHosts": "*", - "ConnectionStrings": { - "Default": "" + "Database": { + "ConnectionString": "" }, - "PlayListConfiguration": { + "MassTransit": { + "Address": "", + "Username": "", + "Password": "" + }, + "InfrastructureStorage": { + "Address": "", + "AccessKey": "", + "SecretAccessKey": "", + "ForcePathStyle": false, + "UseHttp": false + }, + "ApplicationStorage": { + "Bucket": "" + }, + "OpenTelemetry": { + "OtlpEndpoint": "" + }, + "PlayList": { "Routes": { + "UploadsFolder": "uploads", "ParentFolders": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", @@ -30,35 +49,9 @@ "LargePictureHeight": 512 } }, - "Keycloak": { - "Address": "1", - "Realm": "1", - "Username": "1", - "Password": "1" - }, - "Database": { - "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" - }, - "ApplicationStorage": { - "BucketName": "webapi-storage" - }, - "InfrastructureStorage": { - "Address": "http://localhost:8333", - "AccessKey": "admin_access_key", - "SecretAccessKey": "admin_secret_key", - "ForcePathStyle": true, - "UseHttp": true - }, - "Messaging": { - "Address": "rabbitmq://127.0.0.1:5672", - "Username": "guest", - "Password": "guest" - }, - "OpenTelemetry": { - "OtlpEndpoint": "http://localhost:4317" - }, - "TrackConfiguration": { + "Track": { "Routes": { + "UploadsFolder": "uploads", "ParentFolder": "Tracks", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", @@ -81,14 +74,37 @@ } }, "AudioTranscoder": { - "Routes": { - "WorkingDirectory": "D:\\tempsFilesDev" - }, - "TranscodingTimeout": "00:05:00" + "TranscodingTimeout": "00:05:00", + "Ffmpeg": { + "ExecutableName": "ffmpeg", + "ManifestFileName": "manifest.mpd", + "AudioCodec": "aac", + "AudioBitrate": "128k", + "AudioChannels": 2, + "AudioSampleRate": 48000, + "AudioProfile": "aac_low", + "SegmentDurationSeconds": 4, + "InitSegmentName": "init-stream$RepresentationID$.m4s", + "MediaSegmentName": "chunk-stream$RepresentationID$-$Number$.m4s", + "AdditionalArguments": "" + } + }, + "UploadIntent": { + "UploadUrlExpiresInSeconds": 120, + "MaxActiveUploadIntentsPerUser": 5, + "MaxActiveUploadBytesPerUser": 209715200, + "MaxUploadBytes": 104857600, + "DefaultExpectedPictureSizeBytes": 10485760, + "DefaultExpectedAudioSizeBytes": 104857600, + "ExpirationJobIntervalSeconds": 300, + "ExpiredIntentsRetentionDays": 7, + "TempRootPrefix": "temp", + "TempUploadsRetentionDays": 3, + "TempCleanupJobIntervalSeconds": 3600 }, "Workers": { "Routes": { "TemporaryFilesDirectory": "" } } -} \ No newline at end of file +} diff --git a/WebApi/Extensions/ResultHttpExtensions.cs b/WebApi/Extensions/ResultHttpExtensions.cs index 7b8a939..6b3bb64 100644 --- a/WebApi/Extensions/ResultHttpExtensions.cs +++ b/WebApi/Extensions/ResultHttpExtensions.cs @@ -1,4 +1,5 @@ using Ardalis.Result; +using IResult = Microsoft.AspNetCore.Http.IResult; namespace WebApi.Extensions; diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index c152a47..91a7c41 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Mediator; using Musify.Application; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; +using Musify.Application.Configuration; using Musify.Infrastructure.Services; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; @@ -16,10 +17,12 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) { - services.AddApplicationConfigurations(configuration); services.AddDatabase(configuration); - services.AddKeycloakService(configuration); + services.AddApplicationStorageConfiguration(configuration); + services.AddPlayListConfiguration(configuration); + services.AddTrackConfiguration(configuration); services.AddStorageService(configuration); + services.AddUploadIntentConfiguration(configuration); services.AddMassTransitClient(configuration); services.AddMediator(); services.AddOpenApi(); diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index 6034122..b1850e3 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -11,8 +11,9 @@ - - + + + From 874b0d5a81de8d1037deb7ccd8f979d60b56ab6c Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 00:25:26 +0200 Subject: [PATCH 11/33] Remove unused usings --- WebApi/Extensions/ServiceCollectionExtensions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index 91a7c41..0b550fa 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,4 @@ using FluentValidation; -using Mediator; -using Musify.Application; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Application.Configuration; From 07eaff57915797b886e7f307c26520d62736ded7 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 01:48:48 +0200 Subject: [PATCH 12/33] Changes --- .../Extensions/ServiceCollectionExtensions.cs | 27 ------------------- WebApi/Options/OpenTelemetryOptions.cs | 11 -------- WebApi/Program.cs | 3 ++- WebApi/WebApi.csproj | 19 +++++-------- 4 files changed, 9 insertions(+), 51 deletions(-) delete mode 100644 WebApi/Options/OpenTelemetryOptions.cs diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index 0b550fa..94fcc81 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -3,11 +3,7 @@ using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Application.Configuration; using Musify.Infrastructure.Services; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using WebApi.DataTransferObjects.PlayLists; -using WebApi.Options; namespace WebApi.Extensions; @@ -28,27 +24,4 @@ public static class ServiceCollectionExtensions return services; } - - public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) - { - var otlpEndpoint = new Uri(configuration - .GetRequiredSection(OpenTelemetryOptions.SectionName) - .GetRequiredSection(nameof(OpenTelemetryOptions.OtlpEndpoint)) - .Value!); - - services.AddOpenTelemetry() - .WithTracing(tracing => tracing - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddOtlpExporter(o => o.Endpoint = otlpEndpoint)) - .WithMetrics(metrics => metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddOtlpExporter(o => o.Endpoint = otlpEndpoint)); - - services.Configure(logging => - logging.AddOtlpExporter(o => o.Endpoint = otlpEndpoint)); - - return services; - } } diff --git a/WebApi/Options/OpenTelemetryOptions.cs b/WebApi/Options/OpenTelemetryOptions.cs deleted file mode 100644 index 938e30a..0000000 --- a/WebApi/Options/OpenTelemetryOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace WebApi.Options; - -public sealed class OpenTelemetryOptions -{ - public const string SectionName = "OpenTelemetry"; - - [Required] - public string OtlpEndpoint { get; init; } = string.Empty; -} diff --git a/WebApi/Program.cs b/WebApi/Program.cs index d52c69b..80ca5fb 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -1,3 +1,4 @@ +using Musify.Infrastructure.Observability; using Scalar.AspNetCore; using WebApi.Endpoints; using WebApi.Extensions; @@ -6,7 +7,7 @@ var builder = WebApplication.CreateBuilder(); builder.Services .AddApplicationServices(builder.Configuration) - .AddObservability(builder.Configuration); + .AddInfrastructureOpenTelemetry(builder.Configuration); var app = builder.Build(); diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index b1850e3..4d6b30b 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -11,12 +11,13 @@ - - - + + + + @@ -31,15 +32,9 @@ - - ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Application.dll - - - ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Domain.dll - - - ..\..\MusifyBackend\Infrastructure\bin\Debug\net10.0\Infrastructure.dll - + + + From c85039ddc417049e0e0881731f74e78bfc457ad0 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 01:57:28 +0200 Subject: [PATCH 13/33] Rename --- WebApi/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 80ca5fb..eef071d 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -7,7 +7,7 @@ var builder = WebApplication.CreateBuilder(); builder.Services .AddApplicationServices(builder.Configuration) - .AddInfrastructureOpenTelemetry(builder.Configuration); + .AddObservability(builder.Configuration); var app = builder.Build(); From 2fa364a813b5694cf6cd485ac40b8a98dd73572d Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 02:26:37 +0200 Subject: [PATCH 14/33] PascalCase --- WebApi/Endpoints/PlayListEndpoints.cs | 14 +++++++------- WebApi/Endpoints/TrackEndpoints.cs | 12 ++++++------ WebApi/Endpoints/UserEndpoints.cs | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index 6f9c824..49dee8e 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -16,33 +16,33 @@ public static class PlayListEndpoints group.MapGet("/", GetPlayLists) .WithName("GetPlayLists") - .WithSummary("Get paginated playLists."); + .WithSummary("Get paginated PlayLists."); group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") - .WithSummary("Get a playLists by ID."); + .WithSummary("Get a PlayList by Id."); group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get paginated playLists for a user."); + .WithSummary("Get paginated PlayLists for a User."); group.MapPost("/users/{userId}", CreatePlayList) .WithName("CreatePlayList") - .WithSummary("Create a new playLists.") + .WithSummary("Create a new PlayList.") .AddEndpointFilter>(); group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) .WithName("UpdatePlayList") - .WithSummary("Update an existing playLists.") + .WithSummary("Update an existing PlayList.") .AddEndpointFilter>(); group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) .WithName("DeletePlayList") - .WithSummary("Delete a playLists."); + .WithSummary("Delete a PlayList."); group.MapPost("/upload-picture/users/{userId}", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") - .WithSummary("Request a pre-signed URL to upload a playLists picture.") + .WithSummary("Request a pre-signed URL to upload a PlayList picture.") .AddEndpointFilter>(); return app; diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index deadd8b..121466d 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -16,28 +16,28 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get paginated tracks."); + .WithSummary("Get paginated Tracks."); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") - .WithSummary("Get a track by ID."); + .WithSummary("Get a Track by Id."); group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user."); + .WithSummary("Get paginated Tracks for a User."); group.MapPost("/users/{userId}", CreateTrack) .WithName("CreateTrack") - .WithSummary("Create a new track.") + .WithSummary("Create a new Track.") .AddEndpointFilter>(); group.MapDelete("/{trackId}/users/{userId}", DeleteTrack) .WithName("DeleteTrack") - .WithSummary("Delete a track."); + .WithSummary("Delete a Track."); group.MapPost("/upload-urls/users/{userId}", 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>(); return app; diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 92d7d09..9557c58 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -16,15 +16,15 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .WithName("GetUsers") - .WithSummary("Get paginated users."); + .WithSummary("Get paginated Users."); group.MapGet("/{id}", GetUserById) .WithName("GetUserById") - .WithSummary("Get a user by ID."); + .WithSummary("Get a User by Id."); group.MapPost("/", CreateUser) .WithName("CreateUser") - .WithSummary("Create a new user.") + .WithSummary("Create a new User.") .AddEndpointFilter>(); return app; From d387708e3a1589d695f645039c60bbfebbc186cf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 02:28:35 +0200 Subject: [PATCH 15/33] PascalCase --- WebApi/Endpoints/PlayListEndpoints.cs | 14 +++++++------- WebApi/Endpoints/TrackEndpoints.cs | 12 ++++++------ WebApi/Endpoints/UserEndpoints.cs | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index 49dee8e..dcada56 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -16,33 +16,33 @@ public static class PlayListEndpoints group.MapGet("/", GetPlayLists) .WithName("GetPlayLists") - .WithSummary("Get paginated PlayLists."); + .WithSummary("Get Paginated PlayLists."); group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") - .WithSummary("Get a PlayList by Id."); + .WithSummary("Get A PlayList By Id."); group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get paginated PlayLists for a User."); + .WithSummary("Get Paginated PlayLists For A User."); group.MapPost("/users/{userId}", CreatePlayList) .WithName("CreatePlayList") - .WithSummary("Create a new PlayList.") + .WithSummary("Create A New PlayList.") .AddEndpointFilter>(); group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) .WithName("UpdatePlayList") - .WithSummary("Update an existing PlayList.") + .WithSummary("Update An Existing PlayList.") .AddEndpointFilter>(); group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) .WithName("DeletePlayList") - .WithSummary("Delete a PlayList."); + .WithSummary("Delete A PlayList."); group.MapPost("/upload-picture/users/{userId}", 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>(); return app; diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 121466d..ac63e24 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -16,28 +16,28 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get paginated Tracks."); + .WithSummary("Get Paginated Tracks."); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") - .WithSummary("Get a Track by Id."); + .WithSummary("Get A Track By Id."); group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") - .WithSummary("Get paginated Tracks for a User."); + .WithSummary("Get Paginated Tracks For A User."); group.MapPost("/users/{userId}", CreateTrack) .WithName("CreateTrack") - .WithSummary("Create a new Track.") + .WithSummary("Create A New Track.") .AddEndpointFilter>(); group.MapDelete("/{trackId}/users/{userId}", DeleteTrack) .WithName("DeleteTrack") - .WithSummary("Delete a Track."); + .WithSummary("Delete A Track."); group.MapPost("/upload-urls/users/{userId}", 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>(); return app; diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 9557c58..63bb430 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -16,15 +16,15 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .WithName("GetUsers") - .WithSummary("Get paginated Users."); + .WithSummary("Get Paginated Users."); group.MapGet("/{id}", GetUserById) .WithName("GetUserById") - .WithSummary("Get a User by Id."); + .WithSummary("Get A User By Id."); group.MapPost("/", CreateUser) .WithName("CreateUser") - .WithSummary("Create a new User.") + .WithSummary("Create A New User.") .AddEndpointFilter>(); return app; From b931958b166f869fe45dddfe0ff3264532d3b881 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 13:37:34 +0200 Subject: [PATCH 16/33] Added tests --- Musify.WebApi.slnx | 3 + .../Extensions/ResultHttpExtensionsTests.cs | 122 ++++++++++ .../Integration/ApiIntegrationTests.cs | 136 +++++++++++ .../Integration/MusifyApiFactory.cs | 82 +++++++ .../Musify.WebApi.Tests.csproj | 37 +++ .../Validators/ValidatorTests.cs | 215 ++++++++++++++++++ WebApi/Program.cs | 3 + 7 files changed, 598 insertions(+) create mode 100644 Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs create mode 100644 Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs create mode 100644 Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs create mode 100644 Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj create mode 100644 Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs diff --git a/Musify.WebApi.slnx b/Musify.WebApi.slnx index c239259..5a8e7b9 100644 --- a/Musify.WebApi.slnx +++ b/Musify.WebApi.slnx @@ -1,3 +1,6 @@ + + + diff --git a/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs b/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs new file mode 100644 index 0000000..7aa73d9 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs @@ -0,0 +1,122 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using WebApi.Extensions; +using Xunit; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Musify.WebApi.Tests.Extensions; + +public sealed class ResultHttpExtensionsTests +{ + private static int StatusCodeOf(IResult result) => + ((IStatusCodeHttpResult)result).StatusCode ?? 0; + + [Fact] + public void Result_Ok_maps_to_204_no_content() + { + StatusCodeOf(Result.Success().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); + } + + [Fact] + public void Result_NoContent_maps_to_204() + { + StatusCodeOf(Result.NoContent().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); + } + + [Fact] + public void Result_NotFound_maps_to_404() + { + StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void Result_Invalid_maps_to_validation_problem_400() + { + var result = Result.Invalid(new ValidationError("field", "bad")).ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status400BadRequest); + } + + [Fact] + public void Result_Unauthorized_maps_to_401() + { + StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public void Result_Forbidden_maps_to_403() + { + StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public void Result_Conflict_maps_to_409() + { + StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public void Result_Error_maps_to_problem_500() + { + var result = Result.Error("boom").ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); + } + + [Fact] + public void ResultT_Ok_maps_to_200_with_value() + { + var result = Result.Success("payload").ToHttpResult(); + result.Should().BeOfType>(); + result.As>().Value.Should().Be("payload"); + } + + [Fact] + public void ResultT_NotFound_maps_to_404() + { + StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void ResultT_Invalid_maps_to_validation_problem_with_grouped_errors() + { + var result = Result.Invalid( + new ValidationError("Name", "required"), + new ValidationError("Name", "too short"), + new ValidationError("Age", "invalid")).ToHttpResult(); + + result.Should().BeOfType(); + var details = result.As().ProblemDetails.Should().BeOfType().Subject; + details.Errors.Should().ContainKey("Name"); + details.Errors["Name"].Should().BeEquivalentTo("required", "too short"); + details.Errors.Should().ContainKey("Age"); + } + + [Fact] + public void ResultT_Unauthorized_maps_to_401() + { + StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public void ResultT_Forbidden_maps_to_403() + { + StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public void ResultT_Conflict_maps_to_409() + { + StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public void ResultT_Error_maps_to_problem_500() + { + var result = Result.Error("boom").ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); + } +} diff --git a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs new file mode 100644 index 0000000..bfac0f0 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Users.Responses; +using NSubstitute; +using WebApi.DataTransferObjects.PlayLists; +using WebApi.DataTransferObjects.Tracks; +using WebApi.DataTransferObjects.Users; +using Xunit; + +namespace Musify.WebApi.Tests.Integration; + +public sealed class ApiIntegrationTests(MusifyApiFactory factory) + : IClassFixture +{ + private sealed record ValidationProblemResponse(Dictionary Errors); + + private sealed record TrackUploadUrlsLikeResponse(string PictureUploadUrl, string AudioUploadUrl); + + private readonly HttpClient _client = factory.CreateClient(); + + private async Task CreateUserAsync(string name = "IntegrationUser") + { + var id = Guid.NewGuid(); + var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, name, null, null)); + response.StatusCode.Should().Be(HttpStatusCode.Created); + return id; + } + + [Fact] + public async Task Create_then_get_user_roundtrips_through_the_real_pipeline() + { + var id = Guid.NewGuid(); + + var create = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Alice", "Al", "Ice")); + create.StatusCode.Should().Be(HttpStatusCode.Created); + + var get = await _client.GetAsync($"/users/{id}"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + var user = await get.Content.ReadFromJsonAsync(); + user!.Id.Should().Be(id); + user.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Create_user_with_invalid_body_returns_400_validation_problem() + { + var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(Guid.Empty, "", null, null)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var problem = await response.Content.ReadFromJsonAsync(); + problem!.Errors.Should().ContainKey(nameof(CreateUserRequest.Name)); + } + + [Fact] + public async Task Create_duplicate_user_returns_409_conflict() + { + var id = await CreateUserAsync(); + + var duplicate = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Other", null, null)); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Get_unknown_user_returns_404() + { + var response = await _client.GetAsync($"/users/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Create_playlist_without_picture_returns_201_and_persists() + { + var userId = await CreateUserAsync(); + + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{userId}", + new CreatePlayListRequest("My Mix", "A description", null)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var playList = await response.Content.ReadFromJsonAsync(); + playList!.Name.Should().Be("My Mix"); + + var get = await _client.GetAsync($"/playLists/{playList.Id}"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Create_playlist_for_unknown_user_returns_404() + { + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{Guid.NewGuid()}", + new CreatePlayListRequest("Mix", "desc", null)); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Create_playlist_with_invalid_body_returns_400() + { + var userId = await CreateUserAsync(); + + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{userId}", + new CreatePlayListRequest("", "", null)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Get_unknown_track_returns_404() + { + var response = await _client.GetAsync($"/tracks/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Request_track_upload_urls_issues_presigned_urls() + { + var userId = await CreateUserAsync(); + factory.Storage + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/put"); + + var response = await _client.PostAsJsonAsync( + $"/tracks/upload-urls/users/{userId}", + new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + body!.PictureUploadUrl.Should().Be("https://signed.example/put"); + body.AudioUploadUrl.Should().Be("https://signed.example/put"); + } +} diff --git a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs new file mode 100644 index 0000000..6085f16 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Musify.Application.Contracts; +using Musify.Application.Tests.Infrastructure; +using NSubstitute; + +namespace Musify.WebApi.Tests.Integration; + +public sealed class MusifyApiFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:;Foreign Keys=False"); + + public IStorageService Storage { get; } = Substitute.For(); + public IEventBus EventBus { get; } = Substitute.For(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=test;Password=test", + ["ApplicationStorage:Bucket"] = TestData.Bucket, + ["PlayList:Routes:ParentFolders"] = "PlayLists", + ["Track:Routes:ParentFolder"] = "Tracks", + ["UploadIntent:TempRootPrefix"] = "temp", + ["InfrastructureStorage:Address"] = "http://localhost:9000", + ["InfrastructureStorage:AccessKey"] = "test", + ["InfrastructureStorage:SecretAccessKey"] = "test", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["MassTransit:Address"] = "rabbitmq://localhost", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + } ); + } ); + + builder.ConfigureTestServices(services => + { + // Never start the RabbitMQ bus, EF outbox delivery or OTLP exporter in tests. + services.RemoveAll(); + + _connection.Open(); + + // Replace the Npgsql-backed IDatabase with a shared SQLite TestDatabase. + services.RemoveAll(); + services.AddScoped(_ => + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + var db = new TestDatabase(options); + db.Database.EnsureCreated(); + return db; + }); + + // Replace S3 and MassTransit with controllable test doubles. + services.RemoveAll(); + services.AddScoped(_ => Storage); + + services.RemoveAll(); + services.AddScoped(_ => EventBus); + } ); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + _connection.Dispose(); + } +} diff --git a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj new file mode 100644 index 0000000..8b5774e --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + Musify.WebApi.Tests + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs new file mode 100644 index 0000000..dfca788 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs @@ -0,0 +1,215 @@ +using FluentValidation.TestHelper; +using WebApi.DataTransferObjects.PlayLists; +using WebApi.DataTransferObjects.Tracks; +using WebApi.DataTransferObjects.Users; +using WebApi.Validators.PlayLists; +using WebApi.Validators.Tracks; +using WebApi.Validators.Users; +using Xunit; + +namespace Musify.WebApi.Tests.Validators; + +public sealed class CreateTrackRequestValidatorTests +{ + private readonly CreateTrackRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Empty_title_fails(string? title) + { + var model = new CreateTrackRequest(title!, Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Title_longer_than_50_fails() + { + var model = new CreateTrackRequest(new string('a', 51), Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Empty_intent_ids_fail() + { + var model = new CreateTrackRequest("Song", Guid.Empty, Guid.Empty); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.PictureIntentId); + result.ShouldHaveValidationErrorFor(x => x.AudioIntentId); + } +} + +public sealed class RequestTrackUploadUrlsRequestValidatorTests +{ + private readonly RequestTrackUploadUrlsRequestValidator _validator = new(); + + private static RequestTrackUploadUrlsRequest Valid() => + new("png", "image/png", "mp3", "audio/mpeg"); + + [Fact] + public void Valid_request_passes() + { + _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Missing_required_fields_fail() + { + var model = new RequestTrackUploadUrlsRequest("", "", "", ""); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.PictureFileType); + result.ShouldHaveValidationErrorFor(x => x.PictureContentType); + result.ShouldHaveValidationErrorFor(x => x.AudioFileType); + result.ShouldHaveValidationErrorFor(x => x.AudioContentType); + } + + [Fact] + public void Non_positive_expected_sizes_fail_when_provided() + { + var model = Valid() with { ExpectedPictureSizeBytes = 0, ExpectedAudioSizeBytes = -1 }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); + result.ShouldHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); + } + + [Fact] + public void Null_expected_sizes_are_allowed() + { + var model = Valid() with { ExpectedPictureSizeBytes = null, ExpectedAudioSizeBytes = null }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); + result.ShouldNotHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); + } +} + +public sealed class CreatePlayListRequestValidatorTests +{ + private readonly CreatePlayListRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreatePlayListRequest("My List", "A description", null); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_name_and_description_fail() + { + var model = new CreatePlayListRequest("", "", null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + result.ShouldHaveValidationErrorFor(x => x.Description); + } + + [Fact] + public void Over_length_name_and_description_fail() + { + var model = new CreatePlayListRequest(new string('n', 51), new string('d', 257), null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + result.ShouldHaveValidationErrorFor(x => x.Description); + } +} + +public sealed class UpdatePlayListRequestValidatorTests +{ + private readonly UpdatePlayListRequestValidator _validator = new(); + + [Fact] + public void All_null_is_valid() + { + _validator.TestValidate(new UpdatePlayListRequest(null, null, null)) + .ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Over_length_values_fail_only_when_provided() + { + var model = new UpdatePlayListRequest(new string('n', 51), new string('d', 257), null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewName); + result.ShouldHaveValidationErrorFor(x => x.NewDescription); + } + + [Fact] + public void Empty_string_name_is_allowed_by_length_rule() + { + // Only a MaximumLength rule applies, so an empty (non-null) string passes. + var model = new UpdatePlayListRequest("", null, null); + _validator.TestValidate(model).ShouldNotHaveValidationErrorFor(x => x.NewName); + } +} + +public sealed class RequestPlayListPictureUploadRequestValidatorTests +{ + private readonly RequestPlayListPictureUploadRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp")) + .ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Missing_filetype_and_contenttype_fail() + { + var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("", "")); + result.ShouldHaveValidationErrorFor(x => x.FileType); + result.ShouldHaveValidationErrorFor(x => x.ContentType); + } + + [Fact] + public void Non_positive_size_fails_when_provided() + { + var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp", 0)); + result.ShouldHaveValidationErrorFor(x => x.ExpectedSizeBytes); + } +} + +public sealed class CreateUserRequestValidatorTests +{ + private readonly CreateUserRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", "Al", "Ice"); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_id_and_name_fail() + { + var model = new CreateUserRequest(Guid.Empty, "", null, null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Id); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Over_length_optional_names_fail_only_when_provided() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", new string('f', 49), new string('s', 49)); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.FirstName); + result.ShouldHaveValidationErrorFor(x => x.SecondName); + } + + [Fact] + public void Null_optional_names_are_allowed() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", null, null); + var result = _validator.TestValidate(model); + result.ShouldNotHaveValidationErrorFor(x => x.FirstName); + result.ShouldNotHaveValidationErrorFor(x => x.SecondName); + } +} diff --git a/WebApi/Program.cs b/WebApi/Program.cs index eef071d..03be246 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -23,3 +23,6 @@ app .MapUserEndpoints(); app.Run(); + +// Exposed so WebApplicationFactory can bootstrap the app in integration tests. +public partial class Program { } From 48662d6adf2cb5bc1745149e8a9bcb386f045240 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 13:49:49 +0200 Subject: [PATCH 17/33] Small changes --- .../Integration/ApiIntegrationTests.cs | 20 +++++++++++-------- .../Integration/MusifyApiFactory.cs | 3 --- .../Musify.WebApi.Tests.csproj | 3 --- .../Validators/ValidatorTests.cs | 1 - WebApi/Program.cs | 1 - 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs index bfac0f0..af7aae9 100644 --- a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs +++ b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs @@ -38,8 +38,9 @@ public sealed class ApiIntegrationTests(MusifyApiFactory factory) var get = await _client.GetAsync($"/users/{id}"); get.StatusCode.Should().Be(HttpStatusCode.OK); - var user = await get.Content.ReadFromJsonAsync(); - user!.Id.Should().Be(id); + UserApplicationResponse user = await get.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("The response body was null."); + user.Id.Should().Be(id); user.Name.Should().Be("Alice"); } @@ -49,8 +50,9 @@ public sealed class ApiIntegrationTests(MusifyApiFactory factory) var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(Guid.Empty, "", null, null)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - var problem = await response.Content.ReadFromJsonAsync(); - problem!.Errors.Should().ContainKey(nameof(CreateUserRequest.Name)); + ValidationProblemResponse problem = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("The response body was null."); + problem.Errors.Should().ContainKey(nameof(CreateUserRequest.Name)); } [Fact] @@ -80,8 +82,9 @@ public sealed class ApiIntegrationTests(MusifyApiFactory factory) new CreatePlayListRequest("My Mix", "A description", null)); response.StatusCode.Should().Be(HttpStatusCode.Created); - var playList = await response.Content.ReadFromJsonAsync(); - playList!.Name.Should().Be("My Mix"); + PlayListApplicationResponse playList = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("The response body was null."); + playList.Name.Should().Be("My Mix"); var get = await _client.GetAsync($"/playLists/{playList.Id}"); get.StatusCode.Should().Be(HttpStatusCode.OK); @@ -129,8 +132,9 @@ public sealed class ApiIntegrationTests(MusifyApiFactory factory) new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg")); response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadFromJsonAsync(); - body!.PictureUploadUrl.Should().Be("https://signed.example/put"); + TrackUploadUrlsLikeResponse body = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("The response body was null."); + body.PictureUploadUrl.Should().Be("https://signed.example/put"); body.AudioUploadUrl.Should().Be("https://signed.example/put"); } } diff --git a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs index 6085f16..71cb896 100644 --- a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs +++ b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs @@ -47,12 +47,10 @@ public sealed class MusifyApiFactory : WebApplicationFactory builder.ConfigureTestServices(services => { - // Never start the RabbitMQ bus, EF outbox delivery or OTLP exporter in tests. services.RemoveAll(); _connection.Open(); - // Replace the Npgsql-backed IDatabase with a shared SQLite TestDatabase. services.RemoveAll(); services.AddScoped(_ => { @@ -64,7 +62,6 @@ public sealed class MusifyApiFactory : WebApplicationFactory return db; }); - // Replace S3 and MassTransit with controllable test doubles. services.RemoveAll(); services.AddScoped(_ => Storage); diff --git a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj index 8b5774e..356c23c 100644 --- a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj +++ b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj @@ -8,8 +8,6 @@ Musify.WebApi.Tests - @@ -30,7 +28,6 @@ - diff --git a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs index dfca788..046f9ed 100644 --- a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs +++ b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs @@ -142,7 +142,6 @@ public sealed class UpdatePlayListRequestValidatorTests [Fact] public void Empty_string_name_is_allowed_by_length_rule() { - // Only a MaximumLength rule applies, so an empty (non-null) string passes. var model = new UpdatePlayListRequest("", null, null); _validator.TestValidate(model).ShouldNotHaveValidationErrorFor(x => x.NewName); } diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 03be246..96097c7 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -24,5 +24,4 @@ app app.Run(); -// Exposed so WebApplicationFactory can bootstrap the app in integration tests. public partial class Program { } From 4ad6e8a20ff81beae0ee067da4115d039459fc67 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 15:47:38 +0200 Subject: [PATCH 18/33] Remove tests --- Musify.WebApi.slnx | 3 - .../Extensions/ResultHttpExtensionsTests.cs | 122 ---------- .../Integration/ApiIntegrationTests.cs | 140 ------------ .../Integration/MusifyApiFactory.cs | 79 ------- .../Musify.WebApi.Tests.csproj | 34 --- .../Validators/ValidatorTests.cs | 214 ------------------ 6 files changed, 592 deletions(-) delete mode 100644 Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs delete mode 100644 Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs delete mode 100644 Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs delete mode 100644 Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj delete mode 100644 Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs diff --git a/Musify.WebApi.slnx b/Musify.WebApi.slnx index 5a8e7b9..c239259 100644 --- a/Musify.WebApi.slnx +++ b/Musify.WebApi.slnx @@ -1,6 +1,3 @@ - - - diff --git a/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs b/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs deleted file mode 100644 index 7aa73d9..0000000 --- a/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Ardalis.Result; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using WebApi.Extensions; -using Xunit; -using IResult = Microsoft.AspNetCore.Http.IResult; - -namespace Musify.WebApi.Tests.Extensions; - -public sealed class ResultHttpExtensionsTests -{ - private static int StatusCodeOf(IResult result) => - ((IStatusCodeHttpResult)result).StatusCode ?? 0; - - [Fact] - public void Result_Ok_maps_to_204_no_content() - { - StatusCodeOf(Result.Success().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); - } - - [Fact] - public void Result_NoContent_maps_to_204() - { - StatusCodeOf(Result.NoContent().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); - } - - [Fact] - public void Result_NotFound_maps_to_404() - { - StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public void Result_Invalid_maps_to_validation_problem_400() - { - var result = Result.Invalid(new ValidationError("field", "bad")).ToHttpResult(); - result.Should().BeOfType(); - StatusCodeOf(result).Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public void Result_Unauthorized_maps_to_401() - { - StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public void Result_Forbidden_maps_to_403() - { - StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); - } - - [Fact] - public void Result_Conflict_maps_to_409() - { - StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); - } - - [Fact] - public void Result_Error_maps_to_problem_500() - { - var result = Result.Error("boom").ToHttpResult(); - result.Should().BeOfType(); - StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); - } - - [Fact] - public void ResultT_Ok_maps_to_200_with_value() - { - var result = Result.Success("payload").ToHttpResult(); - result.Should().BeOfType>(); - result.As>().Value.Should().Be("payload"); - } - - [Fact] - public void ResultT_NotFound_maps_to_404() - { - StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public void ResultT_Invalid_maps_to_validation_problem_with_grouped_errors() - { - var result = Result.Invalid( - new ValidationError("Name", "required"), - new ValidationError("Name", "too short"), - new ValidationError("Age", "invalid")).ToHttpResult(); - - result.Should().BeOfType(); - var details = result.As().ProblemDetails.Should().BeOfType().Subject; - details.Errors.Should().ContainKey("Name"); - details.Errors["Name"].Should().BeEquivalentTo("required", "too short"); - details.Errors.Should().ContainKey("Age"); - } - - [Fact] - public void ResultT_Unauthorized_maps_to_401() - { - StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public void ResultT_Forbidden_maps_to_403() - { - StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); - } - - [Fact] - public void ResultT_Conflict_maps_to_409() - { - StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); - } - - [Fact] - public void ResultT_Error_maps_to_problem_500() - { - var result = Result.Error("boom").ToHttpResult(); - result.Should().BeOfType(); - StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); - } -} diff --git a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs deleted file mode 100644 index af7aae9..0000000 --- a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using Musify.Application.PlayLists.Responses; -using Musify.Application.Users.Responses; -using NSubstitute; -using WebApi.DataTransferObjects.PlayLists; -using WebApi.DataTransferObjects.Tracks; -using WebApi.DataTransferObjects.Users; -using Xunit; - -namespace Musify.WebApi.Tests.Integration; - -public sealed class ApiIntegrationTests(MusifyApiFactory factory) - : IClassFixture -{ - private sealed record ValidationProblemResponse(Dictionary Errors); - - private sealed record TrackUploadUrlsLikeResponse(string PictureUploadUrl, string AudioUploadUrl); - - private readonly HttpClient _client = factory.CreateClient(); - - private async Task CreateUserAsync(string name = "IntegrationUser") - { - var id = Guid.NewGuid(); - var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, name, null, null)); - response.StatusCode.Should().Be(HttpStatusCode.Created); - return id; - } - - [Fact] - public async Task Create_then_get_user_roundtrips_through_the_real_pipeline() - { - var id = Guid.NewGuid(); - - var create = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Alice", "Al", "Ice")); - create.StatusCode.Should().Be(HttpStatusCode.Created); - - var get = await _client.GetAsync($"/users/{id}"); - get.StatusCode.Should().Be(HttpStatusCode.OK); - UserApplicationResponse user = await get.Content.ReadFromJsonAsync() - ?? throw new InvalidOperationException("The response body was null."); - user.Id.Should().Be(id); - user.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Create_user_with_invalid_body_returns_400_validation_problem() - { - var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(Guid.Empty, "", null, null)); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ValidationProblemResponse problem = await response.Content.ReadFromJsonAsync() - ?? throw new InvalidOperationException("The response body was null."); - problem.Errors.Should().ContainKey(nameof(CreateUserRequest.Name)); - } - - [Fact] - public async Task Create_duplicate_user_returns_409_conflict() - { - var id = await CreateUserAsync(); - - var duplicate = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Other", null, null)); - - duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task Get_unknown_user_returns_404() - { - var response = await _client.GetAsync($"/users/{Guid.NewGuid()}"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Create_playlist_without_picture_returns_201_and_persists() - { - var userId = await CreateUserAsync(); - - var response = await _client.PostAsJsonAsync( - $"/playLists/users/{userId}", - new CreatePlayListRequest("My Mix", "A description", null)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - PlayListApplicationResponse playList = await response.Content.ReadFromJsonAsync() - ?? throw new InvalidOperationException("The response body was null."); - playList.Name.Should().Be("My Mix"); - - var get = await _client.GetAsync($"/playLists/{playList.Id}"); - get.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Create_playlist_for_unknown_user_returns_404() - { - var response = await _client.PostAsJsonAsync( - $"/playLists/users/{Guid.NewGuid()}", - new CreatePlayListRequest("Mix", "desc", null)); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Create_playlist_with_invalid_body_returns_400() - { - var userId = await CreateUserAsync(); - - var response = await _client.PostAsJsonAsync( - $"/playLists/users/{userId}", - new CreatePlayListRequest("", "", null)); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Get_unknown_track_returns_404() - { - var response = await _client.GetAsync($"/tracks/{Guid.NewGuid()}"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Request_track_upload_urls_issues_presigned_urls() - { - var userId = await CreateUserAsync(); - factory.Storage - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://signed.example/put"); - - var response = await _client.PostAsJsonAsync( - $"/tracks/upload-urls/users/{userId}", - new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - TrackUploadUrlsLikeResponse body = await response.Content.ReadFromJsonAsync() - ?? throw new InvalidOperationException("The response body was null."); - body.PictureUploadUrl.Should().Be("https://signed.example/put"); - body.AudioUploadUrl.Should().Be("https://signed.example/put"); - } -} diff --git a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs deleted file mode 100644 index 71cb896..0000000 --- a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; -using Musify.Application.Contracts; -using Musify.Application.Tests.Infrastructure; -using NSubstitute; - -namespace Musify.WebApi.Tests.Integration; - -public sealed class MusifyApiFactory : WebApplicationFactory -{ - private readonly SqliteConnection _connection = new("DataSource=:memory:;Foreign Keys=False"); - - public IStorageService Storage { get; } = Substitute.For(); - public IEventBus EventBus { get; } = Substitute.For(); - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Testing"); - - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=test;Password=test", - ["ApplicationStorage:Bucket"] = TestData.Bucket, - ["PlayList:Routes:ParentFolders"] = "PlayLists", - ["Track:Routes:ParentFolder"] = "Tracks", - ["UploadIntent:TempRootPrefix"] = "temp", - ["InfrastructureStorage:Address"] = "http://localhost:9000", - ["InfrastructureStorage:AccessKey"] = "test", - ["InfrastructureStorage:SecretAccessKey"] = "test", - ["InfrastructureStorage:ForcePathStyle"] = "true", - ["InfrastructureStorage:UseHttp"] = "true", - ["MassTransit:Address"] = "rabbitmq://localhost", - ["MassTransit:Username"] = "guest", - ["MassTransit:Password"] = "guest", - ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", - } ); - } ); - - builder.ConfigureTestServices(services => - { - services.RemoveAll(); - - _connection.Open(); - - services.RemoveAll(); - services.AddScoped(_ => - { - var options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - var db = new TestDatabase(options); - db.Database.EnsureCreated(); - return db; - }); - - services.RemoveAll(); - services.AddScoped(_ => Storage); - - services.RemoveAll(); - services.AddScoped(_ => EventBus); - } ); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (disposing) - _connection.Dispose(); - } -} diff --git a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj deleted file mode 100644 index 356c23c..0000000 --- a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - net10.0 - enable - enable - false - Musify.WebApi.Tests - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - diff --git a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs deleted file mode 100644 index 046f9ed..0000000 --- a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -using FluentValidation.TestHelper; -using WebApi.DataTransferObjects.PlayLists; -using WebApi.DataTransferObjects.Tracks; -using WebApi.DataTransferObjects.Users; -using WebApi.Validators.PlayLists; -using WebApi.Validators.Tracks; -using WebApi.Validators.Users; -using Xunit; - -namespace Musify.WebApi.Tests.Validators; - -public sealed class CreateTrackRequestValidatorTests -{ - private readonly CreateTrackRequestValidator _validator = new(); - - [Fact] - public void Valid_request_passes() - { - var model = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid()); - _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); - } - - [Theory] - [InlineData("")] - [InlineData(null)] - public void Empty_title_fails(string? title) - { - var model = new CreateTrackRequest(title!, Guid.NewGuid(), Guid.NewGuid()); - _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); - } - - [Fact] - public void Title_longer_than_50_fails() - { - var model = new CreateTrackRequest(new string('a', 51), Guid.NewGuid(), Guid.NewGuid()); - _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); - } - - [Fact] - public void Empty_intent_ids_fail() - { - var model = new CreateTrackRequest("Song", Guid.Empty, Guid.Empty); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.PictureIntentId); - result.ShouldHaveValidationErrorFor(x => x.AudioIntentId); - } -} - -public sealed class RequestTrackUploadUrlsRequestValidatorTests -{ - private readonly RequestTrackUploadUrlsRequestValidator _validator = new(); - - private static RequestTrackUploadUrlsRequest Valid() => - new("png", "image/png", "mp3", "audio/mpeg"); - - [Fact] - public void Valid_request_passes() - { - _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Missing_required_fields_fail() - { - var model = new RequestTrackUploadUrlsRequest("", "", "", ""); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.PictureFileType); - result.ShouldHaveValidationErrorFor(x => x.PictureContentType); - result.ShouldHaveValidationErrorFor(x => x.AudioFileType); - result.ShouldHaveValidationErrorFor(x => x.AudioContentType); - } - - [Fact] - public void Non_positive_expected_sizes_fail_when_provided() - { - var model = Valid() with { ExpectedPictureSizeBytes = 0, ExpectedAudioSizeBytes = -1 }; - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); - result.ShouldHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); - } - - [Fact] - public void Null_expected_sizes_are_allowed() - { - var model = Valid() with { ExpectedPictureSizeBytes = null, ExpectedAudioSizeBytes = null }; - var result = _validator.TestValidate(model); - result.ShouldNotHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); - result.ShouldNotHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); - } -} - -public sealed class CreatePlayListRequestValidatorTests -{ - private readonly CreatePlayListRequestValidator _validator = new(); - - [Fact] - public void Valid_request_passes() - { - var model = new CreatePlayListRequest("My List", "A description", null); - _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Empty_name_and_description_fail() - { - var model = new CreatePlayListRequest("", "", null); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.Name); - result.ShouldHaveValidationErrorFor(x => x.Description); - } - - [Fact] - public void Over_length_name_and_description_fail() - { - var model = new CreatePlayListRequest(new string('n', 51), new string('d', 257), null); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.Name); - result.ShouldHaveValidationErrorFor(x => x.Description); - } -} - -public sealed class UpdatePlayListRequestValidatorTests -{ - private readonly UpdatePlayListRequestValidator _validator = new(); - - [Fact] - public void All_null_is_valid() - { - _validator.TestValidate(new UpdatePlayListRequest(null, null, null)) - .ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Over_length_values_fail_only_when_provided() - { - var model = new UpdatePlayListRequest(new string('n', 51), new string('d', 257), null); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.NewName); - result.ShouldHaveValidationErrorFor(x => x.NewDescription); - } - - [Fact] - public void Empty_string_name_is_allowed_by_length_rule() - { - var model = new UpdatePlayListRequest("", null, null); - _validator.TestValidate(model).ShouldNotHaveValidationErrorFor(x => x.NewName); - } -} - -public sealed class RequestPlayListPictureUploadRequestValidatorTests -{ - private readonly RequestPlayListPictureUploadRequestValidator _validator = new(); - - [Fact] - public void Valid_request_passes() - { - _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp")) - .ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Missing_filetype_and_contenttype_fail() - { - var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("", "")); - result.ShouldHaveValidationErrorFor(x => x.FileType); - result.ShouldHaveValidationErrorFor(x => x.ContentType); - } - - [Fact] - public void Non_positive_size_fails_when_provided() - { - var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp", 0)); - result.ShouldHaveValidationErrorFor(x => x.ExpectedSizeBytes); - } -} - -public sealed class CreateUserRequestValidatorTests -{ - private readonly CreateUserRequestValidator _validator = new(); - - [Fact] - public void Valid_request_passes() - { - var model = new CreateUserRequest(Guid.NewGuid(), "Alice", "Al", "Ice"); - _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Empty_id_and_name_fail() - { - var model = new CreateUserRequest(Guid.Empty, "", null, null); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.Id); - result.ShouldHaveValidationErrorFor(x => x.Name); - } - - [Fact] - public void Over_length_optional_names_fail_only_when_provided() - { - var model = new CreateUserRequest(Guid.NewGuid(), "Alice", new string('f', 49), new string('s', 49)); - var result = _validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.FirstName); - result.ShouldHaveValidationErrorFor(x => x.SecondName); - } - - [Fact] - public void Null_optional_names_are_allowed() - { - var model = new CreateUserRequest(Guid.NewGuid(), "Alice", null, null); - var result = _validator.TestValidate(model); - result.ShouldNotHaveValidationErrorFor(x => x.FirstName); - result.ShouldNotHaveValidationErrorFor(x => x.SecondName); - } -} From 21f4855cc6e692f70fd903f089a9853f3f1450bf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 16:02:34 +0200 Subject: [PATCH 19/33] Added auth --- WebApi/AppSettings.Development.json | 10 +++ WebApi/AppSettings.json | 10 +++ .../AuthenticationConfiguration.cs | 37 +++++++++++ .../AuthenticationExtensions.cs | 31 +++++++++ .../Authentication/JwtBearerEventsHandler.cs | 25 +++++++ .../Authentication/JwtBearerOptionsSetup.cs | 37 +++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 7 +- WebApi/OpenApi/OpenApiExtensions.cs | 14 ++++ WebApi/OpenApi/OpenApiOptionsSetup.cs | 66 +++++++++++++++++++ WebApi/Program.cs | 3 + WebApi/Scalar/ScalarExtensions.cs | 11 ++++ WebApi/Scalar/ScalarOptionsSetup.cs | 30 +++++++++ WebApi/WebApi.csproj | 1 + 13 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 WebApi/Authentication/AuthenticationConfiguration.cs create mode 100644 WebApi/Authentication/AuthenticationExtensions.cs create mode 100644 WebApi/Authentication/JwtBearerEventsHandler.cs create mode 100644 WebApi/Authentication/JwtBearerOptionsSetup.cs create mode 100644 WebApi/OpenApi/OpenApiExtensions.cs create mode 100644 WebApi/OpenApi/OpenApiOptionsSetup.cs create mode 100644 WebApi/Scalar/ScalarExtensions.cs create mode 100644 WebApi/Scalar/ScalarOptionsSetup.cs diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index db29d78..1cf5284 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -5,6 +5,16 @@ "Microsoft.AspNetCore": "Warning" } }, + "Authentication": { + "MetadataAddress": "http://localhost:8080/.well-known/openid-configuration", + "IssuerAddress": "http://localhost:8080", + "AudienceAddress": "372825689755287555", + "ClientId": "372825689755287555", + "AuthorizationEndpoint": "http://localhost:8080/oauth/v2/authorize?prompt=login", + "TokenEndpoint": "http://localhost:8080/oauth/v2/token", + "Scopes": [ "openid", "profile", "email" ], + "RequireHttpsMetadata": false + }, "Database": { "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" }, diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index f80dc37..738056d 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -6,6 +6,16 @@ } }, "AllowedHosts": "*", + "Authentication": { + "MetadataAddress": "", + "IssuerAddress": "", + "AudienceAddress": "", + "ClientId": "", + "AuthorizationEndpoint": "", + "TokenEndpoint": "", + "Scopes": [], + "RequireHttpsMetadata": true + }, "Database": { "ConnectionString": "" }, diff --git a/WebApi/Authentication/AuthenticationConfiguration.cs b/WebApi/Authentication/AuthenticationConfiguration.cs new file mode 100644 index 0000000..206a21f --- /dev/null +++ b/WebApi/Authentication/AuthenticationConfiguration.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebApi.Authentication; + +public sealed class AuthenticationConfiguration +{ + public const string SectionName = "Authentication"; + + [Required] + [Url] + public required string MetadataAddress { get; init; } + + [Required] + public required string IssuerAddress { get; init; } + + [Required] + public required string AudienceAddress { get; init; } + + [Required] + public required string ClientId { get; init; } + + public string? ClientSecret { get; init; } + + [Required] + [Url] + public required string AuthorizationEndpoint { get; init; } + + [Required] + [Url] + public required string TokenEndpoint { get; init; } + + [Required] + [MinLength(1)] + public required string[] Scopes { get; init; } + + public bool RequireHttpsMetadata { get; init; } = true; +} diff --git a/WebApi/Authentication/AuthenticationExtensions.cs b/WebApi/Authentication/AuthenticationExtensions.cs new file mode 100644 index 0000000..bc2da3e --- /dev/null +++ b/WebApi/Authentication/AuthenticationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace WebApi.Authentication; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddAuthenticationConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + services.AddScoped(); + services.ConfigureOptions(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services.AddAuthorization(); + + return services; + } +} diff --git a/WebApi/Authentication/JwtBearerEventsHandler.cs b/WebApi/Authentication/JwtBearerEventsHandler.cs new file mode 100644 index 0000000..4819d10 --- /dev/null +++ b/WebApi/Authentication/JwtBearerEventsHandler.cs @@ -0,0 +1,25 @@ +using Mediator; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Musify.Application.Users.Commands; +using System.Security.Claims; + +namespace WebApi.Authentication; + +public sealed class JwtBearerEventsHandler(IMediator mediator) : JwtBearerEvents +{ + public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) + { + var principal = tokenValidatedContext.Principal!; + + var id = principal.FindFirstValue(ClaimTypes.NameIdentifier)!; + var guidId = Guid.Parse(id); + + var username = principal.FindFirstValue(ClaimTypes.Name)!; + var firstName = principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = principal.FindFirstValue(ClaimTypes.Surname); + + await mediator.Send( + new CreateUserCommand(guidId, username, firstName, lastName), + tokenValidatedContext.HttpContext.RequestAborted); + } +} diff --git a/WebApi/Authentication/JwtBearerOptionsSetup.cs b/WebApi/Authentication/JwtBearerOptionsSetup.cs new file mode 100644 index 0000000..ea16ba0 --- /dev/null +++ b/WebApi/Authentication/JwtBearerOptionsSetup.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; + +namespace WebApi.Authentication; + +public sealed class JwtBearerOptionsSetup(IOptions options) + : IConfigureNamedOptions +{ + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(string? name, JwtBearerOptions options) + { + if (name != JwtBearerDefaults.AuthenticationScheme) + { + return; + } + + Configure(options); + } + + public void Configure(JwtBearerOptions options) + { + options.MetadataAddress = _configuration.MetadataAddress; + options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata; + options.Audience = _configuration.AudienceAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = _configuration.IssuerAddress, + ValidAudience = _configuration.AudienceAddress, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + options.EventsType = typeof(JwtBearerEventsHandler); + } +} diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index 94fcc81..a5d8df8 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -3,7 +3,10 @@ using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Application.Configuration; using Musify.Infrastructure.Services; +using WebApi.Authentication; using WebApi.DataTransferObjects.PlayLists; +using WebApi.OpenApi; +using WebApi.Scalar; namespace WebApi.Extensions; @@ -19,7 +22,9 @@ public static class ServiceCollectionExtensions services.AddUploadIntentConfiguration(configuration); services.AddMassTransitClient(configuration); services.AddMediator(); - services.AddOpenApi(); + services.AddAuthenticationConfiguration(configuration); + services.AddOpenApiConfiguration(); + services.AddScalarConfiguration(); services.AddValidatorsFromAssemblyContaining(); return services; diff --git a/WebApi/OpenApi/OpenApiExtensions.cs b/WebApi/OpenApi/OpenApiExtensions.cs new file mode 100644 index 0000000..38c4372 --- /dev/null +++ b/WebApi/OpenApi/OpenApiExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.OpenApi; + +namespace WebApi.OpenApi; + +public static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); + + return services; + } +} diff --git a/WebApi/OpenApi/OpenApiOptionsSetup.cs b/WebApi/OpenApi/OpenApiOptionsSetup.cs new file mode 100644 index 0000000..57af47d --- /dev/null +++ b/WebApi/OpenApi/OpenApiOptionsSetup.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using WebApi.Authentication; + +namespace WebApi.OpenApi; + +public sealed class OpenApiOptionsSetup(IOptions options) + : IConfigureNamedOptions +{ + private const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(string? name, OpenApiOptions options) => Configure(options); + + public void Configure(OpenApiOptions options) + { + options.AddDocumentTransformer((document, _, _) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint), + TokenUrl = new Uri(_configuration.TokenEndpoint), + Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope), + }, + }, + }; + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, _) => + { + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + { + return Task.CompletedTask; + } + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + _configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } +} diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 96097c7..38f37e3 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -17,6 +17,9 @@ if (app.Environment.IsDevelopment()) app.MapScalarApiReference(); } +app.UseAuthentication(); +app.UseAuthorization(); + app .MapPlayListEndpoints() .MapTrackEndpoints() diff --git a/WebApi/Scalar/ScalarExtensions.cs b/WebApi/Scalar/ScalarExtensions.cs new file mode 100644 index 0000000..c499588 --- /dev/null +++ b/WebApi/Scalar/ScalarExtensions.cs @@ -0,0 +1,11 @@ +namespace WebApi.Scalar; + +public static class ScalarExtensions +{ + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); + + return services; + } +} diff --git a/WebApi/Scalar/ScalarOptionsSetup.cs b/WebApi/Scalar/ScalarOptionsSetup.cs new file mode 100644 index 0000000..ef18898 --- /dev/null +++ b/WebApi/Scalar/ScalarOptionsSetup.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using Scalar.AspNetCore; +using WebApi.Authentication; + +namespace WebApi.Scalar; + +public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions +{ + private const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(ScalarOptions scalarOptions) + { + scalarOptions + .WithTitle("Musify API") + .AddPreferredSecuritySchemes(SecuritySchemeId) + .AddAuthorizationCodeFlow(SecuritySchemeId, flow => + { + flow.ClientId = _configuration.ClientId; + flow.ClientSecret = _configuration.ClientSecret; + flow.AuthorizationUrl = _configuration.AuthorizationEndpoint; + flow.TokenUrl = _configuration.TokenEndpoint; + flow.Pkce = Pkce.Sha256; + flow.SelectedScopes = _configuration.Scopes; + }) + .HideModels(); + } +} diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index 4d6b30b..52caafc 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -15,6 +15,7 @@ + From 9e68e1290336e689cb6af73ac0696c059b1e0332 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 22:29:54 +0200 Subject: [PATCH 20/33] Current User --- WebApi/Authentication/CurrentUser.cs | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 WebApi/Authentication/CurrentUser.cs diff --git a/WebApi/Authentication/CurrentUser.cs b/WebApi/Authentication/CurrentUser.cs new file mode 100644 index 0000000..f5b8df2 --- /dev/null +++ b/WebApi/Authentication/CurrentUser.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using System.Security.Claims; + +namespace WebApi.Authentication; + +public sealed class CurrentUser : IBindableFromHttpContext +{ + private CurrentUser(ClaimsPrincipal principal) + { + Principal = principal; + var isAuthenticated = principal.Identity?.IsAuthenticated; + IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; + + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + Id = Guid.TryParse(rawId, out var id)? id : Guid.Empty; + + Username = principal.FindFirstValue(ClaimTypes.Name); + FirstName = principal.FindFirstValue(ClaimTypes.GivenName); + LastName = principal.FindFirstValue(ClaimTypes.Surname); + } + + public ClaimsPrincipal Principal { get; } + + public bool IsAuthenticated { get; } + + public Guid? Id { get; } + + public string? Username { get; } + + public string? FirstName { get; } + + public string? LastName { get; } + + public Guid RequiredId => Id + ?? throw new InvalidOperationException("The current request does not contain an authenticated user id."); + + public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); + + public string? FindClaim(string type) => Principal.FindFirstValue(type); + + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + ValueTask.FromResult(new CurrentUser(context.User)); +} \ No newline at end of file From 8b46547b20f6fa983b338499d562c0c0c1d8655f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 22:36:33 +0200 Subject: [PATCH 21/33] Use null --- WebApi/Authentication/CurrentUser.cs | 5 ++-- WebApi/Endpoints/PlayListEndpoints.cs | 37 +++++++++++++++------------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/WebApi/Authentication/CurrentUser.cs b/WebApi/Authentication/CurrentUser.cs index f5b8df2..0809fe4 100644 --- a/WebApi/Authentication/CurrentUser.cs +++ b/WebApi/Authentication/CurrentUser.cs @@ -12,7 +12,7 @@ public sealed class CurrentUser : IBindableFromHttpContext IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - Id = Guid.TryParse(rawId, out var id)? id : Guid.Empty; + Id = Guid.TryParse(rawId, out var id)? id : null; Username = principal.FindFirstValue(ClaimTypes.Name); FirstName = principal.FindFirstValue(ClaimTypes.GivenName); @@ -31,8 +31,7 @@ public sealed class CurrentUser : IBindableFromHttpContext public string? LastName { get; } - public Guid RequiredId => Id - ?? throw new InvalidOperationException("The current request does not contain an authenticated user id."); + public Guid RequiredId => Id.HasValue? Id.Value : throw new InvalidOperationException("User ID is required but not present."); public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index dcada56..e2a0b0f 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -1,6 +1,7 @@ using Mediator; using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Queries; +using WebApi.Authentication; using WebApi.DataTransferObjects.PlayLists; using WebApi.Extensions; using WebApi.Filters; @@ -26,24 +27,28 @@ public static class PlayListEndpoints .WithName("GetPlayListsByUserId") .WithSummary("Get Paginated PlayLists For A User."); - group.MapPost("/users/{userId}", CreatePlayList) + group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create A New PlayList.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .RequireAuthorization(); - group.MapPut("/{playlistId}/users/{userId}", UpdatePlayList) + group.MapPut("/{playlistId}", UpdatePlayList) .WithName("UpdatePlayList") .WithSummary("Update An Existing PlayList.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .RequireAuthorization(); - group.MapDelete("/{playlistId}/users/{userId}", DeletePlayList) + group.MapDelete("/{playlistId}", DeletePlayList) .WithName("DeletePlayList") - .WithSummary("Delete A PlayList."); + .WithSummary("Delete A PlayList.") + .RequireAuthorization(); - group.MapPost("/upload-picture/users/{userId}", RequestPlayListPictureUpload) + group.MapPost("/upload-picture", RequestPlayListPictureUpload) .WithName("RequestPlayListPictureUpload") .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .RequireAuthorization(); return app; } @@ -81,12 +86,12 @@ public static class PlayListEndpoints private static async Task CreatePlayList( IMediator mediator, - Guid userId, + CurrentUser currentUser, CreatePlayListRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new CreatePlayListCommand(userId, request.Name, request.Description, request.PictureIntentId), + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), cancellationToken); if (!result.IsSuccess) @@ -97,13 +102,13 @@ public static class PlayListEndpoints private static async Task UpdatePlayList( IMediator mediator, - Guid userId, + CurrentUser currentUser, Guid playlistId, UpdatePlayListRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdatePlayListCommand(userId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), + new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), cancellationToken); return result.ToHttpResult(); @@ -111,22 +116,22 @@ public static class PlayListEndpoints private static async Task DeletePlayList( IMediator mediator, - Guid userId, + CurrentUser currentUser, Guid playlistId, CancellationToken cancellationToken) { - var result = await mediator.Send(new DeletePlayListCommand(userId, playlistId), cancellationToken); + var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, playlistId), cancellationToken); return result.ToHttpResult(); } private static async Task RequestPlayListPictureUpload( IMediator mediator, - Guid userId, + CurrentUser currentUser, RequestPlayListPictureUploadRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new RequestPlayListPictureUploadCommand(userId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), cancellationToken); return result.ToHttpResult(); From 31b35bd681c9980341f9da319ffb72d63e7d0d1b Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 22:37:32 +0200 Subject: [PATCH 22/33] Use if condition --- WebApi/Authentication/CurrentUser.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WebApi/Authentication/CurrentUser.cs b/WebApi/Authentication/CurrentUser.cs index 0809fe4..1f198da 100644 --- a/WebApi/Authentication/CurrentUser.cs +++ b/WebApi/Authentication/CurrentUser.cs @@ -12,7 +12,8 @@ public sealed class CurrentUser : IBindableFromHttpContext IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - Id = Guid.TryParse(rawId, out var id)? id : null; + if (Guid.TryParse(rawId, out var id) && id != Guid.Empty) + Id = id; Username = principal.FindFirstValue(ClaimTypes.Name); FirstName = principal.FindFirstValue(ClaimTypes.GivenName); From f7969c875b8c9f6ba22977ccb373e87f41df0ddf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 22:37:51 +0200 Subject: [PATCH 23/33] ?? --- WebApi/Authentication/CurrentUser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebApi/Authentication/CurrentUser.cs b/WebApi/Authentication/CurrentUser.cs index 1f198da..f2921b6 100644 --- a/WebApi/Authentication/CurrentUser.cs +++ b/WebApi/Authentication/CurrentUser.cs @@ -32,7 +32,7 @@ public sealed class CurrentUser : IBindableFromHttpContext public string? LastName { get; } - public Guid RequiredId => Id.HasValue? Id.Value : throw new InvalidOperationException("User ID is required but not present."); + public Guid RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); From a5a05c1ed486da0f1960ea16e8a10fff543f4103 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 22:42:48 +0200 Subject: [PATCH 24/33] Update endpoint --- WebApi/Endpoints/TrackEndpoints.cs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index ac63e24..2ed4835 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -1,6 +1,7 @@ using Mediator; using Musify.Application.Tracks.Commands; using Musify.Application.Tracks.Queries; +using WebApi.Authentication; using WebApi.DataTransferObjects.Tracks; using WebApi.Extensions; using WebApi.Filters; @@ -26,19 +27,22 @@ public static class TrackEndpoints .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User."); - group.MapPost("/users/{userId}", CreateTrack) + group.MapPost("/", CreateTrack) .WithName("CreateTrack") .WithSummary("Create A New Track.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .RequireAuthorization(); - group.MapDelete("/{trackId}/users/{userId}", DeleteTrack) + group.MapDelete("/{trackId}", DeleteTrack) .WithName("DeleteTrack") - .WithSummary("Delete A Track."); + .WithSummary("Delete A Track.") + .RequireAuthorization(); - group.MapPost("/upload-urls/users/{userId}", RequestTrackUploadUrls) + group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") - .AddEndpointFilter>(); + .AddEndpointFilter>() + .RequireAuthorization(); return app; } @@ -76,12 +80,12 @@ public static class TrackEndpoints private static async Task CreateTrack( IMediator mediator, - Guid userId, + CurrentUser currentUser, CreateTrackRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateTrackCommand(userId, request.Title, request.PictureIntentId, request.AudioIntentId), + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), cancellationToken); if (!result.IsSuccess) @@ -92,23 +96,23 @@ public static class TrackEndpoints private static async Task DeleteTrack( IMediator mediator, - Guid userId, + CurrentUser currentUser, Guid trackId, CancellationToken cancellationToken) { - var result = await mediator.Send(new DeleteTrackCommand(userId, trackId), cancellationToken); + var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken); return result.ToHttpResult(); } private static async Task RequestTrackUploadUrls( IMediator mediator, - Guid userId, + CurrentUser currentUser, RequestTrackUploadUrlsRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( new RequestTrackUploadUrlsCommand( - userId, + currentUser.RequiredId, request.PictureFileType, request.PictureContentType, request.AudioFileType, From 66a2ccabd58f72f54b1af54655ba2dc3de599e74 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 30 May 2026 03:30:32 +0200 Subject: [PATCH 25/33] Proxy --- WebApi/AppSettings.Development.json | 10 ++++++++++ WebApi/AppSettings.json | 10 ++++++++++ WebApi/Endpoints/TrackEndpoints.cs | 15 +++++++++++++++ WebApi/Extensions/ServiceCollectionExtensions.cs | 2 ++ 4 files changed, 37 insertions(+) diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index 1cf5284..e06a6a9 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -33,6 +33,16 @@ "ApplicationStorage": { "Bucket": "webapi-storage" }, + "StreamGateway": { + "PublicBaseUrl": "http://localhost:8081", + "ManifestFileName": "manifest.mpd" + }, + "StreamTicket": { + "PrivateKeyPath": "D:\\weed\\stream_private.pem", + "Audience": "media-gateway", + "Issuer": "musify-webapi", + "TicketTtlSeconds": 3600 + }, "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index 738056d..d46af70 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -34,6 +34,16 @@ "ApplicationStorage": { "Bucket": "" }, + "StreamGateway": { + "PublicBaseUrl": "", + "ManifestFileName": "manifest.mpd" + }, + "StreamTicket": { + "PrivateKeyPath": "", + "Audience": "media-gateway", + "Issuer": "musify-webapi", + "TicketTtlSeconds": 3600 + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 2ed4835..4cd9839 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -23,6 +23,11 @@ public static class TrackEndpoints .WithName("GetTrackById") .WithSummary("Get A Track By Id."); + group.MapGet("/{id}/stream", GetTrackStream) + .WithName("GetTrackStream") + .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") + .RequireAuthorization(); + group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User."); @@ -66,6 +71,16 @@ public static class TrackEndpoints return result.ToHttpResult(); } + private static async Task GetTrackStream( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetTracksByUserId( IMediator mediator, Guid userId, diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index a5d8df8..cf1f816 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,9 @@ public static class ServiceCollectionExtensions services.AddApplicationStorageConfiguration(configuration); services.AddPlayListConfiguration(configuration); services.AddTrackConfiguration(configuration); + services.AddStreamGatewayConfiguration(configuration); services.AddStorageService(configuration); + services.AddStreamTicketService(configuration); services.AddUploadIntentConfiguration(configuration); services.AddMassTransitClient(configuration); services.AddMediator(); From 1bd8bb4bd617c8a693958652c9af327a98652da5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 30 May 2026 03:44:32 +0200 Subject: [PATCH 26/33] Fixes on scalar --- WebApi/AppSettings.Development.json | 1 + WebApi/AppSettings.json | 1 + .../AuthenticationConfiguration.cs | 20 ++++++++++--------- WebApi/Scalar/ScalarOptionsSetup.cs | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index e06a6a9..6929fd0 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -13,6 +13,7 @@ "AuthorizationEndpoint": "http://localhost:8080/oauth/v2/authorize?prompt=login", "TokenEndpoint": "http://localhost:8080/oauth/v2/token", "Scopes": [ "openid", "profile", "email" ], + "ScalarRedirectUri": "http://localhost:5111/scalar/", "RequireHttpsMetadata": false }, "Database": { diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index d46af70..9b9b209 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -14,6 +14,7 @@ "AuthorizationEndpoint": "", "TokenEndpoint": "", "Scopes": [], + "ScalarRedirectUri": "", "RequireHttpsMetadata": true }, "Database": { diff --git a/WebApi/Authentication/AuthenticationConfiguration.cs b/WebApi/Authentication/AuthenticationConfiguration.cs index 206a21f..49820ce 100644 --- a/WebApi/Authentication/AuthenticationConfiguration.cs +++ b/WebApi/Authentication/AuthenticationConfiguration.cs @@ -8,30 +8,32 @@ public sealed class AuthenticationConfiguration [Required] [Url] - public required string MetadataAddress { get; init; } + public required string MetadataAddress { get; set; } [Required] - public required string IssuerAddress { get; init; } + public required string IssuerAddress { get; set; } [Required] - public required string AudienceAddress { get; init; } + public required string AudienceAddress { get; set; } [Required] - public required string ClientId { get; init; } + public required string ClientId { get; set; } - public string? ClientSecret { get; init; } + public string? ClientSecret { get; set; } [Required] [Url] - public required string AuthorizationEndpoint { get; init; } + public required string AuthorizationEndpoint { get; set; } [Required] [Url] - public required string TokenEndpoint { get; init; } + public required string TokenEndpoint { get; set; } [Required] [MinLength(1)] - public required string[] Scopes { get; init; } + public required string[] Scopes { get; set; } - public bool RequireHttpsMetadata { get; init; } = true; + public string? ScalarRedirectUri { get; set; } + + public bool RequireHttpsMetadata { get; set; } = true; } diff --git a/WebApi/Scalar/ScalarOptionsSetup.cs b/WebApi/Scalar/ScalarOptionsSetup.cs index ef18898..b78ca39 100644 --- a/WebApi/Scalar/ScalarOptionsSetup.cs +++ b/WebApi/Scalar/ScalarOptionsSetup.cs @@ -24,7 +24,8 @@ public sealed class ScalarOptionsSetup(IOptions opt flow.TokenUrl = _configuration.TokenEndpoint; flow.Pkce = Pkce.Sha256; flow.SelectedScopes = _configuration.Scopes; - }) + flow.RedirectUri = _configuration.ScalarRedirectUri; + } ) .HideModels(); } } From 1e06f60a1bef30357e2e261aa72078bc39d55a92 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 31 May 2026 18:25:40 +0200 Subject: [PATCH 27/33] Use long --- WebApi/Authentication/CurrentUser.cs | 6 +-- .../Authentication/JwtBearerEventsHandler.cs | 42 +++++++++++++++---- .../Users/CreateUserRequest.cs | 2 +- WebApi/Endpoints/PlayListEndpoints.cs | 2 +- WebApi/Endpoints/TrackEndpoints.cs | 2 +- WebApi/Endpoints/UserEndpoints.cs | 2 +- WebApi/Scalar/ScalarOptionsSetup.cs | 1 + 7 files changed, 43 insertions(+), 14 deletions(-) diff --git a/WebApi/Authentication/CurrentUser.cs b/WebApi/Authentication/CurrentUser.cs index f2921b6..cedf335 100644 --- a/WebApi/Authentication/CurrentUser.cs +++ b/WebApi/Authentication/CurrentUser.cs @@ -12,7 +12,7 @@ public sealed class CurrentUser : IBindableFromHttpContext IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (Guid.TryParse(rawId, out var id) && id != Guid.Empty) + if (long.TryParse(rawId, out var id) && id != 0) Id = id; Username = principal.FindFirstValue(ClaimTypes.Name); @@ -24,7 +24,7 @@ public sealed class CurrentUser : IBindableFromHttpContext public bool IsAuthenticated { get; } - public Guid? Id { get; } + public long? Id { get; } public string? Username { get; } @@ -32,7 +32,7 @@ public sealed class CurrentUser : IBindableFromHttpContext public string? LastName { get; } - public Guid RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); + public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); diff --git a/WebApi/Authentication/JwtBearerEventsHandler.cs b/WebApi/Authentication/JwtBearerEventsHandler.cs index 4819d10..5891465 100644 --- a/WebApi/Authentication/JwtBearerEventsHandler.cs +++ b/WebApi/Authentication/JwtBearerEventsHandler.cs @@ -5,21 +5,49 @@ using System.Security.Claims; namespace WebApi.Authentication; -public sealed class JwtBearerEventsHandler(IMediator mediator) : JwtBearerEvents +public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger logger) : JwtBearerEvents { public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) { var principal = tokenValidatedContext.Principal!; - var id = principal.FindFirstValue(ClaimTypes.NameIdentifier)!; - var guidId = Guid.Parse(id); + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(rawId, out var userId)) + { + logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); + return; + } + + var username = principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? principal.FindFirstValue(ClaimTypes.Email) + ?? rawId!; - var username = principal.FindFirstValue(ClaimTypes.Name)!; var firstName = principal.FindFirstValue(ClaimTypes.GivenName); var lastName = principal.FindFirstValue(ClaimTypes.Surname); - await mediator.Send( - new CreateUserCommand(guidId, username, firstName, lastName), - tokenValidatedContext.HttpContext.RequestAborted); + try + { + await mediator.Send( + new CreateUserCommand(userId, username, firstName, lastName), + tokenValidatedContext.HttpContext.RequestAborted); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to provision user {UserId} during token validation", userId); + } + } + + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + logger.LogWarning(context.Exception, "JWT authentication failed"); + return Task.CompletedTask; + } + + public override Task Challenge(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure is not null) + logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); + return Task.CompletedTask; } } diff --git a/WebApi/DataTransferObjects/Users/CreateUserRequest.cs b/WebApi/DataTransferObjects/Users/CreateUserRequest.cs index 5937975..24c7442 100644 --- a/WebApi/DataTransferObjects/Users/CreateUserRequest.cs +++ b/WebApi/DataTransferObjects/Users/CreateUserRequest.cs @@ -1,7 +1,7 @@ namespace WebApi.DataTransferObjects.Users; public record CreateUserRequest( - Guid Id, + long Id, string Name, string? FirstName, string? SecondName); diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index e2a0b0f..36ca9d9 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -74,7 +74,7 @@ public static class PlayListEndpoints private static async Task GetPlayListsByUserId( IMediator mediator, - Guid userId, + long userId, CancellationToken cancellationToken, string? name, int pageNumber = 1, diff --git a/WebApi/Endpoints/TrackEndpoints.cs b/WebApi/Endpoints/TrackEndpoints.cs index 4cd9839..44ad9dc 100644 --- a/WebApi/Endpoints/TrackEndpoints.cs +++ b/WebApi/Endpoints/TrackEndpoints.cs @@ -83,7 +83,7 @@ public static class TrackEndpoints private static async Task GetTracksByUserId( IMediator mediator, - Guid userId, + long userId, CancellationToken cancellationToken, string? name, int pageNumber = 1, diff --git a/WebApi/Endpoints/UserEndpoints.cs b/WebApi/Endpoints/UserEndpoints.cs index 63bb430..c966c89 100644 --- a/WebApi/Endpoints/UserEndpoints.cs +++ b/WebApi/Endpoints/UserEndpoints.cs @@ -43,7 +43,7 @@ public static class UserEndpoints private static async Task GetUserById( IMediator mediator, - Guid id, + long id, CancellationToken cancellationToken) { var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); diff --git a/WebApi/Scalar/ScalarOptionsSetup.cs b/WebApi/Scalar/ScalarOptionsSetup.cs index b78ca39..add12dc 100644 --- a/WebApi/Scalar/ScalarOptionsSetup.cs +++ b/WebApi/Scalar/ScalarOptionsSetup.cs @@ -25,6 +25,7 @@ public sealed class ScalarOptionsSetup(IOptions opt flow.Pkce = Pkce.Sha256; flow.SelectedScopes = _configuration.Scopes; flow.RedirectUri = _configuration.ScalarRedirectUri; + flow.RefreshUrl = _configuration.TokenEndpoint; } ) .HideModels(); } From ad9080be6674bfa4946e2cd413ce3dd8a175ba62 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 31 May 2026 19:25:15 +0200 Subject: [PATCH 28/33] Return 400 for validation errors without identifier and enable permissive CORS in Development --- WebApi/Extensions/ResultHttpExtensions.cs | 2 +- WebApi/Program.cs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/WebApi/Extensions/ResultHttpExtensions.cs b/WebApi/Extensions/ResultHttpExtensions.cs index 6b3bb64..7858397 100644 --- a/WebApi/Extensions/ResultHttpExtensions.cs +++ b/WebApi/Extensions/ResultHttpExtensions.cs @@ -31,6 +31,6 @@ public static class ResultHttpExtensions private static Dictionary ToValidationDictionary(IEnumerable errors) => errors - .GroupBy(e => e.Identifier) + .GroupBy(e => string.IsNullOrEmpty(e.Identifier) ? "general" : e.Identifier) .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); } diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 38f37e3..ea49b4a 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -5,14 +5,23 @@ using WebApi.Extensions; var builder = WebApplication.CreateBuilder(); +const string devCorsPolicy = "dev-cors"; + builder.Services .AddApplicationServices(builder.Configuration) .AddObservability(builder.Configuration); +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); +} + var app = builder.Build(); if (app.Environment.IsDevelopment()) { + app.UseCors(devCorsPolicy); app.MapOpenApi(); app.MapScalarApiReference(); } @@ -25,6 +34,4 @@ app .MapTrackEndpoints() .MapUserEndpoints(); -app.Run(); - -public partial class Program { } +app.Run(); \ No newline at end of file From b11e945a2968422bb60776e51124c8495272334e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 1 Jun 2026 01:20:18 +0200 Subject: [PATCH 29/33] Add playlist endpoints to add and remove tracks --- WebApi/Endpoints/PlayListEndpoints.cs | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index 36ca9d9..b1fc110 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -50,6 +50,16 @@ public static class PlayListEndpoints .AddEndpointFilter>() .RequireAuthorization(); + group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) + .WithName("AddTrackToPlayList") + .WithSummary("Add A Track To A PlayList.") + .RequireAuthorization(); + + group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) + .WithName("RemoveTrackFromPlayList") + .WithSummary("Remove A Track From A PlayList.") + .RequireAuthorization(); + return app; } @@ -136,4 +146,32 @@ public static class PlayListEndpoints return result.ToHttpResult(); } + + private static async Task AddTrackToPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid playlistId, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task RemoveTrackFromPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid playlistId, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromPlayListCommand(currentUser.RequiredId, playlistId, trackId), + cancellationToken); + + return result.ToHttpResult(); + } } From 9f8abb2b6cc08fc14483cd42ee0c9b322ec2dd61 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 1 Jun 2026 01:47:47 +0200 Subject: [PATCH 30/33] Sync user profile (incl. picture) from token claims on validation --- WebApi/Authentication/JwtBearerEventsHandler.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WebApi/Authentication/JwtBearerEventsHandler.cs b/WebApi/Authentication/JwtBearerEventsHandler.cs index 5891465..0f2d665 100644 --- a/WebApi/Authentication/JwtBearerEventsHandler.cs +++ b/WebApi/Authentication/JwtBearerEventsHandler.cs @@ -25,16 +25,17 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger Date: Mon, 1 Jun 2026 18:54:26 +0200 Subject: [PATCH 31/33] Add endpoint to list paginated tracks of a playlist --- WebApi/Endpoints/PlayListEndpoints.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/WebApi/Endpoints/PlayListEndpoints.cs b/WebApi/Endpoints/PlayListEndpoints.cs index b1fc110..b413635 100644 --- a/WebApi/Endpoints/PlayListEndpoints.cs +++ b/WebApi/Endpoints/PlayListEndpoints.cs @@ -27,6 +27,10 @@ public static class PlayListEndpoints .WithName("GetPlayListsByUserId") .WithSummary("Get Paginated PlayLists For A User."); + group.MapGet("/{playlistId}/tracks", GetPlayListTracks) + .WithName("GetPlayListTracks") + .WithSummary("Get Paginated Tracks Of A PlayList."); + group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create A New PlayList.") @@ -94,6 +98,17 @@ public static class PlayListEndpoints return result.ToHttpResult(); } + private static async Task GetPlayListTracks( + IMediator mediator, + Guid playlistId, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + private static async Task CreatePlayList( IMediator mediator, CurrentUser currentUser, From 809f16530bd424f82c26dc93b3b4b6dc9d4f8048 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 2 Jun 2026 19:05:18 +0200 Subject: [PATCH 32/33] Point streaming config to single .m4a; drop DASH ffmpeg settings --- WebApi/AppSettings.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index 9b9b209..ed3bd07 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -37,7 +37,7 @@ }, "StreamGateway": { "PublicBaseUrl": "", - "ManifestFileName": "manifest.mpd" + "AudioFileName": "audio.m4a" }, "StreamTicket": { "PrivateKeyPath": "", @@ -98,15 +98,12 @@ "TranscodingTimeout": "00:05:00", "Ffmpeg": { "ExecutableName": "ffmpeg", - "ManifestFileName": "manifest.mpd", + "OutputFileName": "audio.m4a", "AudioCodec": "aac", "AudioBitrate": "128k", "AudioChannels": 2, "AudioSampleRate": 48000, "AudioProfile": "aac_low", - "SegmentDurationSeconds": 4, - "InitSegmentName": "init-stream$RepresentationID$.m4s", - "MediaSegmentName": "chunk-stream$RepresentationID$-$Number$.m4s", "AdditionalArguments": "" } }, From 108e63c7c2fa51dabfb43152e0c88bd8dadebfd6 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 03:19:56 +0200 Subject: [PATCH 33/33] chore: preserve local dev config before monorepo import --- WebApi/AppSettings.Development.json | 14 +++++++------- WebApi/Properties/launchSettings.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index 6929fd0..430b1ac 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -6,14 +6,14 @@ } }, "Authentication": { - "MetadataAddress": "http://localhost:8080/.well-known/openid-configuration", - "IssuerAddress": "http://localhost:8080", - "AudienceAddress": "372825689755287555", - "ClientId": "372825689755287555", - "AuthorizationEndpoint": "http://localhost:8080/oauth/v2/authorize?prompt=login", - "TokenEndpoint": "http://localhost:8080/oauth/v2/token", + "MetadataAddress": "http://192.168.0.120:8080/.well-known/openid-configuration", + "IssuerAddress": "http://192.168.0.120:8080", + "AudienceAddress": "375710146774499331", + "ClientId": "375710146774499331", + "AuthorizationEndpoint": "http://192.168.0.120:8080/oauth/v2/authorize?prompt=login", + "TokenEndpoint": "http://192.168.0.120:8080/oauth/v2/token", "Scopes": [ "openid", "profile", "email" ], - "ScalarRedirectUri": "http://localhost:5111/scalar/", + "ScalarRedirectUri": "http://192.168.0.120:5111/scalar/", "RequireHttpsMetadata": false }, "Database": { diff --git a/WebApi/Properties/launchSettings.json b/WebApi/Properties/launchSettings.json index a96b644..31f06a4 100644 --- a/WebApi/Properties/launchSettings.json +++ b/WebApi/Properties/launchSettings.json @@ -2,13 +2,13 @@ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5111", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://192.168.0.120:5111", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } }