mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add reverse geocoding for brewery street addresses (#245)
* Add geocoding to seed application, remove postal codes from generator Update comments
This commit is contained in:
@@ -8,20 +8,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2"/>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11"/>
|
||||
<PackageReference Include="Spectre.Console" Version="0.57.2"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj"/>
|
||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj"/>
|
||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\open-ai-test2.sqlite" />
|
||||
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-24T01-52-09.841693Z.sqlite"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -12,6 +12,5 @@ public class ConnectionStrings
|
||||
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
|
||||
SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||
|
||||
public static string SqliteConnectionString => "Data Source=SeedData/open-ai-test2.sqlite";
|
||||
// "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||
public static string SqliteConnectionString => "Data Source=SeedData/biergarten_seed_2026-08-24T01-52-09.841693Z.sqlite";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Database.Seed.Geocoding;
|
||||
|
||||
public sealed record ReverseGeocodeResult(string AddressLine1, string PostalCode);
|
||||
|
||||
internal sealed record NominatimAddress
|
||||
{
|
||||
[JsonPropertyName("house_number")]
|
||||
public string? HouseNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("road")]
|
||||
public string? Road { get; init; }
|
||||
|
||||
[JsonPropertyName("postcode")]
|
||||
public string? Postcode { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record NominatimReverseGeocodeResponse
|
||||
{
|
||||
[JsonPropertyName("address")]
|
||||
public NominatimAddress? Address { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse geocodes coordinates into street addresses via the public Nominatim API
|
||||
/// (https://nominatim.openstreetmap.org), following its usage policy of a single
|
||||
/// identifying User-Agent and at most one request per second
|
||||
/// (https://operations.osmfoundation.org/policies/nominatim/).
|
||||
/// </summary>
|
||||
public sealed class NominatimReverseGeocoder : IDisposable
|
||||
{
|
||||
private const string ReverseEndpoint = "https://nominatim.openstreetmap.org/reverse";
|
||||
private static readonly TimeSpan MinRequestInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _rateLimitLock = new(1, 1);
|
||||
private DateTime _lastRequestUtc = DateTime.MinValue;
|
||||
|
||||
public NominatimReverseGeocoder(string userAgent)
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse geocodes a coordinate pair, returning <c>null</c> if Nominatim has no address
|
||||
/// for the point or the request fails so callers can fall back to a placeholder address.
|
||||
/// </summary>
|
||||
public async Task<ReverseGeocodeResult?> ReverseGeocodeAsync(
|
||||
double longitude,
|
||||
double latitude,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await WaitForRateLimitAsync(cancellationToken);
|
||||
|
||||
string url =
|
||||
$"{ReverseEndpoint}?format=jsonv2"
|
||||
+ $"&lat={latitude.ToString(CultureInfo.InvariantCulture)}"
|
||||
+ $"&lon={longitude.ToString(CultureInfo.InvariantCulture)}"
|
||||
+ "&zoom=18&addressdetails=1";
|
||||
|
||||
try
|
||||
{
|
||||
NominatimReverseGeocodeResponse? response =
|
||||
await _httpClient.GetFromJsonAsync<NominatimReverseGeocodeResponse>(
|
||||
url,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response?.Address is null || response.Error is not null)
|
||||
return null;
|
||||
|
||||
string addressLine1 = string.Join(
|
||||
' ',
|
||||
new[] { response.Address.HouseNumber, response.Address.Road }.Where(
|
||||
part => !string.IsNullOrWhiteSpace(part)
|
||||
)
|
||||
);
|
||||
|
||||
return string.IsNullOrWhiteSpace(addressLine1)
|
||||
? null
|
||||
: new ReverseGeocodeResult(addressLine1, response.Address.Postcode ?? string.Empty);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// HttpClient's own timeout elapsed here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForRateLimitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _rateLimitLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
TimeSpan elapsed = DateTime.UtcNow - _lastRequestUtc;
|
||||
if (elapsed < MinRequestInterval)
|
||||
await Task.Delay(MinRequestInterval - elapsed, cancellationToken);
|
||||
|
||||
_lastRequestUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rateLimitLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_rateLimitLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ public sealed record BreweryAddress
|
||||
{
|
||||
public required City City { get; init; }
|
||||
|
||||
public required string PostalCode { get; init; }
|
||||
|
||||
public required double Longitude { get; init; }
|
||||
|
||||
public required double Latitude { get; init; }
|
||||
|
||||
@@ -4,5 +4,7 @@ public sealed record UserAddress
|
||||
{
|
||||
public required City City { get; init; }
|
||||
|
||||
public required string PostalCode { get; init; }
|
||||
public required double Longitude { get; init; }
|
||||
|
||||
public required double Latitude { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Database.Seed.DatabaseHelpers;
|
||||
using Database.Seed.Geocoding;
|
||||
using Database.Seed.PipelineData;
|
||||
using Database.Seed.Sqlite;
|
||||
using Domain.Entities;
|
||||
@@ -16,6 +17,9 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
|
||||
const string NominatimUserAgent =
|
||||
"TheBiergartenApp-Seeder/1.0 (+https://github.com/aaronpo97/the-biergarten-app)";
|
||||
|
||||
return await RunAsync();
|
||||
|
||||
static async Task<int> RunAsync()
|
||||
@@ -100,23 +104,34 @@ static async Task<int> RunAsync()
|
||||
}
|
||||
);
|
||||
|
||||
await AnsiConsole
|
||||
.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("green"))
|
||||
.StartAsync(
|
||||
"Loading brewery data into target database...",
|
||||
async ctx =>
|
||||
{
|
||||
await LoadBreweriesIntoDatabaseAsync(
|
||||
breweryRepository,
|
||||
locationRepository,
|
||||
seedData.Breweries,
|
||||
postedByIds
|
||||
);
|
||||
ctx.Status("Brewery data loaded into target database.");
|
||||
}
|
||||
);
|
||||
List<string> geocodingWarnings = [];
|
||||
|
||||
using (NominatimReverseGeocoder geocoder = new(NominatimUserAgent))
|
||||
{
|
||||
await AnsiConsole
|
||||
.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("green"))
|
||||
.StartAsync(
|
||||
"Loading brewery data into target database...",
|
||||
async ctx =>
|
||||
{
|
||||
await LoadBreweriesIntoDatabaseAsync(
|
||||
breweryRepository,
|
||||
locationRepository,
|
||||
geocoder,
|
||||
seedData.Breweries,
|
||||
postedByIds,
|
||||
geocodingWarnings,
|
||||
ctx
|
||||
);
|
||||
ctx.Status("Brewery data loaded into target database.");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
foreach (string warning in geocodingWarnings)
|
||||
AnsiConsole.MarkupLine($"[yellow]![/] {warning.EscapeMarkup()}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -165,20 +180,18 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
return userAccountIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists each <paramref name="breweries" /> record, resolving (and creating, if needed) its
|
||||
/// Country/StateProvince/City chain via <paramref name="locationRepository" />. The pipeline
|
||||
/// data has no street-level address or a specific user tied to each brewery, so a placeholder
|
||||
/// address is used and <c>PostedById</c> is assigned round-robin from <paramref name="posterUserIds" />
|
||||
/// to keep seeding deterministic across runs.
|
||||
/// </summary>
|
||||
static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
IBreweryRepository breweryRepository,
|
||||
ILocationRepository locationRepository,
|
||||
NominatimReverseGeocoder geocoder,
|
||||
IReadOnlyList<BreweryRecord> breweries,
|
||||
IReadOnlyList<Guid> posterUserIds
|
||||
IReadOnlyList<Guid> posterUserIds,
|
||||
List<string> geocodingWarnings,
|
||||
StatusContext ctx
|
||||
)
|
||||
{
|
||||
const string placeholderAddressLine1 = "Address unavailable";
|
||||
|
||||
if (posterUserIds.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot load breweries without any registered users.");
|
||||
@@ -187,6 +200,10 @@ static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
{
|
||||
BreweryRecord breweryRecord = breweries[i];
|
||||
|
||||
ctx.Status(
|
||||
$"Geocoding and loading brewery {i + 1}/{breweries.Count} into target database..."
|
||||
);
|
||||
|
||||
Guid cityId = await locationRepository.GetOrCreateCityIdAsync(
|
||||
new CityLocation(
|
||||
breweryRecord.Address.City.CityName,
|
||||
@@ -197,6 +214,18 @@ static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
)
|
||||
);
|
||||
|
||||
ReverseGeocodeResult? geocoded = await geocoder.ReverseGeocodeAsync(
|
||||
breweryRecord.Address.Longitude,
|
||||
breweryRecord.Address.Latitude
|
||||
);
|
||||
|
||||
if (geocoded is null)
|
||||
geocodingWarnings.Add(
|
||||
$"Could not reverse geocode '{breweryRecord.Brewery.NameEn}' "
|
||||
+ $"({breweryRecord.Address.Latitude}, {breweryRecord.Address.Longitude}); "
|
||||
+ "using a placeholder address."
|
||||
);
|
||||
|
||||
await breweryRepository.CreateAsync(
|
||||
new BreweryPost
|
||||
{
|
||||
@@ -204,12 +233,13 @@ static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
BreweryName = breweryRecord.Brewery.NameEn,
|
||||
Description = breweryRecord.Brewery.DescriptionEn,
|
||||
PostedById = posterUserIds[i % posterUserIds.Count],
|
||||
// TODO: Populate Coordinates from breweryRecord.Address.Latitude/Longitude once it goes through NetTopologySuite (NTS).
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = cityId,
|
||||
AddressLine1 = "Address unavailable",
|
||||
PostalCode = breweryRecord.Address.PostalCode,
|
||||
AddressLine1 = geocoded?.AddressLine1 ?? placeholderAddressLine1,
|
||||
PostalCode = geocoded?.PostalCode ?? string.Empty,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -224,7 +254,8 @@ static Table BuildBreweryTable(IReadOnlyList<BreweryRecord> breweries)
|
||||
.AddColumn("City")
|
||||
.AddColumn("State/Province")
|
||||
.AddColumn("Country")
|
||||
.AddColumn("Postal Code");
|
||||
.AddColumn("Longitude")
|
||||
.AddColumn("Latitude");
|
||||
|
||||
foreach (BreweryRecord brewery in breweries)
|
||||
{
|
||||
@@ -234,7 +265,8 @@ static Table BuildBreweryTable(IReadOnlyList<BreweryRecord> breweries)
|
||||
brewery.Address.City.CityName,
|
||||
brewery.Address.City.StateProvince,
|
||||
brewery.Address.City.Country,
|
||||
brewery.Address.PostalCode
|
||||
brewery.Address.Longitude.ToString("F6"),
|
||||
brewery.Address.Latitude.ToString("F6")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -54,7 +54,7 @@ public sealed class PipelineSeedDataReader
|
||||
{
|
||||
const string sql = """
|
||||
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
|
||||
ba.city_id, ba.postal_code, ba.longitude, ba.latitude
|
||||
ba.city_id, ba.longitude, ba.latitude
|
||||
FROM breweries b
|
||||
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
||||
""";
|
||||
@@ -67,9 +67,8 @@ public sealed class PipelineSeedDataReader
|
||||
const int nameLocalIndex = 2;
|
||||
const int descriptionLocalIndex = 3;
|
||||
const int cityIdIndex = 4;
|
||||
const int postalCodeIndex = 5;
|
||||
const int longitudeIndex = 6;
|
||||
const int latitudeIndex = 7;
|
||||
const int longitudeIndex = 5;
|
||||
const int latitudeIndex = 6;
|
||||
|
||||
List<BreweryRecord> records = [];
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
@@ -86,7 +85,6 @@ public sealed class PipelineSeedDataReader
|
||||
Address = new BreweryAddress
|
||||
{
|
||||
City = cities[reader.GetInt64(cityIdIndex)],
|
||||
PostalCode = reader.GetString(postalCodeIndex),
|
||||
Longitude = reader.GetDouble(longitudeIndex),
|
||||
Latitude = reader.GetDouble(latitudeIndex),
|
||||
},
|
||||
@@ -105,7 +103,7 @@ public sealed class PipelineSeedDataReader
|
||||
const string sql = """
|
||||
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
|
||||
u.activity_weight, u.email, u.date_of_birth,
|
||||
ua.city_id, ua.postal_code
|
||||
ua.city_id, ua.longitude, ua.latitude
|
||||
FROM users u
|
||||
JOIN user_addresses ua ON ua.user_id = u.id;
|
||||
""";
|
||||
@@ -122,7 +120,8 @@ public sealed class PipelineSeedDataReader
|
||||
const int emailIndex = 6;
|
||||
const int dateOfBirthIndex = 7;
|
||||
const int cityIdIndex = 8;
|
||||
const int postalCodeIndex = 9;
|
||||
const int longitudeIndex = 9;
|
||||
const int latitudeIndex = 10;
|
||||
|
||||
List<UserRecord> records = [];
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
@@ -143,7 +142,8 @@ public sealed class PipelineSeedDataReader
|
||||
Address = new UserAddress
|
||||
{
|
||||
City = cities[reader.GetInt64(cityIdIndex)],
|
||||
PostalCode = reader.GetString(postalCodeIndex),
|
||||
Longitude = reader.GetDouble(longitudeIndex),
|
||||
Latitude = reader.GetDouble(latitudeIndex),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -283,7 +283,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -418,11 +418,28 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
|
||||
/// raises (for example when the provider has already completed the transaction after a
|
||||
/// connection failure) so the exception that triggered the rollback is what propagates.
|
||||
/// </summary>
|
||||
private static async Task RollbackQuietlyAsync(DbTransaction transaction)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore: the original exception (rethrown by the caller) is what matters here.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
||||
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
|
||||
|
||||
Reference in New Issue
Block a user