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