mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Integrate NetTopologySuite and create CoordinateData mapping (#250)
This commit is contained in:
@@ -13,16 +13,16 @@ namespace API.Core;
|
||||
/// </summary>
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Maps <see cref="FluentValidation.ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||
/// </remarks>
|
||||
public void OnException(ExceptionContext context)
|
||||
/// <summary>
|
||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||
/// </remarks>
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
|
||||
@@ -160,10 +160,7 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
(string username, string password) = scenario.TryGetValue<(
|
||||
string username,
|
||||
string password
|
||||
)>(TestUserKey, out (string username, string password) user)
|
||||
(string username, string password) = scenario.TryGetValue(TestUserKey, out (string username, string password) user)
|
||||
? user
|
||||
: ("test.user", "password");
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.79" />
|
||||
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
||||
<PackageReference Include="NetTopologySuite.IO.SqlServerBytes" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
|
||||
<PackageReference
|
||||
|
||||
@@ -192,13 +192,16 @@ 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 = breweryRecord.Address.AddressLine1,
|
||||
PostalCode = breweryRecord.Address.PostalCode,
|
||||
Coordinates = new CoordinateData(
|
||||
breweryRecord.Address.Latitude,
|
||||
breweryRecord.Address.Longitude
|
||||
),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public sealed record CoordinateData(double Latitude, double Longitude);
|
||||
|
||||
|
||||
public sealed class BreweryPostLocation
|
||||
{
|
||||
// PK
|
||||
@@ -13,7 +16,7 @@ public sealed class BreweryPostLocation
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
public byte[]? Coordinates { get; set; }
|
||||
public CoordinateData? Coordinates { get; set; }
|
||||
|
||||
// Audit Fields
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
@@ -106,16 +106,16 @@ public class TokenService : ITokenService
|
||||
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||
/// </summary>
|
||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||
/// </exception>
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(
|
||||
/// <summary>
|
||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||
/// </summary>
|
||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||
/// </exception>
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(
|
||||
string token,
|
||||
string secret,
|
||||
string tokenType
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
@@ -10,7 +11,7 @@ public record CreateBreweryLocation(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
CoordinateData? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
@@ -14,7 +15,7 @@ public record UpdateBreweryLocation(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
CoordinateData? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>Location details of a brewery, as returned by the API.</summary>
|
||||
@@ -13,7 +15,7 @@ public record BreweryLocationDto(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
CoordinateData? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>A brewery post, as returned by the API.</summary>
|
||||
|
||||
@@ -17,9 +17,16 @@ namespace Features.Breweries.Repository;
|
||||
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
|
||||
/// </remarks>
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Database.Connection.DapperRepository(connectionFactory),
|
||||
: DapperRepository(connectionFactory),
|
||||
IBreweryRepository
|
||||
{
|
||||
private const int GeographySrid = 4326;
|
||||
|
||||
static BreweryRepository()
|
||||
{
|
||||
SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -226,7 +233,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
AddressLine1 = @AddressLine1,
|
||||
AddressLine2 = @AddressLine2,
|
||||
PostalCode = @PostalCode,
|
||||
Coordinates = @Coordinates
|
||||
Coordinates = CASE
|
||||
WHEN @Latitude IS NULL THEN NULL
|
||||
ELSE geography::Point(@Latitude, @Longitude, @Srid)
|
||||
END
|
||||
WHERE BreweryPostID = @BreweryPostId
|
||||
""",
|
||||
new
|
||||
@@ -236,7 +246,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
brewery.Location.AddressLine1,
|
||||
brewery.Location.AddressLine2,
|
||||
brewery.Location.PostalCode,
|
||||
brewery.Location.Coordinates,
|
||||
Latitude = brewery.Location.Coordinates?.Latitude,
|
||||
Longitude = brewery.Location.Coordinates?.Longitude,
|
||||
Srid = GeographySrid,
|
||||
},
|
||||
transaction
|
||||
)
|
||||
@@ -260,7 +272,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
@AddressLine1,
|
||||
@AddressLine2,
|
||||
@PostalCode,
|
||||
@Coordinates)
|
||||
CASE
|
||||
WHEN @Latitude IS NULL THEN NULL
|
||||
ELSE geography::Point(@Latitude, @Longitude, @Srid)
|
||||
END)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -270,7 +285,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
brewery.Location.AddressLine1,
|
||||
brewery.Location.AddressLine2,
|
||||
brewery.Location.PostalCode,
|
||||
brewery.Location.Coordinates,
|
||||
Latitude = brewery.Location.Coordinates?.Latitude,
|
||||
Longitude = brewery.Location.Coordinates?.Longitude,
|
||||
Srid = GeographySrid,
|
||||
},
|
||||
transaction
|
||||
)
|
||||
@@ -394,7 +411,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
@AddressLine1,
|
||||
@AddressLine2,
|
||||
@PostalCode,
|
||||
@Coordinates)
|
||||
CASE
|
||||
WHEN @Latitude IS NULL THEN NULL
|
||||
ELSE geography::Point(@Latitude, @Longitude, @Srid)
|
||||
END)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -404,7 +424,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
brewery.Location.AddressLine1,
|
||||
brewery.Location.AddressLine2,
|
||||
brewery.Location.PostalCode,
|
||||
brewery.Location.Coordinates,
|
||||
Latitude = brewery.Location.Coordinates?.Latitude,
|
||||
Longitude = brewery.Location.Coordinates?.Longitude,
|
||||
Srid = GeographySrid,
|
||||
},
|
||||
transaction
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using Domain.Entities;
|
||||
using NetTopologySuite.Geometries;
|
||||
using NetTopologySuite.IO;
|
||||
|
||||
namespace Features.Breweries.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper <see cref="SqlMapper.TypeHandler{T}" /> that deserializes a SQL Server <c>GEOGRAPHY</c>
|
||||
/// column (read as <c>varbinary(max)</c>, see <see cref="BreweryRepository" />) into a
|
||||
/// <see cref="CoordinateData" />.
|
||||
/// </summary>
|
||||
public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData>
|
||||
{
|
||||
private readonly SqlServerBytesReader _reader = new() { IsGeography = true };
|
||||
|
||||
public override CoordinateData Parse(object value)
|
||||
{
|
||||
var point = (Point)_reader.Read((byte[])value);
|
||||
return new CoordinateData(point.Y, point.X);
|
||||
}
|
||||
|
||||
public override void SetValue(IDbDataParameter parameter, CoordinateData? value) =>
|
||||
throw new NotSupportedException(
|
||||
$"{nameof(CoordinateData)} cannot be bound as a query parameter directly; "
|
||||
+ "pass Latitude/Longitude and build the GEOGRAPHY value in SQL text."
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace Features.Locations.Repository;
|
||||
/// Dapper-based implementation of <see cref="ILocationRepository" />.
|
||||
/// </summary>
|
||||
public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Database.Connection.DapperRepository(connectionFactory),
|
||||
: DapperRepository(connectionFactory),
|
||||
ILocationRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Features.UserManagement.Repository;
|
||||
/// Dapper-based implementation of <see cref="IUserAccountRepository" />.
|
||||
/// </summary>
|
||||
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Database.Connection.DapperRepository(connectionFactory),
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserAccountRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user