diff --git a/ess/src/API/EMBC.ESS.Host/appsettings.json b/ess/src/API/EMBC.ESS.Host/appsettings.json index 521f4ac70..daa2179a1 100644 --- a/ess/src/API/EMBC.ESS.Host/appsettings.json +++ b/ess/src/API/EMBC.ESS.Host/appsettings.json @@ -1,19 +1,23 @@ { - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft ": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.Hosting.Lifetime": "Information", - "System.Net.Http.HttpClient": "Warning", - "Microsoft.OData.Extensions.Client": "Warning", - "Grpc.Net.Client": "Warning" - } + "AllowedHosts": "*", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft ": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.OData.Extensions.Client": "Warning", + "Grpc.Net.Client": "Warning" + } + } + }, + "messaging": { + "mode": "both" + }, + "Spatial": { + "ArcGISUrl": "https://services1.arcgis.com/xeMpV7tU1t4KD3Ei/ArcGIS", + "GeocoderUrl": "https://geocoder.api.gov.bc.ca/" } - }, - "messaging": { - "mode": "both" - }, - "AllowedHosts": "*" } \ No newline at end of file diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/AddressLocator.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/AddressLocator.cs new file mode 100644 index 000000000..e08126592 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/AddressLocator.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using EMBC.ESS.Utilities.Spatial.ArcGISApi; +using EMBC.ESS.Utilities.Spatial.GeocoderApi; + +namespace EMBC.ESS.Utilities.Spatial +{ + internal class AddressLocator : IAddressLocator + { + private readonly IGeocoderAdapter geocoder; + private readonly IArcGISAdapter arcGisAdapter; + + public AddressLocator(IGeocoderAdapter geocoder, IArcGISAdapter arcGisAdapter) + { + this.geocoder = geocoder; + this.arcGisAdapter = arcGisAdapter; + } + + public async Task LocateAsync(Location location, CancellationToken ct) + { + if (location is null) + { + throw new ArgumentNullException(nameof(location)); + } + + var geocode = await geocoder.Resolve(location.FullAddress, ct); + if (geocode == null) return new AddressInformation(location, null, null); + var features = await arcGisAdapter.QueryService(new PointIntersectionQuery("TASK_OA_24/FeatureServer/0", geocode)); + + return new AddressInformation(location, geocode, features.FirstOrDefault()?.Attributes.Select(a => new LocationAttribute(a.Key, a.Value?.ToString()))); + } + } +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/ArcGISAdapter.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/ArcGISAdapter.cs new file mode 100644 index 000000000..a74867ea2 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/ArcGISAdapter.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Anywhere.ArcGIS; +using Anywhere.ArcGIS.Common; +using Anywhere.ArcGIS.Operation; + +namespace EMBC.ESS.Utilities.Spatial.ArcGISApi +{ + internal class ArcGISAdapter : IArcGISAdapter + { + private readonly PortalGateway portalGateway; + + public ArcGISAdapter(PortalGateway portalGateway) + { + this.portalGateway = portalGateway; + } + + public async Task> QueryService(PointIntersectionQuery query) + { + var arcGisQuery = new Query(query.ServiceName.AsEndpoint()) + { + Geometry = new Point + { + X = query.Point.Longitude, + Y = query.Point.Latitude, + SpatialReference = SpatialReference.WGS84 + }, + SpatialRelationship = SpatialRelationshipTypes.Intersects, + }; + + var result = await portalGateway.Query(arcGisQuery); + + return result.Features.Select(f => new GISFeature(f.Attributes.ToDictionary(a => a.Key, a => a.Value))).ToList(); + } + } +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/IArcGISAdapter.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/IArcGISAdapter.cs new file mode 100644 index 000000000..26ef296e1 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/ArcGISApi/IArcGISAdapter.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace EMBC.ESS.Utilities.Spatial.ArcGISApi +{ + internal interface IArcGISAdapter + { + Task> QueryService(PointIntersectionQuery query); + } + + internal record PointIntersectionQuery(string ServiceName, Geocode Point); + + internal record GISFeature(IEnumerable> Attributes); +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/Configuration.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/Configuration.cs new file mode 100644 index 000000000..74a21a4e3 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/Configuration.cs @@ -0,0 +1,36 @@ +using System; +using EMBC.ESS.Utilities.Spatial.ArcGISApi; +using EMBC.ESS.Utilities.Spatial.GeocoderApi; +using EMBC.Utilities.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Refit; + +namespace EMBC.ESS.Utilities.Spatial +{ + public class Configuration : IConfigureComponentServices + { + public void ConfigureServices(ConfigurationServices configurationServices) + { + var settings = configurationServices.Configuration.GetSection("Spatial").Get(); + if (settings == null || !settings.IsValid()) + { + configurationServices.Logger.Report(EMBC.Utilities.Telemetry.ReportType.Warning, "Spatial settings are incomplete, skipping configuration"); + return; + } + configurationServices.Services.AddRefitClient().ConfigureHttpClient(c => c.BaseAddress = settings.GeocoderUrl!); + configurationServices.Services.AddSingleton(new Anywhere.ArcGIS.PortalGateway(settings.ArcGISUrl!.ToString())); + configurationServices.Services.AddTransient(); + configurationServices.Services.AddTransient(); + configurationServices.Services.AddTransient(); + } + } + + public record SpatialSettings + { + public Uri? ArcGISUrl { get; set; } + public Uri? GeocoderUrl { get; set; } + + public bool IsValid() => ArcGISUrl != null && GeocoderUrl != null; + } +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/EMBC.ESS.Utilities.Spatial.csproj b/ess/src/API/EMBC.ESS.Utilities.Spatial/EMBC.ESS.Utilities.Spatial.csproj new file mode 100644 index 000000000..767ea6b8a --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/EMBC.ESS.Utilities.Spatial.csproj @@ -0,0 +1,28 @@ + + + + net6 + enable + true + true + Province of British Columbia + Quartech Systems Limited + Copyright 2022 Province of British Columbia + + https://github.com/bcgov/embc-ess-mod + GIT + Default + true + full + + + + + + + + + + + + diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/GeocoderAdapter.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/GeocoderAdapter.cs new file mode 100644 index 000000000..3bf5a4ae9 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/GeocoderAdapter.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace EMBC.ESS.Utilities.Spatial.GeocoderApi +{ + internal class GeocoderAdapter : IGeocoderAdapter + { + private readonly IGeocoderApi geocoderApi; + + public GeocoderAdapter(IGeocoderApi geocoderApi) + { + this.geocoderApi = geocoderApi; + } + + public async Task Resolve(string address, CancellationToken ct) + { + var response = await geocoderApi.GetAddress(new GetAddressRequest { addressString = address }); + var coordinates = response?.features?[0].geometry?.coordinates; + var score = response?.features?[0].properties?.score ?? 0; + if (coordinates == null || coordinates.Length != 2) return null; + return new Geocode(coordinates[1], coordinates[0], score); + } + } +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderAdapter.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderAdapter.cs new file mode 100644 index 000000000..f92734aa8 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderAdapter.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace EMBC.ESS.Utilities.Spatial.GeocoderApi +{ + internal interface IGeocoderAdapter + { + Task Resolve(string address, CancellationToken ct); + } +} \ No newline at end of file diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderApi.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderApi.cs new file mode 100644 index 000000000..901304926 --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/GeocoderApi/IGeocoderApi.cs @@ -0,0 +1,98 @@ +using System.Threading.Tasks; +using Refit; + +namespace EMBC.ESS.Utilities.Spatial.GeocoderApi +{ + internal interface IGeocoderApi + { + [Get("/addresses.json?")] + Task GetAddress([Query] GetAddressRequest request); + } + + internal record GetAddressRequest + { + public string? addressString { get; set; } + } + + internal class GetAddressResponse + { + public string? baseDataDate { get; set; } + public string? copyrightLicense { get; set; } + public string? copyrightNotice { get; set; } + public Crs? crs { get; set; } + public string? disclaimer { get; set; } + public string? echo { get; set; } + public float? executionTime { get; set; } + public Feature[]? features { get; set; } + public string? interpolation { get; set; } + public string? locationDescriptor { get; set; } + public int? maxResults { get; set; } + public int? minScore { get; set; } + public string? privacyStatement { get; set; } + public string? queryAddress { get; set; } + public string? searchTimestamp { get; set; } + public int? setBack { get; set; } + public string? type { get; set; } + public string? version { get; set; } + } + + internal record Crs + { + public Properties? properties { get; set; } + public string? type { get; set; } + } + + internal record Properties + { + public int code { get; set; } + } + + internal record Feature + { + public Geometry? geometry { get; set; } + public Properties2? properties { get; set; } + public string? type { get; set; } + } + + internal record Geometry + { + public double[]? coordinates { get; set; } + public Crs? crs { get; set; } + public string? type { get; set; } + } + + internal record Properties2 + { + public string? accessNotes { get; set; } + public double? blockID { get; set; } + public string? changeDate { get; set; } + public double? civicNumber { get; set; } + public string? civicNumberSuffix { get; set; } + public string? electoralArea { get; set; } + public object[]? faults { get; set; } + public string? fullAddress { get; set; } + public string? fullSiteDescriptor { get; set; } + public string? isOfficial { get; set; } + public string? isStreetDirectionPrefix { get; set; } + public string? isStreetTypePrefix { get; set; } + public string? localityName { get; set; } + public string? localityType { get; set; } + public string? locationDescriptor { get; set; } + public string? locationPositionalAccuracy { get; set; } + public string? matchPrecision { get; set; } + public int? precisionPoints { get; set; } + public string? provinceCode { get; set; } + public double? score { get; set; } + public string? siteID { get; set; } + public string? siteName { get; set; } + public string? siteRetireDate { get; set; } + public string? siteStatus { get; set; } + public string? streetDirection { get; set; } + public string? streetName { get; set; } + public string? streetQualifier { get; set; } + public string? streetType { get; set; } + public string? unitDesignator { get; set; } + public string? unitNumber { get; set; } + public string? unitNumberSuffix { get; set; } + } +} diff --git a/ess/src/API/EMBC.ESS.Utilities.Spatial/IAddressLocator.cs b/ess/src/API/EMBC.ESS.Utilities.Spatial/IAddressLocator.cs new file mode 100644 index 000000000..c4b8c685f --- /dev/null +++ b/ess/src/API/EMBC.ESS.Utilities.Spatial/IAddressLocator.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EMBC.ESS.Utilities.Spatial +{ + public interface IAddressLocator + { + Task LocateAsync(Location location, CancellationToken ct); + } + + public record Location(string FullAddress) + { + } + + public record AddressInformation(Location Location, Geocode? geocode, IEnumerable? Attributes); + public record Geocode(double Latitude, double Longitude, double score); + + public record LocationAttribute(string Name, string? Value); +} diff --git a/ess/src/API/EMBC.ESS.sln b/ess/src/API/EMBC.ESS.sln index 0c756d310..6d8d23f14 100644 --- a/ess/src/API/EMBC.ESS.sln +++ b/ess/src/API/EMBC.ESS.sln @@ -38,6 +38,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EMBC.ESS.Utilities.Spatial", "EMBC.ESS.Utilities.Spatial\EMBC.ESS.Utilities.Spatial.csproj", "{6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,6 +98,10 @@ Global {C770CDD8-C18C-48D3-A7E8-78797C5CBDEA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C770CDD8-C18C-48D3-A7E8-78797C5CBDEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C770CDD8-C18C-48D3-A7E8-78797C5CBDEA}.Release|Any CPU.Build.0 = Release|Any CPU + {6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -111,6 +117,7 @@ Global {827BC8E3-E64F-4E0D-A001-AB85DDF461F5} = {E5EDC676-4A91-48C0-BF38-D91F005ECD1A} {0528280C-DD38-445E-B7AC-9402F4CB00D3} = {E5EDC676-4A91-48C0-BF38-D91F005ECD1A} {C770CDD8-C18C-48D3-A7E8-78797C5CBDEA} = {E5EDC676-4A91-48C0-BF38-D91F005ECD1A} + {6324E308-ACE4-4AC3-B99D-C4A75BDF0DC6} = {E5EDC676-4A91-48C0-BF38-D91F005ECD1A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7030F633-EA46-4604-948A-65037D5A39B2} diff --git a/ess/src/API/EMBC.Tests.Integration.ESS/EMBC.Tests.Integration.ESS.csproj b/ess/src/API/EMBC.Tests.Integration.ESS/EMBC.Tests.Integration.ESS.csproj index 26d0bcbfe..b53067a49 100644 --- a/ess/src/API/EMBC.Tests.Integration.ESS/EMBC.Tests.Integration.ESS.csproj +++ b/ess/src/API/EMBC.Tests.Integration.ESS/EMBC.Tests.Integration.ESS.csproj @@ -30,5 +30,6 @@ + \ No newline at end of file diff --git a/ess/src/API/EMBC.Tests.Integration.ESS/SpatialTests.cs b/ess/src/API/EMBC.Tests.Integration.ESS/SpatialTests.cs new file mode 100644 index 000000000..10a38774f --- /dev/null +++ b/ess/src/API/EMBC.Tests.Integration.ESS/SpatialTests.cs @@ -0,0 +1,41 @@ +using System.Threading; +using EMBC.ESS.Utilities.Spatial; +using Microsoft.Extensions.DependencyInjection; + +namespace EMBC.Tests.Integration.ESS; + +public class SpatialTests : WebAppTestBase +{ + private readonly IAddressLocator addressLocator; + + public SpatialTests(ITestOutputHelper output, WebAppTestFixture fixture) : base(output, fixture) + { + addressLocator = Services.GetRequiredService(); + } + + [Theory] + [InlineData("1949 ROSEALEE LANE WEST KELOWNA BC", "1234", 100)] + [InlineData("1950 ROSEALEE LANE WEST KELOWNA BC", null, 100)] + [InlineData("2750 SMITH CREEK RD WEST KELOWNA BC", null, 100)] + [InlineData("2755 SMITH CREEK RD WEST KELOWNA BC", "5678", 100)] + [InlineData("3363 MCCONACHIE CRK RD FORT NELSON BC", "NBC", 100)] + [InlineData("101 MCCONACHIE CRK RD FORT NELSON BC", null, 100)] + [InlineData("1432 ROSE HILL PL WEST KELOWNA BC", "1111", 100)] + [InlineData("1423 ROSE HILL PL WEST KELOWNA BC", null, 100)] + public async Task CanResolveAddressToTask(string address, string? expectedTaskNumber, double score) + { + score.ShouldBeGreaterThan(60); + var info = (await addressLocator.LocateAsync(new Location(address), CancellationToken.None)).ShouldNotBeNull(); + var attributes = info.Attributes; + if (expectedTaskNumber == null) + { + attributes.ShouldBeNull(); + } + else + { + attributes.ShouldNotBeNull(); + attributes.ShouldContain(a => a.Name == "ESS_TASK_NUMBER" && a.Value == expectedTaskNumber); + //attributes.ShouldNotBeNull().ShouldContain(a => a.Name == "ESS_STATUS" && a.Value == "Active"); + } + } +} diff --git a/ess/src/API/EMBC.Tests.Unit.ESS/MappingTests.cs b/ess/src/API/EMBC.Tests.Unit.ESS/MappingTests.cs index 8c1567d83..a964e12fb 100644 --- a/ess/src/API/EMBC.Tests.Unit.ESS/MappingTests.cs +++ b/ess/src/API/EMBC.Tests.Unit.ESS/MappingTests.cs @@ -6,7 +6,6 @@ namespace EMBC.Tests.Unit.ESS public class MappingTests { private readonly MapperConfiguration mapperConfig; - private IMapper mapper => mapperConfig.CreateMapper(); public MappingTests() {