feat(dir): migrate query endpoints

This commit is contained in:
Collin Barrett 2024-06-02 16:34:56 -05:00
parent 8268dfbec8
commit 07343fa0d9
21 changed files with 964 additions and 90 deletions

View file

@ -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<Results<Ok<ListDetailsVm>, 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)
);
}
}

View file

@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\FilterLists.ServiceDefaults\FilterLists.ServiceDefaults.csproj"/>
<ProjectReference Include="..\FilterLists.Directory.Application\FilterLists.Directory.Application.csproj"/>
</ItemGroup>
</Project>

View file

@ -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);
}
app.Run();

View file

@ -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"
}
}
}
}
}

View file

@ -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);
});
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0"/>
<PackageReference Include="MediatR" Version="12.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj"/>
</ItemGroup>
</Project>

View file

@ -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<QueryDbContext, IAsyncEnumerable<LanguageVm>> 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<LanguageVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, LanguageVm>
{
public async IAsyncEnumerable<LanguageVm> Handle(Request request, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var language in Query(ctx).WithCancellation(ct)) yield return language;
}
}
[PublicAPI]
public record LanguageVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>37</example>
public short Id { get; init; }
/// <summary>
/// The unique ISO 639-1 code.
/// </summary>
/// <example>en</example>
public required string Iso6391 { get; init; }
/// <summary>
/// The unique ISO name.
/// </summary>
/// <example>English</example>
public required string Name { get; init; }
/// <summary>
/// The identifiers of the FilterLists targeted by this Language.
/// </summary>
/// <example>[ 114, 141 ]</example>
public IEnumerable<int> FilterListIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<LicenseVm>> 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<LicenseVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, LicenseVm>
{
public async IAsyncEnumerable<LicenseVm> Handle(Request request, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var license in Query(ctx).WithCancellation(ct)) yield return license;
}
}
[PublicAPI]
public record LicenseVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>5</example>
public int Id { get; init; }
/// <summary>
/// The unique name.
/// </summary>
/// <example>All Rights Reserved</example>
public required string Name { get; init; }
/// <summary>
/// The URL of the home page.
/// </summary>
/// <example>https://en.wikipedia.org/wiki/All_rights_reserved</example>
public Uri? Url { get; init; }
/// <summary>
/// If the License permits modification.
/// </summary>
/// <example>false</example>
public bool PermitsModification { get; init; }
/// <summary>
/// If the License permits distribution.
/// </summary>
/// <example>false</example>
public bool PermitsDistribution { get; init; }
/// <summary>
/// If the License permits commercial use.
/// </summary>
/// <example>false</example>
public bool PermitsCommercialUse { get; init; }
/// <summary>
/// The identifiers of the FilterLists released under this License.
/// </summary>
/// <example>[ 6, 31 ]</example>
public IEnumerable<int> FilterListIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, int, Task<ListDetailsVm?>> 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<ListDetailsVm?>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IRequestHandler<Request, ListDetailsVm?>
{
public Task<ListDetailsVm?> Handle(Request request, CancellationToken _)
{
return Query(ctx, request.Id);
}
}
[PublicAPI]
public record ListDetailsVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>301</example>
public int Id { get; init; }
/// <summary>
/// The unique name in title case.
/// </summary>
/// <example>EasyList</example>
public required string Name { get; init; }
/// <summary>
/// The brief description in English (preferably quoted from the project).
/// </summary>
/// <example>
/// 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.
/// </example>
public string? Description { get; init; }
/// <summary>
/// The identifier of the License under which this FilterList is released.
/// </summary>
/// <example>4</example>
public int LicenseId { get; init; }
/// <summary>
/// The identifiers of the Syntaxes implemented by this FilterList.
/// </summary>
/// <example>[ 3 ]</example>
public IEnumerable<short> SyntaxIds { get; init; } = [];
/// <summary>
/// The identifiers of the Languages targeted by this FilterList.
/// </summary>
/// <example>[ 37 ]</example>
public IEnumerable<short> LanguageIds { get; init; } = [];
/// <summary>
/// The identifiers of the Tags applied to this FilterList.
/// </summary>
/// <example>[ 2 ]</example>
public IEnumerable<int> TagIds { get; init; } = [];
/// <summary>
/// The view URLs.
/// </summary>
public IEnumerable<ViewUrlVm> ViewUrls { get; init; } = [];
/// <summary>
/// The URL of the home page.
/// </summary>
/// <example>https://easylist.to/</example>
public Uri? HomeUrl { get; init; }
/// <summary>
/// The URL of the Tor / Onion page.
/// </summary>
/// <example>null</example>
public Uri? OnionUrl { get; init; }
/// <summary>
/// The URL of the policy/guidelines for the types of rules this FilterList includes.
/// </summary>
/// <example>null</example>
public Uri? PolicyUrl { get; init; }
/// <summary>
/// The URL of the submission/contact form for adding rules to this FilterList.
/// </summary>
/// <example>null</example>
public Uri? SubmissionUrl { get; init; }
/// <summary>
/// The URL of the GitHub Issues page.
/// </summary>
/// <example>https://github.com/easylist/easylist/issues</example>
public Uri? IssuesUrl { get; init; }
/// <summary>
/// The URL of the forum page.
/// </summary>
/// <example>https://forums.lanik.us/viewforum.php?f=23</example>
public Uri? ForumUrl { get; init; }
/// <summary>
/// The URL of the chat room.
/// </summary>
/// <example>null</example>
public Uri? ChatUrl { get; init; }
/// <summary>
/// The email address at which the project can be contacted.
/// </summary>
/// <example>easylist@protonmail.com</example>
public string? EmailAddress { get; init; }
/// <summary>
/// The URL at which donations to the project can be made.
/// </summary>
/// <example>null</example>
public Uri? DonateUrl { get; init; }
/// <summary>
/// The identifiers of the Maintainers of this FilterList.
/// </summary>
/// <example>[ 7 ]</example>
public IEnumerable<int> MaintainerIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists from which this FilterList was forked.
/// </summary>
/// <example>[]</example>
public IEnumerable<int> UpstreamFilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists that have been forked from this FilterList.
/// </summary>
/// <example>[ 166, 565 ]</example>
public IEnumerable<int> ForkFilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists that include this FilterList.
/// </summary>
/// <example>[]</example>
public IEnumerable<int> IncludedInFilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists that this FilterList includes.
/// </summary>
/// <example>[ 11, 13, 168 ]</example>
public IEnumerable<int> IncludesFilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists that this FilterList depends upon.
/// </summary>
/// <example>[]</example>
public IEnumerable<int> DependencyFilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the FilterLists dependent upon this FilterList.
/// </summary>
/// <example>[]</example>
public IEnumerable<int> DependentFilterListIds { get; init; } = [];
[PublicAPI]
public record ViewUrlVm
{
/// <summary>
/// The segment number of the URL for the FilterList (for multi-part lists).
/// </summary>
/// <example>1</example>
public short SegmentNumber { get; init; }
/// <summary>
/// How primary the URL is for the FilterList segment (1 is original, 2+ is a mirror; unique per SegmentNumber)
/// </summary>
/// <example>1</example>
public short Primariness { get; init; }
/// <summary>
/// The view URL.
/// </summary>
/// <example>https://easylist.to/easylist/easylist.txt</example>
public required Uri Url { get; init; }
}
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<ListVm>> 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<ListVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, ListVm>
{
public async IAsyncEnumerable<ListVm> Handle(Request request, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var list in Query(ctx).WithCancellation(ct)) yield return list;
}
}
[PublicAPI]
public record ListVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>301</example>
public int Id { get; init; }
/// <summary>
/// The unique name in title case.
/// </summary>
/// <example>EasyList</example>
public required string Name { get; init; }
/// <summary>
/// The brief description in English (preferably quoted from the project).
/// </summary>
/// <example>
/// 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.
/// </example>
public string? Description { get; init; }
/// <summary>
/// The identifier of the License under which this FilterList is released.
/// </summary>
/// <example>4</example>
public int LicenseId { get; init; }
/// <summary>
/// The identifiers of the Syntaxes implemented by this FilterList.
/// </summary>
/// <example>[ 3 ]</example>
public IEnumerable<short> SyntaxIds { get; init; } = [];
/// <summary>
/// The identifiers of the Languages targeted by this FilterList.
/// </summary>
/// <example>[ 37 ]</example>
public IEnumerable<short> LanguageIds { get; init; } = [];
/// <summary>
/// The identifiers of the Tags applied to this FilterList.
/// </summary>
/// <example>[ 2 ]</example>
public IEnumerable<int> TagIds { get; init; } = [];
/// <summary>
/// The primary view URL.
/// </summary>
/// <example>https://easylist.to/easylist/easylist.txt</example>
public Uri? PrimaryViewUrl { get; init; }
/// <summary>
/// The identifiers of the Maintainers of this FilterList.
/// </summary>
/// <example>[ 7 ]</example>
public IEnumerable<int> MaintainerIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<MaintainerVm>> 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<MaintainerVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, MaintainerVm>
{
public async IAsyncEnumerable<MaintainerVm> Handle(Request request,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var maintainer in Query(ctx).WithCancellation(ct)) yield return maintainer;
}
}
[PublicAPI]
public record MaintainerVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>7</example>
public int Id { get; init; }
/// <summary>
/// The unique name.
/// </summary>
/// <example>The EasyList Authors</example>
public required string Name { get; init; }
/// <summary>
/// The URL of the home page.
/// </summary>
/// <example>https://easylist.to/</example>
public Uri? Url { get; init; }
/// <summary>
/// The email address.
/// </summary>
/// <example>null</example>
public string? EmailAddress { get; init; }
/// <summary>
/// The Twitter handle.
/// </summary>
/// <example>null</example>
public string? TwitterHandle { get; init; }
/// <summary>
/// The identifiers of the FilterLists maintained by this Maintainer.
/// </summary>
/// <example>[ 11, 13, 301 ]</example>
public IEnumerable<int> FilterListIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<SoftwareVm>> 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<SoftwareVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, SoftwareVm>
{
public async IAsyncEnumerable<SoftwareVm> Handle(Request request,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var software in Query(ctx).WithCancellation(ct)) yield return software;
}
}
[PublicAPI]
public record SoftwareVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>2</example>
public long Id { get; init; }
/// <summary>
/// The unique name.
/// </summary>
/// <example>Adblock Plus</example>
public required string Name { get; init; }
/// <summary>
/// The description.
/// </summary>
/// <example>
/// Adblock Plus is a free extension that allows you to customize your web experience. You can block annoying ads,
/// disable tracking and lots more. Its available for all major desktop browsers and for your mobile devices.
/// </example>
public string? Description { get; init; }
/// <summary>
/// The URL of the home page.
/// </summary>
/// <example>https://adblockplus.org/</example>
public Uri? HomeUrl { get; init; }
/// <summary>
/// The URL of the Software download.
/// </summary>
/// <example>https://adblockplus.org/</example>
public Uri? DownloadUrl { get; init; }
/// <summary>
/// If the Software supports the abp: URL scheme to click-to-subscribe to a FilterList.
/// </summary>
/// <example>true</example>
public bool SupportsAbpUrlScheme { get; init; }
/// <summary>
/// The identifiers of the Syntaxes that this Software supports.
/// </summary>
/// <example>[ 3, 28, 38, 48 ]</example>
public IEnumerable<short> SyntaxIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<SyntaxVm>> 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<SyntaxVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, SyntaxVm>
{
public async IAsyncEnumerable<SyntaxVm> Handle(Request request,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var syntax in Query(ctx).WithCancellation(ct)) yield return syntax;
}
}
[PublicAPI]
public record SyntaxVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>3</example>
public short Id { get; init; }
/// <summary>
/// The unique name.
/// </summary>
/// <example>Adblock Plus</example>
public required string Name { get; init; }
/// <summary>
/// The description.
/// </summary>
/// <example>null</example>
public string? Description { get; init; }
/// <summary>
/// The URL of the home page.
/// </summary>
/// <example>https://adblockplus.org/filters</example>
public Uri? Url { get; init; }
/// <summary>
/// The identifiers of the FilterLists implementing this Syntax.
/// </summary>
/// <example>[ 2, 6, 11 ]</example>
public IEnumerable<int> FilterListIds { get; init; } = [];
/// <summary>
/// The identifiers of the Software that supports this Syntax.
/// </summary>
/// <example>[ 1, 2, 3 ]</example>
public IEnumerable<short> SoftwareIds { get; init; } = [];
}
}

View file

@ -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<QueryDbContext, IAsyncEnumerable<TagVm>> 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<TagVm>;
[UsedImplicitly]
private sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler<Request, TagVm>
{
public async IAsyncEnumerable<TagVm> Handle(Request request,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var tag in Query(ctx).WithCancellation(ct)) yield return tag;
}
}
[PublicAPI]
public record TagVm
{
/// <summary>
/// The identifier.
/// </summary>
/// <example>2</example>
public int Id { get; init; }
/// <summary>
/// The unique name.
/// </summary>
/// <example>ads</example>
public required string Name { get; init; }
/// <summary>
/// The description.
/// </summary>
/// <example>Blocks advertisements</example>
public string? Description { get; init; }
/// <summary>
/// The identifiers of the FilterLists to which this Tag is applied.
/// </summary>
/// <example>[ 1, 3, 6 ]</example>
public IEnumerable<int> FilterListIds { get; init; } = [];
}
}

View file

@ -2,7 +2,7 @@
using FilterLists.Directory.Infrastructure.MigrationService;
var builder = Host.CreateApplicationBuilder(args);
builder.AddInfrastructureForDbMaintenance();
builder.AddInfrastructure();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();

View file

@ -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<QueryDbContext>("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"));
});
}
}

View file

@ -1,14 +0,0 @@
using FilterLists.Directory.Infrastructure.Persistence.Queries.Entities;
namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
public interface IQueryContext
{
IQueryable<FilterList> FilterLists { get; }
IQueryable<Language> Languages { get; }
IQueryable<License> Licenses { get; }
IQueryable<Maintainer> Maintainers { get; }
IQueryable<Software> Software { get; }
IQueryable<Syntax> Syntaxes { get; }
IQueryable<Tag> Tags { get; }
}

View file

@ -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<FilterList> FilterLists => dbContext.FilterLists.AsNoTracking();
public IQueryable<Language> Languages => dbContext.Languages.AsNoTracking();
public IQueryable<License> Licenses => dbContext.Licenses.AsNoTracking();
public IQueryable<Maintainer> Maintainers => dbContext.Maintainers.AsNoTracking();
public IQueryable<Software> Software => dbContext.Software.AsNoTracking();
public IQueryable<Syntax> Syntaxes => dbContext.Syntaxes.AsNoTracking();
public IQueryable<Tag> Tags => dbContext.Tags.AsNoTracking();
}

View file

@ -3,6 +3,7 @@
namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context;
// TODO: explicitly make more readonly-ish
public class QueryDbContext(DbContextOptions<QueryDbContext> options) : DbContext(options)
{
public DbSet<FilterList> FilterLists => Set<FilterList>();
@ -13,18 +14,6 @@ public class QueryDbContext(DbContextOptions<QueryDbContext> options) : DbContex
public DbSet<Syntax> Syntaxes => Set<Syntax>();
public DbSet<Tag> Tags => Set<Tag>();
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
throw new InvalidOperationException("This context is read-only.");
}
public override Task<int> 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");

View file

@ -18,6 +18,7 @@
builder.AddProject<FilterLists_Directory_Api>("directoryapi")
.WithReference(directoryDb)
.WithReference(appInsights);
.WithReference(appInsights)
.WithExternalHttpEndpoints();
builder.Build().Run();

View file

@ -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