From 07343fa0d9b68926aed852775fdc46da9a5e1bb5 Mon Sep 17 00:00:00 2001 From: Collin Barrett Date: Sun, 2 Jun 2024 16:34:56 -0500 Subject: [PATCH] =?UTF-8?q?feat(dir):=20=E2=9C=A8=20migrate=20query=20endp?= =?UTF-8?q?oints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilterLists.Directory.Api/Endpoints.cs | 56 ++++ .../FilterLists.Directory.Api.csproj | 1 + .../FilterLists.Directory.Api/Program.cs | 36 +-- .../Properties/launchSettings.json | 6 +- .../ConfigurationExtensions.cs | 17 ++ .../FilterLists.Directory.Application.csproj | 18 ++ .../Queries/GetLanguages.cs | 66 +++++ .../Queries/GetLicenses.cs | 87 ++++++ .../Queries/GetListDetails.cs | 255 ++++++++++++++++++ .../Queries/GetLists.cs | 114 ++++++++ .../Queries/GetMaintainers.cs | 81 ++++++ .../Queries/GetSoftware.cs | 92 +++++++ .../Queries/GetSyntaxes.cs | 83 ++++++ .../Queries/GetTags.cs | 67 +++++ .../Program.cs | 2 +- .../ConfigurationExtensions.cs | 11 +- .../Queries/Context/IQueryContext.cs | 14 - .../Queries/Context/QueryContext.cs | 25 -- .../Queries/Context/QueryDbContext.cs | 13 +- services/FilterLists.AppHost/Program.cs | 3 +- services/FilterLists.sln | 7 + 21 files changed, 964 insertions(+), 90 deletions(-) create mode 100644 services/Directory/FilterLists.Directory.Api/Endpoints.cs create mode 100644 services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs create mode 100644 services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetLanguages.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetLicenses.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetMaintainers.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetSoftware.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetSyntaxes.cs create mode 100644 services/Directory/FilterLists.Directory.Application/Queries/GetTags.cs delete mode 100644 services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs delete mode 100644 services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs diff --git a/services/Directory/FilterLists.Directory.Api/Endpoints.cs b/services/Directory/FilterLists.Directory.Api/Endpoints.cs new file mode 100644 index 000000000..aa21a36c1 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Api/Endpoints.cs @@ -0,0 +1,56 @@ +using FilterLists.Directory.Application.Queries; +using MediatR; +using Microsoft.AspNetCore.Http.HttpResults; +using static FilterLists.Directory.Application.Queries.GetListDetails; + +namespace FilterLists.Directory.Api; + +internal static class Endpoints +{ + internal static void MapEndpoints(this WebApplication app) + { + app.MapGet("/languages", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetLanguages.Request(), ct) + ); + + app.MapGet("/licenses", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetLicenses.Request(), ct) + ); + + app.MapGet("/lists", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetLists.Request(), ct) + ); + + app.MapGet("/lists/{id:int}", async Task, NotFound>> + (int id, IMediator mediator, CancellationToken ct) => + { + var list = await mediator.Send(new Request(id), ct); + return list is not null + ? TypedResults.Ok(list) + : TypedResults.NotFound(); + }); + + app.MapGet("/maintainers", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetMaintainers.Request(), ct) + ); + + app.MapGet("/software", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetSoftware.Request(), ct) + ); + + app.MapGet("/syntaxes", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetSyntaxes.Request(), ct) + ); + + app.MapGet("/tags", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetTags.Request(), ct) + ); + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj index d2d4c4c76..924f81eb4 100644 --- a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj +++ b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/services/Directory/FilterLists.Directory.Api/Program.cs b/services/Directory/FilterLists.Directory.Api/Program.cs index ae2a2c886..62c953281 100644 --- a/services/Directory/FilterLists.Directory.Api/Program.cs +++ b/services/Directory/FilterLists.Directory.Api/Program.cs @@ -1,41 +1,17 @@ +using FilterLists.Directory.Api; +using FilterLists.Directory.Application; + var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(serverOptions => serverOptions.AddServerHeader = false); - -// Add service defaults & Aspire components. builder.AddServiceDefaults(); - -// Add services to the container. builder.Services.AddProblemDetails(); +builder.AddApplication(); var app = builder.Build(); -// Configure the HTTP request pipeline. app.UseExceptionHandler(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}); - +app.MapEndpoints(); app.MapDefaultEndpoints(); -app.Run(); - -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file +app.Run(); \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json b/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json index 7abf85672..c3363795d 100644 --- a/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json +++ b/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "lists", "applicationUrl": "http://localhost:5444", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -15,11 +15,11 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "lists", "applicationUrl": "https://localhost:7490;http://localhost:5444", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs b/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs new file mode 100644 index 000000000..5a45794c0 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs @@ -0,0 +1,17 @@ +using FilterLists.Directory.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FilterLists.Directory.Application; + +public static class ConfigurationExtensions +{ + public static void AddApplication(this IHostApplicationBuilder builder) + { + builder.AddInfrastructure(); + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(ConfigurationExtensions).Assembly); + }); + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj new file mode 100644 index 000000000..489f452c3 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetLanguages.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetLanguages.cs new file mode 100644 index 000000000..4384e9289 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetLanguages.cs @@ -0,0 +1,66 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetLanguages +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Languages + .Where(l => l.FilterListLanguages.Any()) + .OrderBy(l => l.Iso6391) + .Select(l => new LanguageVm + { + Id = l.Id, + Iso6391 = l.Iso6391, + Name = l.Name, + FilterListIds = l.FilterListLanguages + .OrderBy(fl => fl.FilterListId) + .Select(fl => fl.FilterListId) + }) + .TagWith(nameof(GetLanguages)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var language in Query(ctx).WithCancellation(ct)) yield return language; + } + } + + [PublicAPI] + public record LanguageVm + { + /// + /// The identifier. + /// + /// 37 + public short Id { get; init; } + + /// + /// The unique ISO 639-1 code. + /// + /// en + public required string Iso6391 { get; init; } + + /// + /// The unique ISO name. + /// + /// English + public required string Name { get; init; } + + /// + /// The identifiers of the FilterLists targeted by this Language. + /// + /// [ 114, 141 ] + public IEnumerable FilterListIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetLicenses.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetLicenses.cs new file mode 100644 index 000000000..9ca96698a --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetLicenses.cs @@ -0,0 +1,87 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetLicenses +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Licenses + .Where(l => l.FilterLists.Any()) + .OrderBy(l => l.Id) + .Select(l => new LicenseVm + { + Id = l.Id, + Name = l.Name, + Url = l.Url, + PermitsModification = l.PermitsModification, + PermitsDistribution = l.PermitsDistribution, + PermitsCommercialUse = l.PermitsCommercialUse, + FilterListIds = l.FilterLists + .OrderBy(f => f.Id) + .Select(f => f.Id) + }) + .TagWith(nameof(GetLicenses)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var license in Query(ctx).WithCancellation(ct)) yield return license; + } + } + + [PublicAPI] + public record LicenseVm + { + /// + /// The identifier. + /// + /// 5 + public int Id { get; init; } + + /// + /// The unique name. + /// + /// All Rights Reserved + public required string Name { get; init; } + + /// + /// The URL of the home page. + /// + /// https://en.wikipedia.org/wiki/All_rights_reserved + public Uri? Url { get; init; } + + /// + /// If the License permits modification. + /// + /// false + public bool PermitsModification { get; init; } + + /// + /// If the License permits distribution. + /// + /// false + public bool PermitsDistribution { get; init; } + + /// + /// If the License permits commercial use. + /// + /// false + public bool PermitsCommercialUse { get; init; } + + /// + /// The identifiers of the FilterLists released under this License. + /// + /// [ 6, 31 ] + public IEnumerable FilterListIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs new file mode 100644 index 000000000..7d4df4cd6 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs @@ -0,0 +1,255 @@ +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; +using static FilterLists.Directory.Application.Queries.GetListDetails.ListDetailsVm; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetListDetails +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx, int id) => + ctx.FilterLists + .Select(f => new ListDetailsVm + { + Id = f.Id, + Name = f.Name, + Description = f.Description, + LicenseId = f.LicenseId, + SyntaxIds = f.FilterListSyntaxes + .OrderBy(fs => fs.SyntaxId) + .Select(fs => fs.SyntaxId), + LanguageIds = f.FilterListLanguages + .OrderBy(fl => fl.LanguageId) + .Select(fl => fl.LanguageId), + TagIds = f.FilterListTags + .OrderBy(ft => ft.TagId) + .Select(ft => ft.TagId), + ViewUrls = f.ViewUrls + .OrderBy(u => u.SegmentNumber) + .ThenBy(u => u.Primariness) + .Select(u => new ViewUrlVm + { + SegmentNumber = u.SegmentNumber, + Primariness = u.Primariness, + Url = u.Url + }), + HomeUrl = f.HomeUrl, + OnionUrl = f.OnionUrl, + PolicyUrl = f.PolicyUrl, + SubmissionUrl = f.SubmissionUrl, + IssuesUrl = f.IssuesUrl, + ForumUrl = f.ForumUrl, + ChatUrl = f.ChatUrl, + EmailAddress = f.EmailAddress, + DonateUrl = f.DonateUrl, + MaintainerIds = f.FilterListMaintainers + .OrderBy(fm => fm.MaintainerId) + .Select(fm => fm.MaintainerId), + UpstreamFilterListIds = f.UpstreamFilterLists + .OrderBy(ff => ff.UpstreamFilterListId) + .Select(ff => ff.UpstreamFilterListId), + ForkFilterListIds = f.ForkFilterLists + .OrderBy(ff => ff.ForkFilterListId) + .Select(ff => ff.ForkFilterListId), + IncludedInFilterListIds = f.IncludedInFilterLists + .OrderBy(fm => fm.IncludedInFilterListId) + .Select(fm => fm.IncludedInFilterListId), + IncludesFilterListIds = f.IncludesFilterLists + .OrderBy(fm => fm.IncludesFilterListId) + .Select(fm => fm.IncludesFilterListId), + DependencyFilterListIds = f.DependencyFilterLists + .OrderBy(fd => fd.DependencyFilterListId) + .Select(fd => fd.DependencyFilterListId), + DependentFilterListIds = f.DependentFilterLists + .OrderBy(fd => fd.DependentFilterListId) + .Select(fd => fd.DependentFilterListId) + }) + .TagWith(nameof(GetListDetails)) + .SingleOrDefault(f => f.Id == id)); + + public sealed record Request(int Id) : IRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IRequestHandler + { + public Task Handle(Request request, CancellationToken _) + { + return Query(ctx, request.Id); + } + } + + [PublicAPI] + public record ListDetailsVm + { + /// + /// The identifier. + /// + /// 301 + public int Id { get; init; } + + /// + /// The unique name in title case. + /// + /// EasyList + public required string Name { get; init; } + + /// + /// The brief description in English (preferably quoted from the project). + /// + /// + /// EasyList is the primary filter list that removes most adverts from international web pages, including unwanted + /// frames, images, and objects. It is the most popular list used by many ad blockers and forms the basis of over a + /// dozen combination and supplementary filter lists. + /// + public string? Description { get; init; } + + /// + /// The identifier of the License under which this FilterList is released. + /// + /// 4 + public int LicenseId { get; init; } + + /// + /// The identifiers of the Syntaxes implemented by this FilterList. + /// + /// [ 3 ] + public IEnumerable SyntaxIds { get; init; } = []; + + /// + /// The identifiers of the Languages targeted by this FilterList. + /// + /// [ 37 ] + public IEnumerable LanguageIds { get; init; } = []; + + /// + /// The identifiers of the Tags applied to this FilterList. + /// + /// [ 2 ] + public IEnumerable TagIds { get; init; } = []; + + /// + /// The view URLs. + /// + public IEnumerable ViewUrls { get; init; } = []; + + /// + /// The URL of the home page. + /// + /// https://easylist.to/ + public Uri? HomeUrl { get; init; } + + /// + /// The URL of the Tor / Onion page. + /// + /// null + public Uri? OnionUrl { get; init; } + + /// + /// The URL of the policy/guidelines for the types of rules this FilterList includes. + /// + /// null + public Uri? PolicyUrl { get; init; } + + /// + /// The URL of the submission/contact form for adding rules to this FilterList. + /// + /// null + public Uri? SubmissionUrl { get; init; } + + /// + /// The URL of the GitHub Issues page. + /// + /// https://github.com/easylist/easylist/issues + public Uri? IssuesUrl { get; init; } + + /// + /// The URL of the forum page. + /// + /// https://forums.lanik.us/viewforum.php?f=23 + public Uri? ForumUrl { get; init; } + + /// + /// The URL of the chat room. + /// + /// null + public Uri? ChatUrl { get; init; } + + /// + /// The email address at which the project can be contacted. + /// + /// easylist@protonmail.com + public string? EmailAddress { get; init; } + + /// + /// The URL at which donations to the project can be made. + /// + /// null + public Uri? DonateUrl { get; init; } + + /// + /// The identifiers of the Maintainers of this FilterList. + /// + /// [ 7 ] + public IEnumerable MaintainerIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists from which this FilterList was forked. + /// + /// [] + public IEnumerable UpstreamFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that have been forked from this FilterList. + /// + /// [ 166, 565 ] + public IEnumerable ForkFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that include this FilterList. + /// + /// [] + public IEnumerable IncludedInFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that this FilterList includes. + /// + /// [ 11, 13, 168 ] + public IEnumerable IncludesFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that this FilterList depends upon. + /// + /// [] + public IEnumerable DependencyFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists dependent upon this FilterList. + /// + /// [] + public IEnumerable DependentFilterListIds { get; init; } = []; + + [PublicAPI] + public record ViewUrlVm + { + /// + /// The segment number of the URL for the FilterList (for multi-part lists). + /// + /// 1 + public short SegmentNumber { get; init; } + + /// + /// How primary the URL is for the FilterList segment (1 is original, 2+ is a mirror; unique per SegmentNumber) + /// + /// 1 + public short Primariness { get; init; } + + /// + /// The view URL. + /// + /// https://easylist.to/easylist/easylist.txt + public required Uri Url { get; init; } + } + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs new file mode 100644 index 000000000..4cb736641 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs @@ -0,0 +1,114 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetLists +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.FilterLists + .OrderBy(f => f.Id) + .Select(f => new ListVm + { + Id = f.Id, + Name = f.Name, + Description = f.Description, + LicenseId = f.LicenseId, + SyntaxIds = f.FilterListSyntaxes + .OrderBy(fs => fs.SyntaxId) + .Select(fs => fs.SyntaxId), + LanguageIds = f.FilterListLanguages + .OrderBy(fl => fl.LanguageId) + .Select(fl => fl.LanguageId), + TagIds = f.FilterListTags + .OrderBy(ft => ft.TagId) + .Select(ft => ft.TagId), + PrimaryViewUrl = f.ViewUrls + .OrderBy(u => u.SegmentNumber) + .ThenBy(u => u.Primariness) + .Select(u => u.Url) + .FirstOrDefault(), + MaintainerIds = f.FilterListMaintainers + .OrderBy(fm => fm.MaintainerId) + .Select(fm => fm.MaintainerId) + }) + .TagWith(nameof(GetLists)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var list in Query(ctx).WithCancellation(ct)) yield return list; + } + } + + [PublicAPI] + public record ListVm + { + /// + /// The identifier. + /// + /// 301 + public int Id { get; init; } + + /// + /// The unique name in title case. + /// + /// EasyList + public required string Name { get; init; } + + /// + /// The brief description in English (preferably quoted from the project). + /// + /// + /// EasyList is the primary filter list that removes most adverts from international web pages, including unwanted + /// frames, images, and objects. It is the most popular list used by many ad blockers and forms the basis of over a + /// dozen combination and supplementary filter lists. + /// + public string? Description { get; init; } + + /// + /// The identifier of the License under which this FilterList is released. + /// + /// 4 + public int LicenseId { get; init; } + + /// + /// The identifiers of the Syntaxes implemented by this FilterList. + /// + /// [ 3 ] + public IEnumerable SyntaxIds { get; init; } = []; + + /// + /// The identifiers of the Languages targeted by this FilterList. + /// + /// [ 37 ] + public IEnumerable LanguageIds { get; init; } = []; + + /// + /// The identifiers of the Tags applied to this FilterList. + /// + /// [ 2 ] + public IEnumerable TagIds { get; init; } = []; + + /// + /// The primary view URL. + /// + /// https://easylist.to/easylist/easylist.txt + public Uri? PrimaryViewUrl { get; init; } + + /// + /// The identifiers of the Maintainers of this FilterList. + /// + /// [ 7 ] + public IEnumerable MaintainerIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetMaintainers.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetMaintainers.cs new file mode 100644 index 000000000..aaa3b2db6 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetMaintainers.cs @@ -0,0 +1,81 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetMaintainers +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Maintainers + .Where(m => m.FilterListMaintainers.Any()) + .OrderBy(m => m.Id) + .Select(m => new MaintainerVm + { + Id = m.Id, + Name = m.Name, + Url = m.Url, + EmailAddress = m.EmailAddress, + TwitterHandle = m.TwitterHandle, + FilterListIds = m.FilterListMaintainers + .OrderBy(fm => fm.FilterListId) + .Select(fm => fm.FilterListId) + }) + .TagWith(nameof(GetMaintainers)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var maintainer in Query(ctx).WithCancellation(ct)) yield return maintainer; + } + } + + [PublicAPI] + public record MaintainerVm + { + /// + /// The identifier. + /// + /// 7 + public int Id { get; init; } + + /// + /// The unique name. + /// + /// The EasyList Authors + public required string Name { get; init; } + + /// + /// The URL of the home page. + /// + /// https://easylist.to/ + public Uri? Url { get; init; } + + /// + /// The email address. + /// + /// null + public string? EmailAddress { get; init; } + + /// + /// The Twitter handle. + /// + /// null + public string? TwitterHandle { get; init; } + + /// + /// The identifiers of the FilterLists maintained by this Maintainer. + /// + /// [ 11, 13, 301 ] + public IEnumerable FilterListIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetSoftware.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetSoftware.cs new file mode 100644 index 000000000..7d639011e --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetSoftware.cs @@ -0,0 +1,92 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetSoftware +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Software + .Where(s => s.SoftwareSyntaxes + .Any(ss => ss.Syntax.FilterListSyntaxes.Any())) + .OrderBy(s => s.Id) + .Select(s => new SoftwareVm + { + Id = s.Id, + Name = s.Name, + Description = s.Description, + HomeUrl = s.HomeUrl, + DownloadUrl = s.DownloadUrl, + SupportsAbpUrlScheme = s.SupportsAbpUrlScheme, + SyntaxIds = s.SoftwareSyntaxes + .OrderBy(ss => ss.SyntaxId) + .Select(ss => ss.SyntaxId) + }) + .TagWith(nameof(GetSoftware)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var software in Query(ctx).WithCancellation(ct)) yield return software; + } + } + + [PublicAPI] + public record SoftwareVm + { + /// + /// The identifier. + /// + /// 2 + public long Id { get; init; } + + /// + /// The unique name. + /// + /// Adblock Plus + public required string Name { get; init; } + + /// + /// The description. + /// + /// + /// Adblock Plus is a free extension that allows you to customize your web experience. You can block annoying ads, + /// disable tracking and lots more. It’s available for all major desktop browsers and for your mobile devices. + /// + public string? Description { get; init; } + + /// + /// The URL of the home page. + /// + /// https://adblockplus.org/ + public Uri? HomeUrl { get; init; } + + /// + /// The URL of the Software download. + /// + /// https://adblockplus.org/ + public Uri? DownloadUrl { get; init; } + + /// + /// If the Software supports the abp: URL scheme to click-to-subscribe to a FilterList. + /// + /// true + public bool SupportsAbpUrlScheme { get; init; } + + /// + /// The identifiers of the Syntaxes that this Software supports. + /// + /// [ 3, 28, 38, 48 ] + public IEnumerable SyntaxIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetSyntaxes.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetSyntaxes.cs new file mode 100644 index 000000000..4da46d23d --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetSyntaxes.cs @@ -0,0 +1,83 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetSyntaxes +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Syntaxes + .Where(s => s.FilterListSyntaxes.Any()) + .OrderBy(s => s.Id) + .Select(s => new SyntaxVm + { + Id = s.Id, + Name = s.Name, + Description = s.Description, + Url = s.Url, + FilterListIds = s.FilterListSyntaxes + .OrderBy(fs => fs.FilterListId) + .Select(fs => fs.FilterListId), + SoftwareIds = s.SoftwareSyntaxes + .OrderBy(ss => ss.SoftwareId) + .Select(ss => ss.SoftwareId) + }) + .TagWith(nameof(GetSyntaxes)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var syntax in Query(ctx).WithCancellation(ct)) yield return syntax; + } + } + + [PublicAPI] + public record SyntaxVm + { + /// + /// The identifier. + /// + /// 3 + public short Id { get; init; } + + /// + /// The unique name. + /// + /// Adblock Plus + public required string Name { get; init; } + + /// + /// The description. + /// + /// null + public string? Description { get; init; } + + /// + /// The URL of the home page. + /// + /// https://adblockplus.org/filters + public Uri? Url { get; init; } + + /// + /// The identifiers of the FilterLists implementing this Syntax. + /// + /// [ 2, 6, 11 ] + public IEnumerable FilterListIds { get; init; } = []; + + /// + /// The identifiers of the Software that supports this Syntax. + /// + /// [ 1, 2, 3 ] + public IEnumerable SoftwareIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetTags.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetTags.cs new file mode 100644 index 000000000..14f64a0d3 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetTags.cs @@ -0,0 +1,67 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetTags +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.Tags + .Where(t => t.FilterListTags.Any()) + .OrderBy(t => t.Id) + .Select(t => new TagVm + { + Id = t.Id, + Name = t.Name, + Description = t.Description, + FilterListIds = t.FilterListTags + .OrderBy(flt => flt.FilterListId) + .Select(flt => flt.FilterListId) + }) + .TagWith(nameof(GetTags)) + ); + + public sealed record Request : IStreamRequest; + + [UsedImplicitly] + private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var tag in Query(ctx).WithCancellation(ct)) yield return tag; + } + } + + [PublicAPI] + public record TagVm + { + /// + /// The identifier. + /// + /// 2 + public int Id { get; init; } + + /// + /// The unique name. + /// + /// ads + public required string Name { get; init; } + + /// + /// The description. + /// + /// Blocks advertisements + public string? Description { get; init; } + + /// + /// The identifiers of the FilterLists to which this Tag is applied. + /// + /// [ 1, 3, 6 ] + public IEnumerable FilterListIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs index 23db377b2..310eafc06 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs @@ -2,7 +2,7 @@ using FilterLists.Directory.Infrastructure.MigrationService; var builder = Host.CreateApplicationBuilder(args); -builder.AddInfrastructureForDbMaintenance(); +builder.AddInfrastructure(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs index d89406738..e6313f41d 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs @@ -6,11 +6,14 @@ namespace FilterLists.Directory.Infrastructure; public static class ConfigurationExtensions { - public static void AddInfrastructureForDbMaintenance(this IHostApplicationBuilder builder) + public static void AddInfrastructure(this IHostApplicationBuilder builder) { builder.AddSqlServerDbContext("directorydb", - settings => { }, - o => o.UseSqlServer(so => so.MigrationsAssembly("FilterLists.Directory.Infrastructure.Migrations")) - ); + _ => { }, + o => + { + o.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + o.UseSqlServer(so => so.MigrationsAssembly("FilterLists.Directory.Infrastructure.Migrations")); + }); } } \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs deleted file mode 100644 index b5ac7501a..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FilterLists.Directory.Infrastructure.Persistence.Queries.Entities; - -namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; - -public interface IQueryContext -{ - IQueryable FilterLists { get; } - IQueryable Languages { get; } - IQueryable Licenses { get; } - IQueryable Maintainers { get; } - IQueryable Software { get; } - IQueryable Syntaxes { get; } - IQueryable Tags { get; } -} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs deleted file mode 100644 index 815583569..000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FilterLists.Directory.Infrastructure.Persistence.Queries.Entities; -using Microsoft.EntityFrameworkCore; - -namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; - -internal sealed class QueryContext(QueryDbContext dbContext) : IQueryContext, IAsyncDisposable, IDisposable -{ - public ValueTask DisposeAsync() - { - return dbContext.DisposeAsync(); - } - - void IDisposable.Dispose() - { - dbContext.Dispose(); - } - - public IQueryable FilterLists => dbContext.FilterLists.AsNoTracking(); - public IQueryable Languages => dbContext.Languages.AsNoTracking(); - public IQueryable Licenses => dbContext.Licenses.AsNoTracking(); - public IQueryable Maintainers => dbContext.Maintainers.AsNoTracking(); - public IQueryable Software => dbContext.Software.AsNoTracking(); - public IQueryable Syntaxes => dbContext.Syntaxes.AsNoTracking(); - public IQueryable Tags => dbContext.Tags.AsNoTracking(); -} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs index 7d49f6b68..bffc5b84a 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs @@ -3,6 +3,7 @@ namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +// TODO: explicitly make more readonly-ish public class QueryDbContext(DbContextOptions options) : DbContext(options) { public DbSet FilterLists => Set(); @@ -13,18 +14,6 @@ public class QueryDbContext(DbContextOptions options) : DbContex public DbSet Syntaxes => Set(); public DbSet Tags => Set(); - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - throw new InvalidOperationException("This context is read-only."); - } - - public override Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default) - { - throw new InvalidOperationException("This context is read-only."); - } - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.UseCollation("Latin1_General_100_CI_AS_SC"); diff --git a/services/FilterLists.AppHost/Program.cs b/services/FilterLists.AppHost/Program.cs index 35b1323c9..ff8dde723 100644 --- a/services/FilterLists.AppHost/Program.cs +++ b/services/FilterLists.AppHost/Program.cs @@ -18,6 +18,7 @@ builder.AddProject("directoryapi") .WithReference(directoryDb) - .WithReference(appInsights); + .WithReference(appInsights) + .WithExternalHttpEndpoints(); builder.Build().Run(); \ No newline at end of file diff --git a/services/FilterLists.sln b/services/FilterLists.sln index e1ae5da4a..bf2424d2d 100644 --- a/services/FilterLists.sln +++ b/services/FilterLists.sln @@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infra EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infrastructure", "Directory\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj", "{02B4FF36-8012-42CD-BE3E-92475C091A4C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Application", "Directory\FilterLists.Directory.Application\FilterLists.Directory.Application.csproj", "{0D4420C3-283B-4C0A-88B4-D39E2033B80F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Release|Any CPU.Build.0 = Release|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,5 +70,6 @@ Global {5DD434A8-D394-4178-9DF3-669EC85EA229} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} {37170729-4BC9-412F-9ADF-97592A677E90} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} {02B4FF36-8012-42CD-BE3E-92475C091A4C} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} + {0D4420C3-283B-4C0A-88B4-D39E2033B80F} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} EndGlobalSection EndGlobal