diff --git a/Controller/ArtistController.cs b/Controller/ArtistController.cs index 28e7db7..bdfe658 100644 --- a/Controller/ArtistController.cs +++ b/Controller/ArtistController.cs @@ -22,17 +22,6 @@ public async Task GetById([FromQuery] string? id) } return BadRequest("Provide either id for the artist"); } - - [HttpGet("name")] - public async Task GetByName([FromQuery] string? name) - { - if (!string.IsNullOrWhiteSpace(name)) - { - var result = await mediator.Send(new ArtistQuery.GetByName(name)); - return result is null ? NotFound() : Ok(result); - } - return BadRequest("Provide either name for the artist"); - } [HttpGet("wikidata")] public async Task GetWikidataById([FromQuery] string? id) diff --git a/Controller/ArtistInfoController.cs b/Controller/ArtistInfoController.cs new file mode 100644 index 0000000..244061b --- /dev/null +++ b/Controller/ArtistInfoController.cs @@ -0,0 +1,21 @@ +using HollyJukeBox.QueryModels; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace HollyJukeBox.Controller; + +[ApiController] +[Route("api/artistinfo")] +public class ArtistInfoController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task GetById([FromQuery] string? id) + { + if (!string.IsNullOrWhiteSpace(id)) + { + var result = await mediator.Send(new ArtistInfoQuery.GetById(id)); + return result is null ? NotFound() : Ok(result); + } + return BadRequest("Provide either id for the artist"); + } +} \ No newline at end of file diff --git a/Controller/AlbumController.cs b/Controller/CoverArtController.cs similarity index 74% rename from Controller/AlbumController.cs rename to Controller/CoverArtController.cs index ca46ccf..627e0da 100644 --- a/Controller/AlbumController.cs +++ b/Controller/CoverArtController.cs @@ -8,15 +8,15 @@ namespace HollyJukeBox.Controller; /// Handles album-related data access /// [ApiController] -[Route("api/album")] -public class AlbumController(IMediator mediator) : ControllerBase +[Route("api/coverart")] +public class CoverArtController(IMediator mediator) : ControllerBase { [HttpGet] public async Task GetById([FromQuery] string? id) { if (!string.IsNullOrWhiteSpace(id)) { - var result = await mediator.Send(new AlbumQuery.GetById(id)); + var result = await mediator.Send(new CoverArtQuery.GetById(id)); return result is null ? NotFound() : Ok(result); } diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs new file mode 100644 index 0000000..0ddca17 --- /dev/null +++ b/Data/DbInitializer.cs @@ -0,0 +1,80 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace HollyJukeBox.Data; + +public class DbInitializer +{ + private SqliteConnection connection; + + public DbInitializer(IConfiguration configuration) + { + connection = new SqliteConnection(configuration.GetConnectionString("HollyJukeBoxDb")); + } + + public async Task EnsureTablesCreatedAsync() + { + await connection.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS Artist ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS Releases ( + Id TEXT PRIMARY KEY, + Title TEXT NOT NULL, + FirstReleaseDate TEXT, + ArtistId TEXT NOT NULL, + FOREIGN KEY (ArtistId) REFERENCES Artist(Id) + ); + + CREATE TABLE IF NOT EXISTS Relations ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + ArtistId TEXT NOT NULL, + Type TEXT NOT NULL, + UrlId TEXT NOT NULL, + FOREIGN KEY (ArtistId) REFERENCES Artist(Id), + FOREIGN KEY (UrlId) REFERENCES Urls(Id) + ); + + CREATE TABLE IF NOT EXISTS Urls ( + Id TEXT PRIMARY KEY, + Resource TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS CoverArt ( + Id TEXT PRIMARY KEY + ); + + CREATE TABLE IF NOT EXISTS Images ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CoverArtId TEXT NOT NULL, + Image TEXT NOT NULL, + FOREIGN KEY (CoverArtId) REFERENCES CoverArt(Id) + ); + + CREATE TABLE IF NOT EXISTS ImageTypes ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + ImageId INTEGER NOT NULL, + Type TEXT NOT NULL, + FOREIGN KEY (ImageId) REFERENCES Images(Id) + ); + + CREATE TABLE IF NOT EXISTS ArtistInfo ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Description TEXT + ); + + CREATE TABLE IF NOT EXISTS AlbumInfo ( + Id TEXT PRIMARY KEY, + Title TEXT NOT NULL, + FirstReleaseDate TEXT, + ImageFront TEXT, + ImageBack TEXT, + ArtistInfoId TEXT NOT NULL, + FOREIGN KEY (ArtistInfoId) REFERENCES ArtistInfo(Id) + ); + "); + } +} \ No newline at end of file diff --git a/Endpoints/AlbumEndPoint.cs b/Endpoints/AlbumEndPoint.cs deleted file mode 100644 index da8b167..0000000 --- a/Endpoints/AlbumEndPoint.cs +++ /dev/null @@ -1,11 +0,0 @@ -using HollyJukeBox.Models; -using HollyJukeBox.Services; -using Microsoft.Extensions.Options; - -namespace HollyJukeBox.Endpoints; - -public class AlbumEndPoint(IOptions options, HttpClient client) : IAlbumEndPoint -{ - public async Task GetById(string id) => - await client.GetFromJsonAsync(options.Value.CoverArtArchiveUrl + $"release-group/{id}"); -} \ No newline at end of file diff --git a/Endpoints/ArtistEndPoint.cs b/Endpoints/ArtistEndPoint.cs index 00e3da6..8471f8c 100644 --- a/Endpoints/ArtistEndPoint.cs +++ b/Endpoints/ArtistEndPoint.cs @@ -9,9 +9,6 @@ public class ArtistEndPoint(IOptions options, HttpClient client) : public async Task GetById(string id) => await client.GetFromJsonAsync( options.Value.MusicBrainzUrl + $"artist/{id}?inc=release-groups+url-rels&fmt=json"); - public async Task GetByName(string name) => await client.GetFromJsonAsync( - options.Value.MusicBrainzUrl + $"artist?query={name}&fmt=json"); - public async Task GetWikiData(string id) => await client.GetFromJsonAsync( options.Value.WikiDataUrl + $"?action=wbgetentities&ids={id}&format=json&props=sitelinks"); diff --git a/Endpoints/CoverArtEndPoint.cs b/Endpoints/CoverArtEndPoint.cs new file mode 100644 index 0000000..447464b --- /dev/null +++ b/Endpoints/CoverArtEndPoint.cs @@ -0,0 +1,23 @@ +using System.Net; +using HollyJukeBox.Models; +using HollyJukeBox.Services; +using Microsoft.Extensions.Options; + +namespace HollyJukeBox.Endpoints; + +public class CoverArtEndPoint(IOptions options, HttpClient client) : ICoverArtEndPoint +{ + public async Task GetById(string id) + { + var response = await client.GetAsync(options.Value.CoverArtArchiveUrl + $"release-group/{id}"); + if (response.StatusCode != HttpStatusCode.OK) + { + return new CoverArtDto{ + Id = id, + Images = new List() + }; + } + + return await response.Content.ReadFromJsonAsync(); + } +} \ No newline at end of file diff --git a/Endpoints/IAlbumEndPoint.cs b/Endpoints/IAlbumEndPoint.cs deleted file mode 100644 index 8ebb51c..0000000 --- a/Endpoints/IAlbumEndPoint.cs +++ /dev/null @@ -1,8 +0,0 @@ -using HollyJukeBox.Models; - -namespace HollyJukeBox.Endpoints; - -public interface IAlbumEndPoint -{ - public Task GetById(string id); -} \ No newline at end of file diff --git a/Endpoints/IArtistEndPoint.cs b/Endpoints/IArtistEndPoint.cs index 7d3e1bb..7f469bc 100644 --- a/Endpoints/IArtistEndPoint.cs +++ b/Endpoints/IArtistEndPoint.cs @@ -5,7 +5,6 @@ namespace HollyJukeBox.Endpoints; public interface IArtistEndPoint { public Task GetById(string id); - public Task GetByName(string name); public Task GetWikiData(string id); public Task GetWikipediaSummary(string enwikiTitle); } \ No newline at end of file diff --git a/Endpoints/ICoverArtEndPoint.cs b/Endpoints/ICoverArtEndPoint.cs new file mode 100644 index 0000000..70299be --- /dev/null +++ b/Endpoints/ICoverArtEndPoint.cs @@ -0,0 +1,8 @@ +using HollyJukeBox.Models; + +namespace HollyJukeBox.Endpoints; + +public interface ICoverArtEndPoint +{ + public Task GetById(string id); +} \ No newline at end of file diff --git a/Handler/AlbumHandler.cs b/Handler/AlbumHandler.cs deleted file mode 100644 index 1ff6791..0000000 --- a/Handler/AlbumHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using HollyJukeBox.Endpoints; -using HollyJukeBox.Models; -using HollyJukeBox.QueryModels; -using MediatR; - -namespace HollyJukeBox.Handler; - -public class AlbumHandler(IAlbumEndPoint albumEndPoint) : - IRequestHandler -{ - public async Task Handle(AlbumQuery.GetById query, CancellationToken cancellationToken) - => await albumEndPoint.GetById(query.Id); -} \ No newline at end of file diff --git a/Handler/ArtistHandler.cs b/Handler/ArtistHandler.cs index 3018e61..04ee978 100644 --- a/Handler/ArtistHandler.cs +++ b/Handler/ArtistHandler.cs @@ -1,25 +1,40 @@ using HollyJukeBox.Endpoints; using HollyJukeBox.Models; using HollyJukeBox.QueryModels; +using HollyJukeBox.Repository; +using HollyJukeBox.Services; using MediatR; namespace HollyJukeBox.Handler; -public class ArtistHandler(IArtistEndPoint artistEndPoint) : +public class ArtistHandler( + IArtistEndPoint artistEndPoint, + IMemoryCashingService memoryCashingService, + IArtistRepository artistRepository) : IRequestHandler, - IRequestHandler, IRequestHandler, IRequestHandler { - public async Task Handle(ArtistQuery.GetById query, CancellationToken cancellationToken) - => await artistEndPoint.GetById(query.Id); + public async Task Handle(ArtistQuery.GetById request, CancellationToken cancellationToken) + { + var artist = memoryCashingService.Get($"artistDto:{request.Id}"); + + if (artist is not null) + { + return artist; + } - public async Task Handle(ArtistQuery.GetByName query, CancellationToken cancellationToken) - { - var result = await artistEndPoint.GetByName(query.Name); - var artist = result.Artists.First(x => string.Equals(x.Name, query.Name, StringComparison.OrdinalIgnoreCase)); - artist = await artistEndPoint.GetById(artist.Id); - return artist; + artist = await artistRepository.GetByIdAsync(request.Id); + if (artist is not null) + { + memoryCashingService.Store($"artistDto:{request.Id}", artist); + return artist; + } + + artist = await artistEndPoint.GetById(request.Id); + memoryCashingService.Store($"artistDto:{request.Id}", artist); + await artistRepository.SaveAsync(artist); + return artist; } public async Task Handle(ArtistQuery.GetWikiData request, CancellationToken cancellationToken) diff --git a/Handler/ArtistInfoHandler.cs b/Handler/ArtistInfoHandler.cs new file mode 100644 index 0000000..cafebb2 --- /dev/null +++ b/Handler/ArtistInfoHandler.cs @@ -0,0 +1,120 @@ +using HollyJukeBox.Endpoints; +using HollyJukeBox.Models; +using HollyJukeBox.QueryModels; +using HollyJukeBox.Repository; +using HollyJukeBox.Services; +using MediatR; + +namespace HollyJukeBox.Handler; + +public class ArtistInfoHandler( + IArtistEndPoint artistEndPoint, + ICoverArtEndPoint coverArtEndPoint, + IMemoryCashingService memoryCashingService, + IArtistRepository artistRepository, + IArtistInfoRepository artistInfoRepository, + IAlbumInfoRepository albumInfoRepository, + ICoverArtRepository coverArtRepository, + IRetryPolicyService retryPolicy) + : IRequestHandler +{ + public async Task Handle(ArtistInfoQuery.GetById request, CancellationToken cancellationToken) + { + var artist = memoryCashingService.Get($"artist:{request.Id}"); + if (artist is not null) + { + return artist; + } + + artist = await artistInfoRepository.GetByIdAsync(request.Id); + if (artist is not null) + { + var albumList = await albumInfoRepository.GetByArtistIdAsync(request.Id); + if(albumList is not null && albumList.ToList().Count > 0) + { + artist.Albums = albumList.ToList(); + memoryCashingService.Store($"artist:{request.Id}", artist); + return artist; + } + + return null; + } + + var summary = string.Empty; + var artistDto = memoryCashingService.Get($"artistDto:{request.Id}"); + if (artistDto is null) + { + artistDto = await artistRepository.GetByIdAsync(request.Id); + if (artistDto is null) + { + artistDto = await artistEndPoint.GetById(request.Id); + var wikiSummaryId = string.Empty; + var relation = artistDto.Relations + .Find(x => Enum.TryParse(x.type, true, out var keyType) && keyType == KeyTypes.wikipedia); + if(relation is not null)//if relation has wikipedia + { + wikiSummaryId = relation.url.resource.Split('/').Last(); + } + else + { + relation = artistDto.Relations + .Find(x => Enum.TryParse(x.type, true, out var keyType) && keyType == KeyTypes.wikidata); + var wikiId = relation.url.resource.Split('/').Last(); + var wikiData = await artistEndPoint.GetWikiData(wikiId); + var sitelinks = wikiData.Entities.First().Value.Sitelinks; + wikiSummaryId = sitelinks.First(x => + Enum.TryParse(x.Key, true, out var keyType) && keyType == KeyTypes.enwiki).Value.Title; + } + + var wikiSummary = await artistEndPoint.GetWikipediaSummary(wikiSummaryId); + summary = wikiSummary.Extract; + memoryCashingService.Store($"artistDto:{request.Id}", artistDto); + } + } + var albums = new List(); + + foreach (var release in artistDto.ReleaseGroups) + { + var coverArt = memoryCashingService.Get($"coverArt:{release.Id}"); + if(coverArt is null) coverArt = await coverArtRepository.GetByIdAsync(release.Id); + if(coverArt is null) + { + coverArt = await retryPolicy.RetryGet().ExecuteAsync(() => coverArtEndPoint.GetById(release.Id)); + if(coverArt is not null) + { + var sortedImages = coverArt.Images + .Where(x => + x.Types.Contains(nameof(ImageTypes.Front)) || + x.Types.Contains(nameof(ImageTypes.Back))); + + coverArt.Images = sortedImages.ToList(); + coverArt.Id = release.Id; + } + } + + albums.Add(new AlbumInfo + { + Id = release.Id, + ArtistInfoId = artistDto.Id, + Title = release.Title, + FirstReleaseDate = release.FirstReleaseDate, + ImageFront = coverArt.Images.FirstOrDefault(x => x.Types.Contains(nameof(ImageTypes.Front)))?.Image ?? string.Empty, + ImageBack = coverArt.Images.FirstOrDefault(x => x.Types.Contains(nameof(ImageTypes.Back)))?.Image ?? string.Empty + }); + memoryCashingService.Store($"coverArt:{release.Id}", coverArt); + await coverArtRepository.SaveAsync(coverArt); + } + artist = new ArtistInfo + { + Mbid = artistDto.Id, + Name = artistDto.Name, + Description = summary, + Albums = albums + }; + + memoryCashingService.Store($"artist:{request.Id}", artist); + await artistInfoRepository.SaveAsync(artist); + await albumInfoRepository.SaveAsync(artist.Albums); + return artist; + } +} \ No newline at end of file diff --git a/Handler/CoverArtHandler.cs b/Handler/CoverArtHandler.cs new file mode 100644 index 0000000..3d54efc --- /dev/null +++ b/Handler/CoverArtHandler.cs @@ -0,0 +1,44 @@ +using HollyJukeBox.Endpoints; +using HollyJukeBox.Models; +using HollyJukeBox.QueryModels; +using HollyJukeBox.Repository; +using HollyJukeBox.Services; +using MediatR; + +namespace HollyJukeBox.Handler; + +public class CoverArtHandler( + ICoverArtEndPoint coverArtEndPoint, + IMemoryCashingService memoryCashingService, + ICoverArtRepository coverArtRepository) : + IRequestHandler +{ + public async Task Handle(CoverArtQuery.GetById request, CancellationToken cancellationToken) + { + var coverArt = memoryCashingService.Get($"coverArt:{request.Id}"); + if (coverArt is not null) + { + return coverArt; + } + + coverArt = await coverArtRepository.GetByIdAsync(request.Id); + if (coverArt is not null) + { + memoryCashingService.Store($"coverArt:{request.Id}", coverArt); + return coverArt; + } + + coverArt = await coverArtEndPoint.GetById(request.Id); + coverArt.Id = request.Id; + var sortedImages = coverArt.Images + .Where(x => + x.Types.Contains(nameof(ImageTypes.Front)) || + x.Types.Contains(nameof(ImageTypes.Back))); + + coverArt.Images = sortedImages.ToList(); + + memoryCashingService.Store($"coverArt:{request.Id}", coverArt); + await coverArtRepository.SaveAsync(coverArt); + return coverArt; + } +} \ No newline at end of file diff --git a/HollyJukeBox.csproj b/HollyJukeBox.csproj index f862219..2345503 100644 --- a/HollyJukeBox.csproj +++ b/HollyJukeBox.csproj @@ -7,9 +7,13 @@ + + - - + + + + diff --git a/Models/AlbumDto.cs b/Models/AlbumDto.cs deleted file mode 100644 index 17e4b87..0000000 --- a/Models/AlbumDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace HollyJukeBox.Models; - -public class AlbumDto -{ - public List images { get; set; } -} -public record Images(string[] types, string image); \ No newline at end of file diff --git a/Models/AlbumInfo.cs b/Models/AlbumInfo.cs new file mode 100644 index 0000000..7a5770a --- /dev/null +++ b/Models/AlbumInfo.cs @@ -0,0 +1,11 @@ +namespace HollyJukeBox.Models; + +public class AlbumInfo +{ + public required string Id { get; set; } + public required string ArtistInfoId { get; set; } + public required string Title { get; set; } + public required string FirstReleaseDate { get; set; } + public string? ImageFront { get; set; } + public string? ImageBack { get; set; } +} \ No newline at end of file diff --git a/Models/ArtistDto.cs b/Models/ArtistDto.cs index ff6ba09..7f5a36c 100644 --- a/Models/ArtistDto.cs +++ b/Models/ArtistDto.cs @@ -1,25 +1,28 @@ using System.Text.Json.Serialization; - namespace HollyJukeBox.Models; public class ArtistDto { - [JsonPropertyName("id")] + [JsonPropertyName("id")] public string Id { get; set; } - [JsonPropertyName("name")] + [JsonPropertyName("name")] public string Name { get; set; } - - [JsonPropertyName("release-groups")] - public List ReleasesGroups { get; set; } + [JsonPropertyName("release-groups")] + public List ReleaseGroups { get; set; } = new(); [JsonPropertyName("relations")] - public List Relations { get; set; } + public List Relations { get; set; } = new(); } - -public record ReleasesGroups( +public record Relations( + [property: JsonPropertyName("type")]string type, + [property: JsonPropertyName("url")]Url url +); +public record ReleaseGroup( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("first-release-date")] string FirstReleaseDate ); -public record Relations(string type, Url url); -public record Url(string id, string resource); \ No newline at end of file +public record Url( + [property: JsonPropertyName("id")]string id, + [property: JsonPropertyName("resource")]string resource +); \ No newline at end of file diff --git a/Models/ArtistInfo.cs b/Models/ArtistInfo.cs new file mode 100644 index 0000000..9f116d5 --- /dev/null +++ b/Models/ArtistInfo.cs @@ -0,0 +1,9 @@ +namespace HollyJukeBox.Models; + +public class ArtistInfo +{ + public required string Mbid { get; set; } + public required string Name { get; set; } + public required List Albums { get; set; } + public required string Description { get; set; } +} \ No newline at end of file diff --git a/Models/ArtistsDto.cs b/Models/ArtistsDto.cs deleted file mode 100644 index 837c3d0..0000000 --- a/Models/ArtistsDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace HollyJukeBox.Models; - -public class ArtistsDto -{ - [JsonPropertyName("artists")] - public List Artists { get; set; } -} \ No newline at end of file diff --git a/Models/CoverArtDto.cs b/Models/CoverArtDto.cs new file mode 100644 index 0000000..e61395f --- /dev/null +++ b/Models/CoverArtDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace HollyJukeBox.Models; + +public class CoverArtDto +{ + public string Id { get; set; } + [JsonPropertyName("images")] + public List Images { get; set; } +} + +public record Images( + [property: JsonPropertyName("types")] string[] Types, + [property: JsonPropertyName("image")] string Image +); diff --git a/Models/ImageTypes.cs b/Models/ImageTypes.cs new file mode 100644 index 0000000..f22b73d --- /dev/null +++ b/Models/ImageTypes.cs @@ -0,0 +1,6 @@ +namespace HollyJukeBox.Models; +public enum ImageTypes +{ + Front, + Back +} \ No newline at end of file diff --git a/Models/KeyTypes.cs b/Models/KeyTypes.cs new file mode 100644 index 0000000..0d206df --- /dev/null +++ b/Models/KeyTypes.cs @@ -0,0 +1,8 @@ +namespace HollyJukeBox.Models; + +public enum KeyTypes +{ + enwiki, + wikidata, + wikipedia +} \ No newline at end of file diff --git a/Models/WikiDataDto.cs b/Models/WikiDataDto.cs index c1bfbc5..abcef49 100644 --- a/Models/WikiDataDto.cs +++ b/Models/WikiDataDto.cs @@ -1,21 +1,14 @@ using System.Text.Json.Serialization; - namespace HollyJukeBox.Models; -public class WikiDataDto -{ - [JsonPropertyName("entities")] - public Dictionary Entities { get; set; } -} +public record WikiDataDto( + [property:JsonPropertyName("entities")] Dictionary Entities +); public record Entity( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("sitelinks")] Dictionary Sitelinks ); public record SiteLink( - [property: JsonPropertyName("site")] string site, - [property: JsonPropertyName("title")] string title -); - -public record Enwiki( - [property: JsonPropertyName("site")] string enwiki + [property: JsonPropertyName("site")] string Site, + [property: JsonPropertyName("title")] string Title ); \ No newline at end of file diff --git a/Models/WikipediaSummaryDto.cs b/Models/WikipediaSummaryDto.cs index 4c715f7..d4b4233 100644 --- a/Models/WikipediaSummaryDto.cs +++ b/Models/WikipediaSummaryDto.cs @@ -2,8 +2,8 @@ namespace HollyJukeBox.Models; -public class WikipediaSummaryDto -{ - [JsonPropertyName("extract")] - public string Extract { get; set; } -} \ No newline at end of file +public record WikipediaSummaryDto +( + [property: JsonPropertyName("extract")] string Extract, + string ArtsistId +); \ No newline at end of file diff --git a/Program.cs b/Program.cs index 1c1915e..b3fcd47 100644 --- a/Program.cs +++ b/Program.cs @@ -1,10 +1,25 @@ +using HollyJukeBox.Data; using HollyJukeBox.Endpoints; +using HollyJukeBox.Repository; using HollyJukeBox.Services; +using Microsoft.Data.Sqlite; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddMemoryCache(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + new SqliteConnection(builder.Configuration.GetConnectionString("HollyJukeBoxDb"))); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -22,6 +37,18 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var dbInit = scope.ServiceProvider.GetRequiredService(); + await dbInit.EnsureTablesCreatedAsync(); +} + +app.MapGet("/", context => +{ + context.Response.Redirect("/swagger"); + return Task.CompletedTask; +}); + app.UseSwagger(); app.UseSwaggerUI(); app.MapControllers(); diff --git a/Querys/AlbumQuery.cs b/Querys/AlbumQuery.cs deleted file mode 100644 index eb2fe78..0000000 --- a/Querys/AlbumQuery.cs +++ /dev/null @@ -1,9 +0,0 @@ -using HollyJukeBox.Models; -using MediatR; - -namespace HollyJukeBox.QueryModels; - -public class AlbumQuery -{ - public record GetById(string Id) : IRequest; -} \ No newline at end of file diff --git a/Querys/ArtistInfoQuery.cs b/Querys/ArtistInfoQuery.cs new file mode 100644 index 0000000..47b42ab --- /dev/null +++ b/Querys/ArtistInfoQuery.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; +using MediatR; + +namespace HollyJukeBox.QueryModels; + +public class ArtistInfoQuery +{ + public record GetById(string Id) : IRequest; +} \ No newline at end of file diff --git a/Querys/ArtistQuery.cs b/Querys/ArtistQuery.cs index 1de5680..c0329b5 100644 --- a/Querys/ArtistQuery.cs +++ b/Querys/ArtistQuery.cs @@ -3,10 +3,9 @@ namespace HollyJukeBox.QueryModels; -public class ArtistQuery : IRequest +public class ArtistQuery { public record GetById(string Id) : IRequest; - public record GetByName(string Name) : IRequest; public record GetWikiData(string id) : IRequest; public record GetWikipediaSummary(string enwikiTitle) : IRequest; } \ No newline at end of file diff --git a/Querys/CoverArtQuery.cs b/Querys/CoverArtQuery.cs new file mode 100644 index 0000000..868b5e3 --- /dev/null +++ b/Querys/CoverArtQuery.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; +using MediatR; + +namespace HollyJukeBox.QueryModels; + +public class CoverArtQuery +{ + public record GetById(string Id) : IRequest; +} \ No newline at end of file diff --git a/README.md b/README.md index 0c58eb4..f41d5d7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,131 @@ [![.NET](https://github.com/Carpenteri1/HollyJukeBox/actions/workflows/dotnet.yml/badge.svg)](https://github.com/Carpenteri1/HollyJukeBox/actions/workflows/dotnet.yml) [![CodeQL Advanced](https://github.com/Carpenteri1/HollyJukeBox/actions/workflows/codeql.yml/badge.svg)](https://github.com/Carpenteri1/HollyJukeBox/actions/workflows/codeql.yml) + +# Holly JukeBox + +### Starta +- git clone https://github.com/Carpenteri1/HollyJukeBox.git eller ladda ner som zip +- unzip (skip if git clone) +- cd HollyJukeBox +- dotnet restore +- dotnet build +- dotnet run +### Installera self contained +Om man vill köra HollyJukeBox helt self containd. +Efter att ha gjort någon av commandon nedanför hiitar du allt i HollyJukeBox/bin/Release/net8.0/osx-arm64/publish/ + +#### Windows (x64) +- dotnet publish -c Release -r win-x64 --self-contained true +#### Linux (x64) +- dotnet publish -c Release -r linux-x64 --self-contained true +#### macOS (x64) +- dotnet publish -c Release -r osx-x64 --self-contained true +#### macOS (Apple Silicon) +- dotnet publish -c Release -r osx-arm64 --self-contained true +### Ramverk +- NET Core 8 +- ADO.NET +- ASP.NET +- C# +- Swagger +- Dapper +- MediaR +- SQLlite +- Poppin +- Yaml +#### .NET Core +Jag har valt att använda .NET 8, som är den senaste LTS-versionen (Long Term Support). Det erbjuder hög stabilitet, långsiktigt stöd och god prestanda. + +.NET är mitt huvudsakliga utvecklingsspråk, vilket gör det till ett naturligt val. Plattformen är väletablerad, kraftfull och lätt att skala upp till större system. + +För denna lösning var det självklart att bygga ett REST API med ASP.NET Core, eftersom ramverket erbjuder en mängd funktionalitet “out of the box”, såsom: +- Inbyggd routing +- Swagger-stöd +- Dependency Injection +- Middleware för t.ex. logging och felhantering + +Valet av ADO.NET och Dapper som datalager följer .NET:s naturliga arkitekturella styrkor när man vill ha kontroll, prestanda och låg overhead. +#### Swagger +Swagger ingår som standard när man skapar ett API-projekt i .NET Core och är ett mycket smidigt verktyg för att testa, dokumentera och visualisera API:er. + +Det möjliggör en interaktiv dokumentation där man kan skicka anrop direkt via webbläsaren, vilket underlättar både utveckling och felsökning. Swagger är väl etablerat i branschen, enkelt att konfigurera, och ger snabbt värde i ett proof of concept. + +Jag är medveten om att Swagger byts ut i vissa sammanhang i nyare versioner av .NET, men valde ändå att använda det eftersom: +- Det är beprövat och välkänt +- Det finns god verktygssupport +- Jag har personlig erfarenhet och kännedom om det +#### Dapper +Jag har valt att använda Dapper som datalager istället för att arbeta direkt med ADO.NET. Dapper är ett så kallat micro ORM som kombinerar hög prestanda med låg komplexitet. Det erbjuder en mycket lättviktig och snabb datatillgång, utan att abstrahera bort SQL – vilket passar utmärkt för ett proof of concept där kontroll och läsbarhet är prioriterade. + +Fördelarna med Dapper i detta sammanhang: +- Minimal kodmängd +- Hög prestanda, nära rå ADO.NET +- Enkel syntax och snabb inlärningströskel +- Tydlig separation av queries i kodbasen + +Valet stödjer också en modulär struktur där varje query eller kommando är explicit och lätt att underhålla. +#### MediaR +För att strukturera applikationslogiken har jag valt att använda MediatR tillsammans med CQRS (Command Query Responsibility Segregation). + +MediatR fungerar som ett mediator pattern-bibliotek, vilket ger en tydlig separation mellan API-lagret (controllers) och affärslogiken (handlers). Detta möjliggör: +- Lös koppling mellan komponenter +- Enkel testbarhet +- Skalbar struktur vid växande kodbas +- Möjlighet att införa pipeline behaviors för t.ex. loggning, validering, caching + +Jag använder MediatR främst för queries, och strukturen gör det enkelt att skala vidare och bygga ut funktionalitet utan att controller-lagret växer okontrollerat. Jag är medveten om att det kan upplevas som överengineering i enklare lösningar, men ser det som en strategisk investering i struktur. +#### SQLite +För datalagring använder projektet SQLite, en lättviktig, filbaserad databas som kräver minimal konfiguration. Den lämpar sig utmärkt för ett proof of concept då den: +- Kräver ingen separat server +- Är portabel och fungerar direkt på alla plattformar +- Har fullständig SQL-stöd +- Passar väl för lokal utveckling och snabb testning + +SQLite är ett bra val i detta sammanhang eftersom det gör setup och distribution enkel, samtidigt som det tillåter ett tydligt schema och persistent lagring. +#### In-Memory Caching +Projektet använder Microsoft.Extensions.Caching.Memory för inbyggd in-memory caching i .NET. Det möjliggör temporär lagring av data direkt i applikationens minne, vilket är särskilt användbart för att: +- Minska onödiga externa anrop +- Snabba upp svarstider +- Förbättra resurseffektivitet + +Cachen används exempelvis för att spara artistinformation som hämtas via externa källor. Genom att använda IMemoryCache med konfigurerad expiration (t.ex. sliding eller absolute), undviker vi upprepade nätverksanrop och förbättrar användarupplevelsen i klienten. + +Denna typ av caching lämpar sig mycket väl för API-tjänster där datan är relativt statisk under en kortare tid, och den är enkel att konfigurera och underhålla. +#### Poppin +Poppin används som ett komplement till MediatR för att ge stöd för pipelines och policy-baserade behaviors. Det hjälper till att separera tvärgående aspekter såsom: +- Logging +- Retry +- Timeout-hantering +- Circuit breaker-mönster + +Detta följer principen om Separation of Concerns och gör systemet mer modulärt och underhållbart. Poppin passar särskilt bra i CQRS-arkitektur där man vill dekorera Handlers med extra funktionalitet utan att blanda in det i affärslogiken. +#### YAML +Projektet använder YAML som format för konfiguration och exempeldata. YAML är ett lättläst, human-friendly format som lämpar sig väl för: +- Enkel strukturering av konfigurationsfiler +- Överskådlig initdata till t.ex. API:er eller tester +- Miljöspecifika inställningar i DevOps + +I detta projekt används YAML t.ex. för att definiera seed-data eller inställningar på ett överskådligt sätt. Det ger bättre läsbarhet än JSON i många konfigurationssammanhang. +#### Arkitektur +Projektet följer en förenklad version av Clean Architecture för att uppnå tydlig separation av ansvar och god testbarhet: +- API-lager: Innehåller Controllers som exponerar endpoints via ASP.NET Core. +- Application-lager: Använder CQRS-mönstret med Commands, Queries och Handlers via MediatR för att kapsla in affärslogik. +- Infrastruktur-lager: Innehåller implementeringar för dataåtkomst med Dapper och SQLite som databasmotor. +#### Felhantering +Nuvarande implementation har grundläggande felhantering, men det finns förbättringspotential: +- Vissa null-fall hanteras inte uttryckligen. +- Endpoints saknar tydlig hantering av statuskoder — man förutsätter exempelvis 200 OK, vilket kan ge felaktig responslogik vid t.ex. 404 Not Found eller 400 Bad Request. +- Felhantering i t.ex. repository-lagret är minimal och bör förstärkas. +- Inga enhetstester har ännu skrivits, vilket var planerat men prioriterades bort på grund av tidsramen. + +I en vidareutveckling bör global felhantering via ExceptionMiddleware och användning av ProblemDetails enligt RFC7807 övervägas. +#### Kompletterande ändringar ej pushade +Den aktuella branchen är inte helt fullt committad då vissa ändringar gjordes efter att jag tillfälligt förlorade åtkomsten till GitHub. Lokala uppdateringar finns kvar men hann inte pushas innan deadline. Funktionaliteten påverkas inte i grunden, men några förbättringar och justeringar syns inte i det publika repot. Allt ska pushas remote när tillgången till github är tillbaka. +#### Vidareutvecklingsförslag +Det finns flera naturliga utvecklingsvägar för att bygga vidare på detta proof of concept: +- Tester: Lägg till enhetstester för både application-lagret (handlers) och API-lagret (controllers). +- Utökade endpoints: Exempelvis en sökfunktion baserad på artistnamn istället för endast ID. CQRS-strukturen gör det enkelt att införa fler queries med olika filter. +- Flera retry-policies: Just nu används en enkel retry-policy i ArtistInfoHandler.cs. Detta kan generaliseras med Polly eller liknande bibliotek för att hantera instabil extern datahämtning. +- Prestandaförbättringar: ArtistInfo-sökningen kan optimeras – just nu är svarstiden något lång innan all metadata laddats. +- UI-klient: Projektet skulle med fördel kunna kompletteras med en frontend (t.ex. React, Vue eller Blazor) för att visualisera artistdata och albumomslag på ett mer användarvänligt sätt. + diff --git a/Repository/AlbumInfoRepository.cs b/Repository/AlbumInfoRepository.cs new file mode 100644 index 0000000..4bb8704 --- /dev/null +++ b/Repository/AlbumInfoRepository.cs @@ -0,0 +1,40 @@ +using System.Data; +using Dapper; +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public class AlbumInfoRepository(IDbConnection connection) : IAlbumInfoRepository +{ + public async Task?> GetByArtistIdAsync(string id) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate("SELECT * FROM AlbumInfo /**where**/"); + builder.Where("ArtistInfoId = @Id"); + return await connection.QueryAsync(template.RawSql, new { Id = id }); + } + + public async Task SaveAsync(List? albums) + { + var rowsAffected = 0; + foreach (var album in albums) + { + var builder = new SqlBuilder(); + var artistInsert = + builder.AddTemplate("INSERT OR IGNORE INTO AlbumInfo (Id, Title, FirstReleaseDate, ImageFront, ImageBack, ArtistInfoId) " + + "VALUES (@Id, @Title, @FirstReleaseDate, @ImageFront, @ImageBack, @ArtistInfoId)"); + rowsAffected += await connection.ExecuteAsync(artistInsert.RawSql, + new + { + album.Id, + album.Title, + album.FirstReleaseDate, + album.ImageFront, + album.ImageBack, + album.ArtistInfoId + }); + } + + return rowsAffected; + } +} \ No newline at end of file diff --git a/Repository/ArtistInfoRepository.cs b/Repository/ArtistInfoRepository.cs new file mode 100644 index 0000000..c21a278 --- /dev/null +++ b/Repository/ArtistInfoRepository.cs @@ -0,0 +1,33 @@ +using System.Data; +using Dapper; +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public class ArtistInfoRepository(IDbConnection connection) : IArtistInfoRepository +{ + public async Task GetByIdAsync(string id) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate("SELECT Id AS Mbid, Name, Description FROM ArtistInfo /**where**/ LIMIT 1"); + builder.Where("id = @Id", new { Id = id }); + return await connection.QueryFirstOrDefaultAsync(template.RawSql, + new { Id = id }); + } + + public async Task SaveAsync(ArtistInfo artist) + { + var builder = new SqlBuilder(); + var artistInsert = + builder.AddTemplate("INSERT OR IGNORE INTO ArtistInfo (Id, Name, Description) " + + "VALUES (@Id, @Name, @Description)"); + + return await connection.ExecuteAsync(artistInsert.RawSql, + new + { + Id = artist.Mbid, + artist.Name, + artist.Description + }); + } +} \ No newline at end of file diff --git a/Repository/ArtistRepository.cs b/Repository/ArtistRepository.cs new file mode 100644 index 0000000..144e437 --- /dev/null +++ b/Repository/ArtistRepository.cs @@ -0,0 +1,82 @@ +using System.Data; +using HollyJukeBox.Models; +using Dapper; + +namespace HollyJukeBox.Repository; + +public class ArtistRepository(IDbConnection connection) : IArtistRepository +{ + public async Task GetByIdAsync(string id) + { + var artist = await connection.QueryFirstOrDefaultAsync( + "SELECT Id, Name FROM Artist WHERE Id = @Id", new { Id = id }); + + if(artist is null) + { + return null; + } + + var releases = await connection.QueryAsync( + "SELECT Id, Title, FirstReleaseDate FROM Releases WHERE ArtistId = @Id", new { Id = id }); + + var rawData = await connection.QueryAsync<(string Type, string Id, string Resource)>( + @"SELECT rel.Type, u.Id, u.Resource + FROM Relations rel + JOIN Urls u ON u.Id = rel.UrlId + WHERE rel.ArtistId = @Id", new { Id = id }); + + var relations = rawData + .Select(r => new Relations(r.Type, new Url(r.Id, r.Resource))) + .ToList(); + + artist.ReleaseGroups = releases.ToList(); + artist.Relations = relations.ToList(); + return artist; + } + + public async Task SaveAsync(ArtistDto artist) + { + int affectedRows = 0; + + var builder = new SqlBuilder(); + var artistInsert = builder.AddTemplate("INSERT OR IGNORE INTO Artist (Id, Name) VALUES (@Id, @Name)"); + affectedRows += await connection.ExecuteAsync(artistInsert.RawSql, new { artist.Id, artist.Name }); + + foreach (var release in artist.ReleaseGroups) + { + var releaseBuilder = new SqlBuilder(); + var releaseInsert = releaseBuilder.AddTemplate( + "INSERT OR IGNORE INTO Releases (Id, Title, FirstReleaseDate, ArtistId) " + + "VALUES (@Id, @Title, @FirstReleaseDate, @ArtistId)"); + + affectedRows += await connection.ExecuteAsync(releaseInsert.RawSql, new + { + release.Id, + release.Title, + release.FirstReleaseDate, + ArtistId = artist.Id + }); + } + + foreach (var rel in artist.Relations) + { + var urlBuilder = new SqlBuilder(); + var insertUrl = urlBuilder.AddTemplate("INSERT OR IGNORE INTO Urls (Id, Resource) VALUES (@Id, @Resource)"); + affectedRows += await connection.ExecuteAsync(insertUrl.RawSql, new { Id = rel.url.id, Resource = rel.url.resource }); + + var relBuilder = new SqlBuilder(); + var insertRel = relBuilder.AddTemplate( + "INSERT OR IGNORE INTO Relations (ArtistId, Type, UrlId) VALUES (@ArtistId, @Type, @UrlId)"); + + affectedRows += await connection.ExecuteAsync(insertRel.RawSql, new + { + ArtistId = artist.Id, + Type = rel.type, + UrlId = rel.url.id + }); + } + + return affectedRows; + } + +} \ No newline at end of file diff --git a/Repository/CoverArtRepository.cs b/Repository/CoverArtRepository.cs new file mode 100644 index 0000000..d4049a3 --- /dev/null +++ b/Repository/CoverArtRepository.cs @@ -0,0 +1,46 @@ +using System.Data; +using HollyJukeBox.Models; +using Dapper; + +namespace HollyJukeBox.Repository; + +public class CoverArtRepository(IDbConnection connection) : ICoverArtRepository +{ + public async Task GetByIdAsync(string id) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate( + "SELECT c.* FROM CoverArt c /**join**/ /**leftjoin**/ /**where**/ LIMIT 1"); + builder.LeftJoin("Images i ON i.CoverArtId = c.Id"); + builder.LeftJoin("ImageTypes it ON it.ImageId = i.Id"); + builder.Where("c.Id = @Id", new { Id = id }); + + var covertArt = await connection.QueryFirstOrDefaultAsync(template.RawSql, template.Parameters); + return covertArt; + } + + public async Task SaveAsync(CoverArtDto coverArtDto) + { + int affectedRows = 0; + var builder = new SqlBuilder(); + var insertCoverArt = builder.AddTemplate("INSERT OR IGNORE INTO CoverArt (Id) VALUES (@Id)"); + affectedRows += await connection.ExecuteAsync(insertCoverArt.RawSql, new { Id = coverArtDto.Id }); + + foreach (var image in coverArtDto.Images) + { + var insertImage = "INSERT INTO Images (CoverArtId, Image) VALUES (@CoverArtId, @Image); SELECT last_insert_rowid();"; + var imageId = await connection.ExecuteScalarAsync(insertImage, new { CoverArtId = coverArtDto.Id, Image = image.Image }); + + foreach (var type in image.Types) + { + var builderType = new SqlBuilder(); + var insertType = builderType.AddTemplate("INSERT INTO ImageTypes (ImageId, Type) VALUES (@ImageId, @Type)"); + affectedRows += await connection.ExecuteAsync(insertType.RawSql, new { ImageId = imageId, Type = type }); + } + + affectedRows++; + } + + return affectedRows; + } +} \ No newline at end of file diff --git a/Repository/IAlbumInfoRepository.cs b/Repository/IAlbumInfoRepository.cs new file mode 100644 index 0000000..1005182 --- /dev/null +++ b/Repository/IAlbumInfoRepository.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public interface IAlbumInfoRepository +{ + public Task?> GetByArtistIdAsync(string id); + public Task SaveAsync(List? album); +} \ No newline at end of file diff --git a/Repository/IArtistInfoRepository.cs b/Repository/IArtistInfoRepository.cs new file mode 100644 index 0000000..d685463 --- /dev/null +++ b/Repository/IArtistInfoRepository.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public interface IArtistInfoRepository +{ + public Task GetByIdAsync(string id); + public Task SaveAsync(ArtistInfo artist); +} \ No newline at end of file diff --git a/Repository/IArtistRepository.cs b/Repository/IArtistRepository.cs new file mode 100644 index 0000000..e4842d9 --- /dev/null +++ b/Repository/IArtistRepository.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public interface IArtistRepository +{ + public Task GetByIdAsync(string id); + public Task SaveAsync(ArtistDto artistDto); +} \ No newline at end of file diff --git a/Repository/ICoverArtRepository.cs b/Repository/ICoverArtRepository.cs new file mode 100644 index 0000000..ed2ca97 --- /dev/null +++ b/Repository/ICoverArtRepository.cs @@ -0,0 +1,9 @@ +using HollyJukeBox.Models; + +namespace HollyJukeBox.Repository; + +public interface ICoverArtRepository +{ + public Task GetByIdAsync(string id); + public Task SaveAsync(CoverArtDto coverArtDto); +} \ No newline at end of file diff --git a/Services/IMemoryCashingService.cs b/Services/IMemoryCashingService.cs new file mode 100644 index 0000000..23c8536 --- /dev/null +++ b/Services/IMemoryCashingService.cs @@ -0,0 +1,7 @@ +namespace HollyJukeBox.Services; + +public interface IMemoryCashingService +{ + public T? Get(string key) where T : class; + public bool Store(string key, T item) where T : class; +} \ No newline at end of file diff --git a/Services/IRetryPolicyService.cs b/Services/IRetryPolicyService.cs new file mode 100644 index 0000000..724401d --- /dev/null +++ b/Services/IRetryPolicyService.cs @@ -0,0 +1,6 @@ +namespace HollyJukeBox.Services; + +public interface IRetryPolicyService +{ + public Polly.Retry.AsyncRetryPolicy RetryGet(); +} \ No newline at end of file diff --git a/Services/MemoryCashingService.cs b/Services/MemoryCashingService.cs new file mode 100644 index 0000000..8b131e7 --- /dev/null +++ b/Services/MemoryCashingService.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace HollyJukeBox.Services; + +public class MemoryCashingService(IMemoryCache memoryCache) : IMemoryCashingService +{ + public T Get(string key) where T : class => memoryCache.Get(key); + public bool Store(string key, T item) where T : class => + memoryCache.Set( + key, + item, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(60) + }) is T; +} \ No newline at end of file diff --git a/Services/RetryPolicyService.cs b/Services/RetryPolicyService.cs new file mode 100644 index 0000000..0d1bd93 --- /dev/null +++ b/Services/RetryPolicyService.cs @@ -0,0 +1,16 @@ +using Polly; + +namespace HollyJukeBox.Services; + +public class RetryPolicyService : IRetryPolicyService +{ + public Polly.Retry.AsyncRetryPolicy RetryGet() + { + return Policy + .Handle() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 0bde613..b88927a 100644 --- a/appsettings.json +++ b/appsettings.json @@ -11,5 +11,8 @@ "WikiDataUrl" : "https://www.wikidata.org/w/api.php", "WikipediaSummery" : "https://en.wikipedia.org/api/rest_v1/page/summary/" }, + "ConnectionStrings": { + "HollyJukeBoxDb": "Data Source=hollyjukebox.db" + }, "AllowedHosts": "*" } diff --git a/launchSettings.json b/launchSettings.json index 023f15b..90349ad 100644 --- a/launchSettings.json +++ b/launchSettings.json @@ -4,7 +4,8 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:5088", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5009", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }