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:
2026-08-23 23:53:56 -04:00
committed by Aaron Po
parent e738fe484a
commit cd5fd2d998
41 changed files with 2966 additions and 2297 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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