initial commit

This commit is contained in:
fox
2026-06-24 14:25:32 +01:00
commit 9788479ae7
109 changed files with 8666 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /App
# Copy everything
COPY . ./
# Restore as distinct layers
RUN dotnet restore
# Build and publish a release
RUN dotnet publish -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0
VOLUME /data
WORKDIR /App
COPY --from=build /App/out .
ENTRYPOINT ["dotnet", "Solicitors.Api.dll"]
+14
View File
@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/app/">
<Project Path="app/Solicitors.Api/Solicitors.Api.csproj" />
</Folder>
<Folder Name="/libs/">
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
<Project Path="libs/Solicitors.Data/Solicitors.Data.csproj" />
<Project Path="libs/Solicitors.HtmlParsing/Solicitors.HtmlParsing.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Solicitors.Core.Tests/Solicitors.Core.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
namespace Solicitors.Api.Endpoints;
public static class CitiesEndpoints
{
public static void MapCities(this IEndpointRouteBuilder app)
{
app.MapGet(
"/cities",
(
[FromServices] IReadOnlyCitiesService citiesService,
CancellationToken cancellationToken
) => citiesService.GetAllCitiesAsync(cancellationToken));
}
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
namespace Solicitors.Api.Endpoints;
public static class RatingsProvidersEndpoints
{
public static void MapRatingsProviders(this IEndpointRouteBuilder app)
{
app.MapGet(
"/ratingsProviders",
(
[FromServices] IRatingsProviderService service,
CancellationToken cancellationToken
) => service.GetRatingsProvidersAsync(cancellationToken));
}
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Ordering;
namespace Solicitors.Api.Endpoints;
public static class SolicitorsEndpoints
{
public static void MapSolicitors(this IEndpointRouteBuilder app)
{
app.MapGet(
"/solicitors",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromServices] ISolicitorComparerFactory solicitorComparerFactory,
CancellationToken cancellationToken,
[FromQuery] uint pageNumber = 1,
[FromQuery] uint pageSize = 20,
[FromQuery] string? nameFilter = null,
[FromQuery] decimal minRating = 0,
[FromQuery] string[]? cities = null,
[FromQuery] string ratingsProvider = "Solicitors.com",
[FromQuery] OrderingType? orderingType = null) =>
{
IFilter<Solicitor>? filter = null;
if (!string.IsNullOrEmpty(nameFilter))
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter, StringComparison.CurrentCultureIgnoreCase));
if (cities is not null && cities.Length > 0)
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
if (minRating > 0)
filter = new WrapperFilter<Solicitor>(
filter,
solicitor => solicitor.Ratings.Any(rating => rating.Provider == ratingsProvider && rating.Value / rating.Maximum >= minRating / 5.0m));
var ordering = solicitorComparerFactory.GetComparer(orderingType, ratingsProvider);
return solicitorService.GetSolicitorSummariesAsync(
new Pagination(pageNumber, pageSize),
ratingsProvider,
filter,
ordering,
cancellationToken: cancellationToken);
}
);
app.MapGet(
"/solicitors/{id:guid}",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromRoute] Guid id,
CancellationToken cancellationToken
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
using Solicitors.Api.Endpoints;
using Solicitors.CacheBuild;
using Solicitors.Core;
using Solicitors.Data;
using Solicitors.HtmlParsing;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("/data/apiSettings.json", optional: true);
builder.Services.AddOpenApi();
builder.Services.AddCore();
builder.Services.AddData(builder.Configuration);
builder.Services.AddCacheBuild();
builder.Services.Configure<ImportConfiguration>(builder.Configuration.GetSection("Imports"));
builder.Services.AddHtmlParsing();
builder.Services.AddHttpClient();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapSolicitors();
app.MapCities();
app.MapRatingsProviders();
app.UseCors();
using (var scope = app.Services.CreateScope())
{
await scope.UseCacheBuildAsync(CancellationToken.None);
}
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7226;http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
<ProjectReference Include="..\..\libs\Solicitors.Data\Solicitors.Data.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@Solicitors.Api_HostAddress = http://localhost:5254
GET {{Solicitors.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,54 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Quartz;
namespace Solicitors.CacheBuild;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddCacheBuild(this IServiceCollection services)
{
return services
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
.AddScoped<ISolicitorImporter, SolicitorImporter>()
.AddQuartz()
.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);
}
public static async Task UseCacheBuildAsync(this IServiceScope scope, CancellationToken cancellationToken)
{
var factory = scope.ServiceProvider.GetRequiredService<ISchedulerFactory>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<ImportConfiguration>>().Value;
var scheduler = await factory.GetScheduler(cancellationToken);
var importJob = JobBuilder.Create<ImportRunner>()
.WithIdentity("ImportJob", "ImportGroup")
.Build();
var importTrigger = TriggerBuilder.Create()
.WithIdentity("ImportRunner", "ImportGroup")
.StartNow()
.WithSimpleSchedule(builder => builder
.WithIntervalInMinutes(options.ImportMinutes)
.RepeatForever())
.Build();
await scheduler.ScheduleJob(importJob, importTrigger, cancellationToken);
var staleJob = JobBuilder.Create<StaleDataRemover>()
.WithIdentity("StaleDataRemover", "RemoveGroup")
.Build();
var staleTrigger = TriggerBuilder.Create()
.WithIdentity("StaleDataTrigger", "RemoveGroup")
.StartNow()
.WithSimpleSchedule(builder => builder
.WithIntervalInMinutes(options.RemoveMinutes)
.RepeatForever())
.Build();
await scheduler.ScheduleJob(staleJob, staleTrigger, cancellationToken);
}
}
@@ -0,0 +1,6 @@
namespace Solicitors.CacheBuild;
public interface ISolicitorImporter
{
Task RunFullImport(CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
using Solicitors.Core.Models.Imports;
namespace Solicitors.CacheBuild;
internal interface ISolicitorParser
{
Task<SolicitorData[]> GetSolicitorsAsync(
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
namespace Solicitors.CacheBuild;
public class ImportConfiguration
{
public int StaleMinutes { get; set; }
public int RemoveMinutes { get; set; }
public int ImportMinutes { get; set; }
}
@@ -0,0 +1,15 @@
using Quartz;
using Solicitors.Core.Data;
namespace Solicitors.CacheBuild;
internal class ImportRunner(
ISolicitorImporter importer,
ISolicitorRepository repository) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await repository.EnsureCreatedAsync(context.CancellationToken);
await importer.RunFullImport(context.CancellationToken);
}
}
@@ -0,0 +1,20 @@
using Solicitors.Core.Data;
using Solicitors.Core.Models.Imports;
namespace Solicitors.CacheBuild;
internal class SolicitorImporter(
ISolicitorParser parser,
ISolicitorRepository repository) : ISolicitorImporter
{
public async Task RunFullImport(CancellationToken cancellationToken)
{
foreach (var solicitor in await parser.GetSolicitorsAsync(cancellationToken))
{
await RunImport(solicitor, cancellationToken);
}
}
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
=> repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
<ProjectReference Include="..\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal static class Consts
{
public const string BaseUrl = "https://www.solicitors.com";
public static readonly string[] Locations =
[
"london", "birmingham", "leeds", "manchester", "sheffield", "bradford", "liverpool", "bristol"
];
public const decimal MaxRating = 5.0m;
public const string Name = "Solicitors.com";
public const string IconPath = "/images/logo.svg";
}
@@ -0,0 +1,31 @@
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal class SolicitorBuilder
{
private readonly HashSet<string> _baseLocations = [];
public SolicitorBuilder(
string name,
string path,
string? shortDesc,
string baseLocation)
{
Name = name;
ShortDescription = shortDesc;
_baseLocations.Add(baseLocation);
Path = path;
}
public void AddBaseLocation(string baseLocation)
{
_baseLocations.Add(baseLocation);
}
public string Path { get; }
public string Name { get; }
public string? ShortDescription { get; }
public string[] BaseLocations => _baseLocations.ToArray();
}
@@ -0,0 +1,315 @@
using System.Diagnostics.CodeAnalysis;
using Solicitors.Core.Misc;
using Solicitors.Core.Models.Imports;
using Solicitors.HtmlParsing;
using Solicitors.HtmlParsing.Models;
namespace Solicitors.CacheBuild.SolicitorsDotCom;
internal class SolicitorParser : ISolicitorParser
{
private readonly HttpClient _client;
private readonly IHtmlParser _htmlParser;
public SolicitorParser(
HttpClient client,
IHtmlParser htmlParser)
{
_client = client;
_client.BaseAddress = new Uri(Consts.BaseUrl);
_htmlParser = htmlParser;
}
public async Task<SolicitorData[]> GetSolicitorsAsync(
CancellationToken cancellationToken = default)
{
var builders = new Dictionary<string, SolicitorBuilder>();
foreach (var location in Consts.Locations)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/conveyancing+{location}.html");
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
var nodes = _htmlParser.ParseHtml(html);
IHtmlNode? mainNode = null;
foreach (var node in nodes)
if (node.TryGetByTagName("main", out mainNode))
break;
if (TryGetResultSection(mainNode, out var results))
ParseResultsSection(results, location, builders);
}
var batches = builders.Values
.Select(builder => GetFullSolicitorDataAsync(builder, cancellationToken))
.Batch(5);
var solicitors = new List<SolicitorData?>();
foreach (var batch in batches)
solicitors.AddRange(await Task.WhenAll(batch));
return solicitors
.Where(x => x is not null)
.Cast<SolicitorData>()
.ToArray();
}
private bool TryGetResultSection(
IHtmlNode? mainNode,
[NotNullWhen(true)] out IEnumerable<IHtmlNode>? results)
{
results = null;
// going one level at a time helps a little with performance here, at the cost of messier code
return mainNode is not null
&& mainNode.TryGetByTagNameAndClass("div", "content-holder", out var contentHolderNode)
&& contentHolderNode.TryGetByTagNameAndClass("div", "container", out var containerNode)
&& containerNode.TryGetByTagNameAndClass("div", "content", out var contentNode)
&& contentNode.TryGetByTagNameAndClass("div", "result-section", out var resultSectionNode)
&& resultSectionNode.TryGetChildren(out results);
}
private void ParseResultsSection(
IEnumerable<IHtmlNode> results,
string location,
Dictionary<string, SolicitorBuilder> builders)
{
foreach (var result in results)
{
if (result.TryGetByClass("result-item", out var resultItem, true)
&& TryParseToNewBuilder(resultItem, location, out var builder))
{
if (!builders.TryAdd(builder.Path, builder))
builders[builder.Path].AddBaseLocation(location);
}
}
}
private bool TryParseToNewBuilder(
IHtmlNode resultItemNode,
string baseLocation,
[NotNullWhen(true)] out SolicitorBuilder? builder)
{
builder = null;
if (!resultItemNode.TryGetByTagNameAndClass("a", "link-map", out var linkMapNode)
|| !linkMapNode.TryGetAttributeValue("href", out var uniqueUrl)
|| !resultItemNode.TryGetByTagNameAndClass("span", "h2", out var titleNode)
|| !titleNode.TryGetChildren(out var titleChildren))
return false;
string? name = null;
foreach (var child in titleChildren)
if (child.TryGetText(out name))
break;
if (name is null)
return false;
string? shortDesc = null;
if (resultItemNode.TryGetByTagName("p", out var pNode)
&& pNode.TryGetChildren(out var pChildren))
foreach (var child in pChildren)
if (child.TryGetText(out shortDesc))
break;
builder = new SolicitorBuilder(name, uniqueUrl, shortDesc, baseLocation);
return true;
}
private async Task<SolicitorData?> GetFullSolicitorDataAsync(
SolicitorBuilder builder,
CancellationToken cancellationToken)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, builder.Path);
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
var nodes = _htmlParser.ParseHtml(html);
IHtmlNode? mainNode = null;
foreach (var node in nodes)
if (node.TryGetByTagName("main", out mainNode))
break;
if (mainNode is null || !mainNode.TryGetByTagNameAndClass("div", "content-block", out var contentBlockNode))
return null;
Location[] offices = [];
if (contentBlockNode.TryGetByTagNameAndClass("div", "office-item", out var officeItemNode)
&& officeItemNode.TryGetChildren(out var officeItems))
{
offices = ParseOffices(officeItems);
}
Rating[] ratings = [];
string? phone = null;
string? email = null;
string? website = null;
if (mainNode.TryGetByTagNameAndClass("div", "side-holder", out var sidebarNode)
&& sidebarNode.TryGetChildren(out var sidebarChildren))
{
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
}
return new SolicitorData
{
Name = builder.Name,
UrlPath = builder.Path,
ShortDescription = builder.ShortDescription,
Phone = phone,
Email = email,
Website = website,
Ratings = ratings,
Offices = offices,
Cities = builder.BaseLocations
};
}
private Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
=> officeNodes
.Select(ParseOffice)
.Where(x => x is not null)
.Cast<Location>()
.ToArray();
private Location? ParseOffice(IHtmlNode officeNode)
{
if (!officeNode.TryGetChildren(out var officeChildren)
|| !officeNode.TryGetByTagName("address", out var addressNode)
|| !addressNode.TryGetChildren(out var addressLines))
return null;
Location? location = null;
var address = "";
foreach (var line in addressLines)
{
if (line.TryGetText(out var lineText))
address += lineText;
else if (line.TryGetByTagName("br", out _, true))
address += "\n";
}
if (string.IsNullOrWhiteSpace(address))
address = null;
string? phone = null;
foreach (var child in officeChildren)
{
if (child.TryGetByTagName("a", out var anchor)
&& anchor.TryGetAttributeValue("href", out var telString)
&& telString.StartsWith("\"tel:")
&& telString.Length > 5)
{
phone = telString.Substring(5, telString.Length - 6);
break;
}
}
if (address is not null && phone is not null)
location = new Location(address, phone, []);
return location;
}
private void ParseSidebar(
IEnumerable<IHtmlNode> sidebarChildren,
out string? phone,
out string? email,
out string? website,
out Rating[] ratings)
{
List<Rating> ratingsList = [];
phone = null;
website = null;
email = null;
foreach (var node in sidebarChildren)
{
var isRevCount = node.TryGetByTagNameAndClass("div", "rev-count", out _, true);
var isLinksHolder = node.TryGetByTagNameAndClass("div", "links-holder", out _, true);
if ((!isRevCount && !isLinksHolder)
|| !node.TryGetChildren(out var children))
continue;
if (isLinksHolder)
{
if (node.TryGetByTagNameAndClass("a", "website", out var websiteNode)
&& websiteNode.TryGetAttributeValue("href", out website))
website = website.Trim('"');
if (node.TryGetByTagNameAndClass("a", "phone", out var phoneNode)
&& phoneNode.TryGetAttributeValue("href", out var phoneStr)
&& phoneStr.StartsWith("\"tel:")
&& phoneStr.Length > 5)
{
phone = phoneStr.Substring(5, phoneStr.Length - 6);
}
}
var childrenArray = children as IHtmlNode[] ?? children.ToArray();
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
{
if (child.TryGetAttributeValue("title", out var ratingString)
&& TryParseRatingString(ratingString, out var rating, out var maxRating)
&& child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode)
&& imgNode.TryGetAttributeValue("src", out var imgSrc)
&& TryParseRatingSrc(imgSrc, out var ratingProvider))
{
ratingsList.Add(new Rating(rating, maxRating, ratingProvider, imgSrc));
}
}
if (ratingsList.Count == 0)
{
foreach (var child in childrenArray.Where(x => !x.HasAttribute("class")))
{
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
if (grandChild.TryGetText(out var textContent)
&& TryParseOwnRatingString(textContent, out var rating))
ratingsList.Add(new Rating(rating, Consts.MaxRating, Consts.Name, Consts.IconPath));
}
}
}
ratings = ratingsList.ToArray();
}
bool TryParseRatingString(string ratingString, out decimal rating, out decimal maxRating)
{
rating = 0;
maxRating = 0;
var parts = ratingString.Trim('"').Split(" / ");
if (parts.Length != 2)
return false;
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
}
private bool TryParseRatingSrc(string imgSrc, out string provider)
{
provider = "";
imgSrc = imgSrc.Trim('"');
const string prefix = "/images/logo-";
if (!imgSrc.StartsWith(prefix))
return false;
var providerAndExtension = imgSrc[prefix.Length..];
provider = providerAndExtension.Split('.').First();
return true;
}
bool TryParseOwnRatingString(string ratingString, out decimal rating)
{
rating = 0;
const string prefix = "Average review score : ";
if (!ratingString.StartsWith(prefix))
return false;
var ratingPart = ratingString[prefix.Length..];
return decimal.TryParse(ratingPart, out rating);
}
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.Options;
using Quartz;
using Solicitors.Core.Data;
namespace Solicitors.CacheBuild;
internal class StaleDataRemover(
IOptions<ImportConfiguration> config,
ISolicitorRepository repository) : IJob
{
private readonly ImportConfiguration _config = config.Value;
public Task Execute(IJobExecutionContext context)
{
return repository.RemoveStaleEntriesAsync(
TimeSpan.FromMinutes(_config.StaleMinutes),
context.CancellationToken);
}
}
@@ -0,0 +1,19 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Solicitors.Core.Ordering;
namespace Solicitors.Core;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddCore(this IServiceCollection services)
{
return services
.AddScoped<ISolicitorService, SolicitorService>()
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
.AddScoped<IRatingsProviderService, RatingsProviderService>()
.AddScoped<ISolicitorComparerFactory, SolicitorComparerFactory>();
}
}
@@ -0,0 +1,6 @@
namespace Solicitors.Core.Data;
public interface IRatingsProviderRepository
{
IAsyncEnumerable<string> GetAllRatingsProvidersAsync();
}
@@ -0,0 +1,14 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Data;
public interface IReadOnlySolicitorRepository
{
IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync();
Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default);
}
public interface IReadOnlyCitiesRepository
{
IAsyncEnumerable<string> GetCitiesAsync();
}
@@ -0,0 +1,10 @@
using Solicitors.Core.Models.Imports;
namespace Solicitors.Core.Data;
public interface ISolicitorRepository : IReadOnlySolicitorRepository
{
Task EnsureCreatedAsync(CancellationToken cancellationToken);
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace Solicitors.Core;
public interface IRatingsProviderService
{
Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace Solicitors.Core;
public interface IReadOnlyCitiesService
{
Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,17 @@
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Models.View;
namespace Solicitors.Core;
public interface IReadOnlySolicitorService
{
Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
Pagination pagination,
string ratingsProvider,
IFilter<Solicitor>? filter = null,
IComparer<Solicitor>? ordering = null,
CancellationToken cancellationToken = default);
Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,3 @@
namespace Solicitors.Core;
public interface ISolicitorService : IReadOnlySolicitorService;
@@ -0,0 +1,21 @@
namespace Solicitors.Core.Misc;
public static class EnumerableExtensions
{
public static IEnumerable<T[]> Batch<T>(this IEnumerable<T> source, int batchSize)
{
var batch = new List<T>(batchSize);
foreach (var item in source)
{
batch.Add(item);
if (batch.Count == batch.Capacity)
{
yield return batch.ToArray();
batch.Clear();
}
}
if (batch.Count > 0)
yield return batch.ToArray();
}
}
@@ -0,0 +1,19 @@
namespace Solicitors.Core.Misc;
public interface IFilter<in T>
{
bool Filter(T item);
}
public class WrapperFilter<T>(
IFilter<T>? inner,
Func<T, bool> filter) : IFilter<T>
{
public bool Filter(T item)
{
if (inner is not null)
return inner.Filter(item) && filter(item);
return filter(item);
}
}
@@ -0,0 +1,7 @@
namespace Solicitors.Core.Misc;
public readonly struct Pagination(uint currentPage, uint pageSize)
{
public uint CurrentPage { get; } = currentPage;
public uint PageSize { get; } = pageSize;
}
@@ -0,0 +1,10 @@
namespace Solicitors.Core.Models;
public record City
{
public Guid CityId { get; set; }
public required string Name { get; set; }
public DateTime LastModified { get; set; }
public List<Solicitor> Solicitors { get; } = [];
}
@@ -0,0 +1,8 @@
namespace Solicitors.Core.Models.Imports;
public record Location(string Address, string Phone, Rating[] LocationRatings)
{
public string Address { get; } = Address;
public string Phone { get; } = Phone;
public Rating[] LocationRatings { get; } = LocationRatings;
}
@@ -0,0 +1,9 @@
namespace Solicitors.Core.Models.Imports;
public record Rating(decimal Value, decimal MaxValue, string RatingProvider, string RatingProviderImgSrc)
{
public decimal Value { get; } = Value;
public decimal MaxValue { get; } = MaxValue;
public string RatingProvider { get; } = RatingProvider;
public string RatingProviderImgSrc { get; } = RatingProviderImgSrc;
}
@@ -0,0 +1,16 @@
namespace Solicitors.Core.Models.Imports;
public class SolicitorData
{
public required string Name { get; init; }
public required string UrlPath { get; init; }
public string? ShortDescription { get; init; }
public string? Phone { get; init; }
public string? Email { get; init; }
public string? Website { get; init; }
public string[] Cities { get; init; } = [];
public Rating[] Ratings { get; init; } = [];
public Location[] Offices { get; init; } = [];
}
@@ -0,0 +1,19 @@
namespace Solicitors.Core.Models;
public record Solicitor
{
public Guid SolicitorId { get; set; }
public required string Name { get; set; }
public required string RelativeUrl { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? ShortDescription { get; set; }
public DateTime LastModified { get; set; }
public List<City> Cities { get; } = [];
public List<SolicitorRating> Ratings { get; } = [];
public List<SolicitorLocation> Locations { get; } = [];
}
@@ -0,0 +1,40 @@
using Solicitors.Core.Models.View;
namespace Solicitors.Core.Models;
public record SolicitorInfo
{
internal SolicitorInfo(Solicitor solicitor)
{
Id = solicitor.SolicitorId;
Name = solicitor.Name;
ShortDescription = solicitor.ShortDescription;
Phone = solicitor.Phone;
Email = solicitor.Email;
Website = solicitor.Website;
Cities = solicitor.Cities
.Select(city => city.Name)
.ToArray();
Ratings = solicitor.Ratings
.Select(rating => new RatingInfo(rating))
.ToArray();
Locations = solicitor.Locations
.Select(location => new LocationInfo(location))
.ToArray();
}
public string Name { get; set; }
public string? ShortDescription { get; set; }
public Guid Id { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string[] Cities { get; set; }
public RatingInfo[] Ratings { get; set; }
public LocationInfo[] Locations { get; set; }
}
@@ -0,0 +1,22 @@
namespace Solicitors.Core.Models;
public record SolicitorLocation
{
public Guid SolicitorLocationId { get; set; }
public required string Address { get; set; }
public required string Phone { get; set; }
public DateTime LastModified { get; set; }
public List<SolicitorLocationRating> LocationRatings { get; } = [];
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }
}
public interface IRating
{
decimal Value { get; }
decimal Maximum { get; }
string Provider { get; }
}
@@ -0,0 +1,14 @@
namespace Solicitors.Core.Models;
public record SolicitorLocationRating : IRating
{
public Guid SolicitorLocationRatingId { get; set; }
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public DateTime LastModified { get; set; }
public Guid SolicitorLocationId { get; set; }
public required SolicitorLocation SolicitorLocation { get; set; }
}
@@ -0,0 +1,14 @@
namespace Solicitors.Core.Models;
public record SolicitorRating : IRating
{
public Guid SolicitorRatingId { get; set; }
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public DateTime LastModified { get; set; }
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }
}
@@ -0,0 +1,18 @@
namespace Solicitors.Core.Models.View;
public record LocationInfo
{
public LocationInfo(SolicitorLocation location)
{
Address = location.Address;
Phone = location.Phone;
Ratings = location.LocationRatings
.Select(rating => new RatingInfo(rating))
.ToArray();
}
public string Address { get; }
public string Phone { get; }
public RatingInfo[] Ratings { get; }
}
@@ -0,0 +1,15 @@
namespace Solicitors.Core.Models.View;
public record RatingInfo
{
public RatingInfo(IRating rating)
{
Value = rating.Value;
Maximum = rating.Maximum;
Provider = rating.Provider;
}
public decimal Value { get; }
public decimal Maximum { get; }
public string Provider { get; }
}
@@ -0,0 +1,19 @@
namespace Solicitors.Core.Models.View;
public record SolicitorSummary
{
internal SolicitorSummary(Solicitor solicitor, string ratingsProvider)
{
Id = solicitor.SolicitorId;
Name = solicitor.Name;
ShortDescription = solicitor.ShortDescription;
var rating = solicitor.Ratings.FirstOrDefault(x => x.Provider == ratingsProvider);
if (rating is not null)
Rating = new RatingInfo(rating);
}
public Guid Id { get; }
public string Name { get; }
public string? ShortDescription { get; }
public RatingInfo? Rating { get; }
}
@@ -0,0 +1,23 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
{
public static IComparer<Solicitor> Instance { get; } = new DefaultSolicitorDataOrdering();
private DefaultSolicitorDataOrdering()
{
}
public int Compare(Solicitor? x, Solicitor? y)
{
if (x is null)
return y is null ? 0 : int.MaxValue;
else if (y is null)
return int.MinValue;
return Comparer<Guid>.Default.Compare(x.SolicitorId, y.SolicitorId);
}
}
@@ -0,0 +1,10 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
public interface ISolicitorComparerFactory
{
IComparer<Solicitor> GetComparer(
OrderingType? orderingType,
string ratingsProvider);
}
@@ -0,0 +1,26 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class NameComparer(bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
int compare;
if (ReferenceEquals(x, y))
compare = 0;
else if (y is null)
compare = 1;
else if (x is null)
compare = -1;
else
{
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
if (!ascending)
compare *= -1;
}
return compare;
}
}
@@ -0,0 +1,9 @@
namespace Solicitors.Core.Ordering;
public enum OrderingType
{
RatingDescending,
RatingAscending,
AlphabetAscending,
AlphabetDescending
}
@@ -0,0 +1,32 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
var xRating = x?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider);
var yRating = y?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider);
decimal compare;
if (xRating is null)
compare = yRating is null ? 0 : 1;
else if (yRating is null)
compare = -1;
else
{
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
if (!ascending)
compare *= -1;
}
if (compare >= 0)
return (int)Math.Ceiling(compare);
else
return (int)Math.Floor(compare);
}
}
@@ -0,0 +1,20 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class SolicitorComparerFactory : ISolicitorComparerFactory
{
public IComparer<Solicitor> GetComparer(
OrderingType? orderingType,
string ratingsProvider)
{
return orderingType switch
{
OrderingType.RatingAscending => new RatingComparer(ratingsProvider, true),
OrderingType.RatingDescending => new RatingComparer(ratingsProvider, false),
OrderingType.AlphabetAscending => new NameComparer(true),
OrderingType.AlphabetDescending => new NameComparer(false),
_ => DefaultSolicitorDataOrdering.Instance
};
}
}
@@ -0,0 +1,7 @@
namespace Solicitors.Core;
public record PaginationResponse<T>(int Total, T[] Data)
{
public T[] Data { get; set; } = Data;
public int Total { get; set; } = Total;
}
@@ -0,0 +1,11 @@
using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class RatingsProviderService(IRatingsProviderRepository repo) : IRatingsProviderService
{
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
{
return await repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
}
}
@@ -0,0 +1,12 @@
using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class ReadOnlyCitiesService(IReadOnlyCitiesRepository repo) : IReadOnlyCitiesService
{
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
{
var cities = repo.GetCitiesAsync();
return await cities.ToArrayAsync(cancellationToken);
}
}
@@ -0,0 +1,44 @@
using Solicitors.Core.Data;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Models.View;
using Solicitors.Core.Ordering;
namespace Solicitors.Core;
internal class SolicitorService(IReadOnlySolicitorRepository repo) : ISolicitorService
{
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
Pagination pagination,
string ratingsProvider,
IFilter<Solicitor>? filter = null,
IComparer<Solicitor>? ordering = null,
CancellationToken cancellationToken = default)
{
var allSolicitors = repo.GetAllSolicitorsAsync();
if (filter is not null)
allSolicitors = allSolicitors.Where(filter.Filter);
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
ordering ??= DefaultSolicitorDataOrdering.Instance;
var results = matchingSolicitors
.OrderBy(x => x, ordering)
.Skip((int)((pagination.CurrentPage - 1) * pagination.PageSize))
.Take((int)pagination.PageSize)
.Select(x => new SolicitorSummary(x, ratingsProvider))
.ToArray();
return new PaginationResponse<SolicitorSummary>(matchingSolicitors.Length, results);
}
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var data = await repo.GetSolicitorByIdAsync(id, cancellationToken);
if (data is null)
return null;
return new SolicitorInfo(data);
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Solicitors.Core.Tests" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Solicitors.Core.Data;
using Solicitors.Data.RepositorySetup;
using Solicitors.Data.RepositorySetup.InMemory;
using Solicitors.Data.RepositorySetup.Sqlite;
namespace Solicitors.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddData(this IServiceCollection services, IConfiguration configuration)
{
return services
.AddScoped<SolicitorsRepository>()
.AddScoped<ISolicitorRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddSingleton(GetSetupService(configuration));
}
private static IRepoSetupService GetSetupService(IConfiguration configuration)
{
var options = ReadConfigForOptions(configuration);
return options switch
{
InMemoryDbSetupOptions inMemOptions => new InMemorySetupService(inMemOptions),
SqliteDbSetupOptions sqliteOptions => new SqliteSetupService(sqliteOptions),
_ => throw new ArgumentException("Options type not supported", nameof(configuration))
};
}
private static IRepoSetupOptions? ReadConfigForOptions(IConfiguration configuration)
{
var dbConfig = configuration.GetSection("Database");
var dbType = dbConfig["Type"]?.ToLower();
return dbType switch
{
"inmemory" => dbConfig.Get<InMemoryDbSetupOptions>(),
"sqlite" => dbConfig.Get<SqliteDbSetupOptions>(),
_ => null
};
}
}
@@ -0,0 +1,3 @@
namespace Solicitors.Data.RepositorySetup;
public interface IRepoSetupOptions;
@@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup;
internal interface IRepoSetupService
{
void OnConfiguring(DbContextOptionsBuilder optionsBuilder);
}
@@ -0,0 +1,6 @@
namespace Solicitors.Data.RepositorySetup.InMemory;
public class InMemoryDbSetupOptions(string dbName) : IRepoSetupOptions
{
public string DbName { get; } = dbName;
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup.InMemory;
internal class InMemorySetupService(InMemoryDbSetupOptions config) : IRepoSetupService
{
public void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseInMemoryDatabase(config.DbName);
}
}
@@ -0,0 +1,6 @@
namespace Solicitors.Data.RepositorySetup.Sqlite;
public class SqliteDbSetupOptions(string dbPath) : IRepoSetupOptions
{
public string DbPath { get; } = dbPath;
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace Solicitors.Data.RepositorySetup.Sqlite;
internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupService
{
public void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite($"Data Source={config.DbPath}");
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>
@@ -0,0 +1,335 @@
using Microsoft.EntityFrameworkCore;
using Solicitors.Core.Data;
using Solicitors.Core.Models;
using Solicitors.Core.Models.Imports;
using Solicitors.Data.RepositorySetup;
namespace Solicitors.Data;
internal class SolicitorsRepository(IRepoSetupService setup)
: DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
{
public DbSet<Solicitor> Solicitors { get; set; }
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
public DbSet<SolicitorLocationRating> SolicitorLocationRatings { get; set; }
public DbSet<City> Cities { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> setup.OnConfiguring(optionsBuilder);
public Task EnsureCreatedAsync(CancellationToken cancellationToken)
=> Database.EnsureCreatedAsync(cancellationToken);
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
=> Solicitors
.Include(solicitor => solicitor.Cities)
.Include(solicitor => solicitor.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(solicitor => solicitor.Ratings)
.AsAsyncEnumerable();
public Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> Solicitors
.Include(solicitor => solicitor.Cities)
.Include(solicitor => solicitor.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(solicitor => solicitor.Ratings)
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
public IAsyncEnumerable<string> GetCitiesAsync()
{
return Cities
.Select(city => city.Name)
.AsAsyncEnumerable();
}
public IAsyncEnumerable<string> GetAllRatingsProvidersAsync()
{
return SolicitorRatings
.Select(rating => rating.Provider)
.Distinct()
.OrderBy(x => x == "Solicitors.com" ? 0 : 1)
.ThenBy(x => x)
.AsAsyncEnumerable();
}
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
{
var matchingSolicitor = await Solicitors
.Include(existing => existing.Cities)
.Include(existing => existing.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(existing => existing.Ratings)
.FirstOrDefaultAsync(
item => item.Name == solicitor.Name,
cancellationToken: cancellationToken);
if (matchingSolicitor is null)
await AddNewSolicitorAsync(solicitor, cancellationToken);
else
await UpdateExistingSolicitorAsync(solicitor, matchingSolicitor, cancellationToken);
await SaveChangesAsync(cancellationToken);
}
public Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken)
{
var staleTime = DateTime.UtcNow - staleAge;
Solicitors.RemoveRange(Solicitors.Where(x => x.LastModified < staleTime));
SolicitorLocations.RemoveRange(SolicitorLocations.Where(x => x.LastModified < staleTime));
SolicitorRatings.RemoveRange(SolicitorRatings.Where(x => x.LastModified < staleTime));
SolicitorLocationRatings.RemoveRange(SolicitorLocationRatings.Where(x => x.LastModified < staleTime));
Cities.RemoveRange(Cities.Where(x => x.LastModified < staleTime));
return SaveChangesAsync(cancellationToken);
}
private async Task AddNewSolicitorAsync(
SolicitorData solicitor,
CancellationToken cancellationToken)
{
var newSol = new Solicitor
{
Name = solicitor.Name,
RelativeUrl = solicitor.UrlPath
};
UpdateSolicitorRootData(newSol, solicitor);
Solicitors.Add(newSol);
var cityTasks = solicitor.Cities
.Distinct()
.Select(async city =>
{
var matchingCity = await GetCityByNameAsync(city, cancellationToken);
if (matchingCity is null)
AddNewCityToSolicitor(city, newSol);
else
UpdateExistingCity(matchingCity, newSol);
});
await Task.WhenAll(cityTasks);
foreach (var rating in solicitor.Ratings)
AddNewRatingToSolicitor(rating, newSol);
foreach (var location in solicitor.Offices)
AddNewLocationToSolicitor(location, newSol);
}
private void UpdateSolicitorRootData(Solicitor solicitor, SolicitorData data)
{
solicitor.Name = data.Name;
solicitor.RelativeUrl = data.UrlPath;
solicitor.Phone = data.Phone;
solicitor.Email = data.Email;
solicitor.Website = data.Website;
solicitor.ShortDescription = data.ShortDescription;
solicitor.LastModified = DateTime.UtcNow;
}
private void AddNewCityToSolicitor(string cityName, Solicitor solicitor)
{
var newCity = new City
{
Name = cityName
};
Cities.Add(newCity);
UpdateExistingCity(newCity, solicitor);
}
private void AddNewRatingToSolicitor(Rating rating, Solicitor solicitor)
{
var newRating = new SolicitorRating
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
Solicitor = solicitor,
LastModified = DateTime.UtcNow
};
SolicitorRatings.Add(newRating);
solicitor.Ratings.Add(newRating);
}
private void AddNewLocationToSolicitor(Location location, Solicitor solicitor)
{
var newLocation = new SolicitorLocation
{
Address = location.Address,
Phone = location.Phone,
Solicitor = solicitor,
LastModified = DateTime.UtcNow
};
SolicitorLocations.Add(newLocation);
solicitor.Locations.Add(newLocation);
foreach (var rating in location.LocationRatings)
AddNewRatingToLocation(rating, newLocation);
}
private void AddNewRatingToLocation(Rating rating, SolicitorLocation location)
{
var newLocRating = new SolicitorLocationRating
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
SolicitorLocation = location,
LastModified = DateTime.UtcNow
};
SolicitorLocationRatings.Add(newLocRating);
location.LocationRatings.Add(newLocRating);
}
private Task UpdateExistingSolicitorAsync(
SolicitorData solicitor,
Solicitor matchingSolicitor,
CancellationToken cancellationToken)
{
UpdateSolicitorRootData(matchingSolicitor, solicitor);
var citiesUpdate = UpdateExistingCitiesAsync(solicitor, matchingSolicitor, cancellationToken);
UpdateExistingRatings(solicitor, matchingSolicitor);
UpdateExistingLocations(solicitor, matchingSolicitor);
return citiesUpdate;
}
private async Task UpdateExistingCitiesAsync(
SolicitorData solicitor,
Solicitor matchingSolicitor,
CancellationToken cancellationToken)
{
var lostCities = matchingSolicitor.Cities
.Where(city => solicitor.Cities.All(cityName => cityName != city.Name))
.ToArray();
foreach (var city in lostCities)
{
matchingSolicitor.Cities.Remove(city);
city.Solicitors.Remove(matchingSolicitor);
}
foreach (var cityName in solicitor.Cities.Distinct())
{
var matchingCity = matchingSolicitor.Cities
.FirstOrDefault(city => cityName == city.Name);
matchingCity ??= await GetCityByNameAsync(cityName, cancellationToken);
if (matchingCity is null)
AddNewCityToSolicitor(cityName, matchingSolicitor);
else
UpdateExistingCity(matchingCity, matchingSolicitor);
}
}
private void UpdateExistingRatings(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
var lostRatings = matchingSolicitor.Ratings
.Where(solicitorRating => solicitor.Ratings.All(rating => rating.RatingProvider != solicitorRating.Provider))
.ToArray();
foreach (var rating in lostRatings)
{
matchingSolicitor.Ratings.Remove(rating);
SolicitorRatings.Remove(rating);
}
foreach (var rating in solicitor.Ratings)
{
var matchingRating = matchingSolicitor.Ratings
.FirstOrDefault(solicitorRating => rating.RatingProvider == solicitorRating.Provider);
if (matchingRating is null)
AddNewRatingToSolicitor(rating, matchingSolicitor);
else
UpdateExistingRating(rating, matchingRating);
}
}
private void UpdateExistingRating(Rating rating, SolicitorRating existingRating)
{
existingRating.Value = rating.Value;
existingRating.Maximum = rating.MaxValue;
existingRating.LastModified = DateTime.UtcNow;
}
private void UpdateExistingLocations(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
var lostLocations = matchingSolicitor.Locations
.Where(location => solicitor.Offices.All(office => office.Address != location.Address))
.ToArray();
foreach (var location in lostLocations)
{
matchingSolicitor.Locations.Remove(location);
SolicitorLocations.Remove(location);
}
foreach (var office in solicitor.Offices)
{
var location = matchingSolicitor.Locations
.FirstOrDefault(location => office.Address == location.Address);
if (location is null)
AddNewLocationToSolicitor(office, matchingSolicitor);
else
UpdateExistingLocation(office, location);
}
}
private void UpdateExistingLocation(Location location, SolicitorLocation existingLocation)
{
existingLocation.Phone = location.Phone;
existingLocation.LastModified = DateTime.UtcNow;
var lostRatings = existingLocation.LocationRatings
.Where(locationRating => location.LocationRatings.All(rating => locationRating.Provider != rating.RatingProvider))
.ToArray();
foreach (var rating in lostRatings)
{
existingLocation.LocationRatings.Remove(rating);
SolicitorLocationRatings.Remove(rating);
}
foreach (var rating in location.LocationRatings)
{
var existingRating = existingLocation.LocationRatings
.FirstOrDefault(locationRating => locationRating.Provider == rating.RatingProvider);
if (existingRating is null)
AddNewRatingToLocation(rating, existingLocation);
else
UpdateExistingLocationRating(rating, existingRating);
}
}
private void UpdateExistingLocationRating(Rating rating, SolicitorLocationRating existingRating)
{
existingRating.Value = rating.Value;
existingRating.Maximum = rating.MaxValue;
existingRating.LastModified = DateTime.UtcNow;
}
private void UpdateExistingCity(City city, Solicitor solicitor)
{
city.Solicitors.Add(solicitor);
solicitor.Cities.Add(city);
city.LastModified = DateTime.UtcNow;
}
private Task<City?> GetCityByNameAsync(string cityName, CancellationToken cancellationToken)
{
return Cities
.Include(city => city.Solicitors)
.FirstOrDefaultAsync(
city => city.Name == cityName,
cancellationToken: cancellationToken);
}
}
@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.HtmlParsing;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)
{
return services.AddSingleton<IHtmlParser, HtmlParser>();
}
}
@@ -0,0 +1,130 @@
namespace Solicitors.HtmlParsing;
using Models;
internal class HtmlParser : IHtmlParser
{
private readonly string[] _voidElements =
[
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr"
];
public IEnumerable<IHtmlNode> ParseHtml(string html)
{
// wanted to do this in one pass but was quite time-consuming
// so taking a little lesson from compiler development
// and lex the text into tokens before parsing :)
// deferred execution might help it still be "one pass"?
var stringEnumerator = html.GetEnumerator();
var tokens = LexHtml(stringEnumerator);
var tokenEnumerator = tokens.GetEnumerator();
return ParseHtml(tokenEnumerator);
}
private IEnumerable<string> LexHtml(CharEnumerator content)
{
var work = "";
while (content.MoveNext())
{
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
work += content.Current;
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
}
}
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
{
while (tokens.MoveNext())
{
var token = tokens.Current;
if (!token.StartsWith('<'))
{
yield return new StringNode(token);
continue;
}
var noBraces = token.Substring(1, token.Length - 2);
var split = noBraces.Split(' ');
IHtmlAttribute[] attributes = [];
if (split.Length > 1)
{
attributes = ParseAttributes(noBraces).ToArray();
}
if (IsVoidOrSelfClosingElement(token))
yield return new HtmlNode(split.First(), attributes);
else if (token.StartsWith("</"))
yield break;
else
{
var children = ParseHtml(tokens).ToArray();
yield return new ParentHtmlNode(split.First(), attributes, children);
}
}
}
private bool IsVoidOrSelfClosingElement(string token)
{
if (token.StartsWith("<!") || token.EndsWith("/>"))
return true;
var tagName = token.Split(' ').First()[1..];
if (tagName.EndsWith('>'))
tagName = tagName[..^1];
return _voidElements.Contains(tagName);
}
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
{
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
var attString = "";
var inQuote = false;
foreach (var c in attributesString)
{
if (!inQuote && c == ' ')
{
yield return ParseAttribute(attString);
attString = "";
}
else
{
attString += c;
if (c == '\"')
inQuote = !inQuote;
}
}
yield return ParseAttribute(attString);
}
private IHtmlAttribute ParseAttribute(string attString)
{
var split = attString.Split('=');
if (split.Length > 1)
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
else
return new HtmlAttribute(attString);
}
}
@@ -0,0 +1,8 @@
namespace Solicitors.HtmlParsing;
using Models;
public interface IHtmlParser
{
IEnumerable<IHtmlNode> ParseHtml(string html);
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlAttribute(string name) : IHtmlAttribute
{
public string Name { get; } = name;
}
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class HtmlNode(string name, IHtmlAttribute[] attributes)
: IHtmlNode
{
public virtual bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == name)
node = this;
return node is not null;
}
private string[] Classes
{
get
{
var classAttribute = attributes
.Where(a => a is HtmlValueAttribute)
.Select(a => (a as HtmlValueAttribute)!)
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
return classAttribute is not null
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
: [];
}
}
public virtual bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == name && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
{
text = null;
return false;
}
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName)
{
return attributes
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
}
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
var matchingAttribute = attributes.FirstOrDefault(
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
);
if (matchingAttribute is HtmlValueAttribute valueAttribute)
attributeValue = valueAttribute.Value;
return attributeValue is not null;
}
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
{
public string Value { get; } = value;
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal interface IHtmlAttribute
{
string Name { get; }
}
@@ -0,0 +1,64 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
public interface IHtmlNode
{
/// <summary>
/// Searches this node and its direct children for a matching tag name.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="node">If found, the node that matches the tag name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching class.
/// </summary>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the tag name and class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Checks this node (and *not* its children) to see if it is text content.
/// </summary>
/// <param name="text">If found, the text content.</param>
/// <returns>True if the node is a text node, false otherwise.</returns>
bool TryGetText([NotNullWhen(true)] out string? text);
/// <summary>
/// Checks this node to see if it has child elements.
/// </summary>
/// <param name="children">If found, the child elements.</param>
/// <returns>True if the node has child elements, false otherwise.</returns>
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
bool HasAttribute(string attributeName);
bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue);
}
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
: HtmlNode(tagName, attributes)
{
public override bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagName(tagName, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByClass(className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children1)
{
children1 = children;
return children.Length != 0;
}
}
@@ -0,0 +1,57 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class StringNode(string content)
: IHtmlNode
{
public bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetText([NotNullWhen(true)] out string? text)
{
text = content;
return !string.IsNullOrEmpty(text);
}
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName) => false;
public bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
return false;
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
using Moq;
using Solicitors.Core.Data;
using Solicitors.Core.Models;
namespace Solicitors.Core.Tests;
public class SolicitorServiceTests
{
private readonly Mock<IReadOnlySolicitorRepository> _mockRepo;
private readonly SolicitorService _service;
public SolicitorServiceTests()
{
_mockRepo = new Mock<IReadOnlySolicitorRepository>();
_service = new SolicitorService(_mockRepo.Object);
}
[Fact]
public async Task GetSolicitorInfoByIdAsync_Should_ReturnNull_When_NoMatchingSolicitor()
{
//arrange
var cts = new CancellationTokenSource();
var id = Guid.NewGuid();
_mockRepo
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
.ReturnsAsync(default(Solicitor?));
//act
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
//assert
Assert.Null(result);
_mockRepo.Verify(x => x.GetSolicitorByIdAsync(id, cts.Token), Times.Once);
}
[Fact]
public async Task GetSolicitorInfoByIdAsync_Should_ReturnMainSolicitorInfo_When_MatchingSolicitor()
{
//arrange
var cts = new CancellationTokenSource();
var id = Guid.NewGuid();
var expected = new Solicitor
{
SolicitorId = id,
Name = "Solicitor",
ShortDescription = "Short Desc",
Phone = "000 0000 0000",
Email = "example@example.com",
Website = "example.com",
RelativeUrl = "/-some-solicitor.html"
};
_mockRepo
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
.ReturnsAsync(expected);
//act
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
//assert
Assert.NotNull(result);
Assert.Equal(result.Id, expected.SolicitorId);
Assert.Equal(result.Name, expected.Name);
Assert.Equal(result.ShortDescription, expected.ShortDescription);
Assert.Equal(result.Phone, expected.Phone);
Assert.Equal(result.Email, expected.Email);
Assert.Equal(result.Website, expected.Website);
}
[Fact]
public async Task GetSolicitorInfoByIdAsync_Should_MapCities_When_CitiesArePresent()
{
//arrange
var cts = new CancellationTokenSource();
var id = Guid.NewGuid();
var expected = new Solicitor
{
Name = "Solicitor",
RelativeUrl = "/-some-solicitor.html"
};
expected.Cities.Add(new City { Name = "London" });
_mockRepo
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
.ReturnsAsync(expected);
// expected.Ratings.Add(new SolicitorRating { Provider = "Google", Value = 4, Maximum = 5, Solicitor = expected });
//act
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
//assert
Assert.NotNull(result);
Assert.Collection(result.Cities, city => Assert.Equal("London", city));
}
[Fact]
public async Task GetSolicitorInfoByIdAsync_Should_MapRatings_When_RatingsArePresent()
{
//arrange
var cts = new CancellationTokenSource();
var id = Guid.NewGuid();
var expected = new Solicitor
{
Name = "Solicitor",
RelativeUrl = "/-some-solicitor.html"
};
expected.Ratings.Add(new SolicitorRating { Provider = "Google", Value = 4, Maximum = 5, Solicitor = expected });
_mockRepo
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
.ReturnsAsync(expected);
//act
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
//assert
Assert.NotNull(result);
Assert.Collection(result.Ratings, rating =>
{
Assert.Equal("Google", rating.Provider);
Assert.Equal(4, rating.Value);
Assert.Equal(5, rating.Maximum);
});
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\libs\Solicitors.Core\Solicitors.Core.csproj" />
</ItemGroup>
</Project>