From e447e1f75f39d3365604faf29d82d3e4d53c86fe Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 16 Jun 2026 19:57:28 -0400 Subject: [PATCH 1/3] feat(trimble): add Trimble Maps geocoding provider with forward and reverse geocoding support --- Geo.NET.sln | 14 + .../Abstractions/ITrimbleGeocoding.cs | 38 +++ .../ServiceCollectionExtensions.cs | 44 +++ .../Extensions/LoggerExtensions.cs | 62 ++++ src/Geo.Trimble/Geo.Trimble.csproj | 45 +++ .../Models/Enums/CountryAbbrevType.cs | 28 ++ src/Geo.Trimble/Models/Enums/Region.cs | 37 ++ .../Models/Parameters/Coordinate.cs | 31 ++ .../Models/Parameters/GeocodingParameters.cs | 83 +++++ .../Parameters/ReverseGeocodingParameters.cs | 95 ++++++ .../Models/Responses/GeocodeAddress.cs | 57 ++++ .../Models/Responses/GeocodeCoords.cs | 27 ++ .../Models/Responses/GeocodeError.cs | 39 +++ .../Models/Responses/GeocodeResponse.cs | 88 +++++ .../Models/Responses/SpeedLimitInfo.cs | 45 +++ src/Geo.Trimble/Properties/AssemblyInfo.cs | 10 + src/Geo.Trimble/README.md | 39 +++ .../Services/TrimbleGeocoding.Designer.cs | 99 ++++++ .../Resources/Services/TrimbleGeocoding.resx | 132 ++++++++ src/Geo.Trimble/Services/TrimbleGeocoding.cs | 292 ++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 88 +++++ .../Geo.Trimble.Tests.csproj | 15 + .../Services/TrimbleGeocodingShould.cs | 315 ++++++++++++++++++ .../TestData/CultureTestData.cs | 40 +++ 24 files changed, 1763 insertions(+) create mode 100644 src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs create mode 100644 src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Geo.Trimble/Extensions/LoggerExtensions.cs create mode 100644 src/Geo.Trimble/Geo.Trimble.csproj create mode 100644 src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs create mode 100644 src/Geo.Trimble/Models/Enums/Region.cs create mode 100644 src/Geo.Trimble/Models/Parameters/Coordinate.cs create mode 100644 src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs create mode 100644 src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeAddress.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeCoords.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeError.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeResponse.cs create mode 100644 src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs create mode 100644 src/Geo.Trimble/Properties/AssemblyInfo.cs create mode 100644 src/Geo.Trimble/README.md create mode 100644 src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs create mode 100644 src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx create mode 100644 src/Geo.Trimble/Services/TrimbleGeocoding.cs create mode 100644 test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs create mode 100644 test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj create mode 100644 test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs create mode 100644 test/Geo.Trimble.Tests/TestData/CultureTestData.cs diff --git a/Geo.NET.sln b/Geo.NET.sln index be941ab..0e2f0cb 100644 --- a/Geo.NET.sln +++ b/Geo.NET.sln @@ -53,6 +53,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Positionstack", "src\Ge EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Positionstack.Tests", "test\Geo.Positionstack.Tests\Geo.Positionstack.Tests.csproj", "{E09BD60D-6E8A-4210-9274-695A2DFFE976}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Trimble", "src\Geo.Trimble\Geo.Trimble.csproj", "{7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Trimble.Tests", "test\Geo.Trimble.Tests\Geo.Trimble.Tests.csproj", "{B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -131,6 +135,14 @@ Global {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Debug|Any CPU.Build.0 = Debug|Any CPU {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Release|Any CPU.ActiveCfg = Release|Any CPU {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Release|Any CPU.Build.0 = Release|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Release|Any CPU.Build.0 = Release|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -154,6 +166,8 @@ Global {1A320DE3-B14B-46EE-A0E6-C6783E585F73} = {67253D97-9FC9-4749-80DC-A5D84339DC05} {03C7B4AE-7905-4292-8133-3F4AA6EDCA3A} = {8F3BA9BC-542C-450C-96C9-F0D72FECC930} {E09BD60D-6E8A-4210-9274-695A2DFFE976} = {67253D97-9FC9-4749-80DC-A5D84339DC05} + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90} = {8F3BA9BC-542C-450C-96C9-F0D72FECC930} + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60} = {67253D97-9FC9-4749-80DC-A5D84339DC05} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C4B688C4-40EC-4577-9EB2-4CF2412DA0B1} diff --git a/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs b/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs new file mode 100644 index 0000000..d8ae4c3 --- /dev/null +++ b/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble +{ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Models.Responses; + + /// + /// An interface for calling the Trimble Maps geocoding methods. + /// + public interface ITrimbleGeocoding + { + /// + /// Calls the Trimble Maps geocoding API and returns the results. + /// + /// A with the parameters of the request. + /// A used to cancel the request. + /// A list of with the response from Trimble Maps. + /// Thrown for multiple different reasons. Check the inner exception for more information. + Task> GeocodingAsync(GeocodingParameters parameters, CancellationToken cancellationToken = default); + + /// + /// Calls the Trimble Maps reverse geocoding API and returns the results. + /// + /// A with the parameters of the request. + /// A used to cancel the request. + /// A with the response from Trimble Maps. + /// Thrown for multiple different reasons. Check the inner exception for more information. + Task ReverseGeocodingAsync(ReverseGeocodingParameters parameters, CancellationToken cancellationToken = default); + } +} diff --git a/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs b/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9697025 --- /dev/null +++ b/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Extensions.DependencyInjection +{ + using System; + using Geo.Trimble; + using Geo.Trimble.Services; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + + /// + /// Extension methods for the class. + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds the Trimble Maps geocoding services to the service collection. + /// + /// Adds the services: + /// + /// of + /// + /// + /// + /// + /// An to add the Trimble Maps services to. + /// An to configure the Trimble Maps geocoding. + /// Thrown if is null. + public static KeyBuilder AddTrimbleGeocoding(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddKeyOptions(); + + return new KeyBuilder(services.AddHttpClient()); + } + } +} diff --git a/src/Geo.Trimble/Extensions/LoggerExtensions.cs b/src/Geo.Trimble/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..6451c23 --- /dev/null +++ b/src/Geo.Trimble/Extensions/LoggerExtensions.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble +{ + using System; + using Geo.Trimble.Services; + using Microsoft.Extensions.Logging; + + /// + /// Extension methods for the class. + /// + internal static class LoggerExtensions + { + private static readonly Action _error = LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {ErrorMessage}"); + + private static readonly Action _warning = LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {WarningMessage}"); + + private static readonly Action _debug = LoggerMessage.Define( + LogLevel.Debug, + new EventId(3, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {DebugMessage}"); + + /// + /// "TrimbleGeocoding: {ErrorMessage}". + /// + /// An used to log the error message. + /// The error message to log. + public static void TrimbleError(this ILogger logger, string errorMessage) + { + _error(logger, errorMessage, null); + } + + /// + /// "TrimbleGeocoding: {WarningMessage}". + /// + /// An used to log the warning message. + /// The warning message to log. + public static void TrimbleWarning(this ILogger logger, string warningMessage) + { + _warning(logger, warningMessage, null); + } + + /// + /// "TrimbleGeocoding: {DebugMessage}". + /// + /// An used to log the debug message. + /// The debug message to log. + public static void TrimbleDebug(this ILogger logger, string debugMessage) + { + _debug(logger, debugMessage, null); + } + } +} diff --git a/src/Geo.Trimble/Geo.Trimble.csproj b/src/Geo.Trimble/Geo.Trimble.csproj new file mode 100644 index 0000000..d547a9a --- /dev/null +++ b/src/Geo.Trimble/Geo.Trimble.csproj @@ -0,0 +1,45 @@ + + + + netstandard2.0;net6.0;net8.0;net10.0 + Justin Canton + Geo.NET + Geo.NET Trimble + geocoding geo.net Trimble + A lightweight method for communicating with the Trimble Maps geocoding APIs. This includes models and interfaces for calling Trimble Maps. + MIT + https://github.com/JustinCanton/Geo.NET + true + README.md + + + + + + + + + + + True + True + TrimbleGeocoding.resx + + + + + + ResXFileCodeGenerator + TrimbleGeocoding.Designer.cs + + + + + + True + \ + Always + + + + diff --git a/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs b/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs new file mode 100644 index 0000000..76dd3dd --- /dev/null +++ b/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Enums +{ + /// + /// The format used for country abbreviations in Trimble Maps API responses. + /// + public enum CountryAbbrevType + { + /// FIPS country code. + FIPS, + + /// ISO 3166-1 alpha-2 (two-letter) country code. + ISO2, + + /// ISO 3166-1 alpha-3 (three-letter) country code. + ISO3, + + /// GENC two-letter country code. + GENC2, + + /// GENC three-letter country code. + GENC3, + } +} diff --git a/src/Geo.Trimble/Models/Enums/Region.cs b/src/Geo.Trimble/Models/Enums/Region.cs new file mode 100644 index 0000000..d3e49c7 --- /dev/null +++ b/src/Geo.Trimble/Models/Enums/Region.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Enums +{ + /// + /// The geographic region used to scope Trimble Maps API requests. + /// + public enum Region + { + /// North America (default). + NorthAmerica = 0, + + /// Africa. + Africa = 1, + + /// Asia. + Asia = 2, + + /// Europe. + Europe = 3, + + /// Oceania. + Oceania = 4, + + /// South America. + SouthAmerica = 5, + + /// Middle East. + MiddleEast = 6, + + /// Global. + Global = 7, + } +} diff --git a/src/Geo.Trimble/Models/Parameters/Coordinate.cs b/src/Geo.Trimble/Models/Parameters/Coordinate.cs new file mode 100644 index 0000000..07e4455 --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/Coordinate.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Globalization; + + /// + /// The coordinates (longitude, latitude) used for Trimble Maps reverse geocoding. + /// + public class Coordinate + { + /// + /// Gets or sets the latitude of the location. For example: "40.7128". + /// + public double Latitude { get; set; } + + /// + /// Gets or sets the longitude of the location. For example: "-74.0060". + /// + public double Longitude { get; set; } + + /// + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, "{0},{1}", Longitude, Latitude); + } + } +} diff --git a/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs b/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs new file mode 100644 index 0000000..3fccf68 --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs @@ -0,0 +1,83 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Collections.Generic; + using Geo.Trimble.Models.Enums; + + /// + /// The parameters possible to use during a Trimble Maps geocoding request. + /// + public class GeocodingParameters : IKeyParameters, IAdditionalParameters + { + /// + /// Gets or sets the street address to geocode. + /// Optional. + /// + public string Street { get; set; } + + /// + /// Gets or sets the city to geocode. + /// Optional. + /// + public string City { get; set; } + + /// + /// Gets or sets the state or province to geocode. + /// Optional. + /// + public string State { get; set; } + + /// + /// Gets or sets the postal/ZIP code to geocode. + /// Optional. + /// + public string Zip { get; set; } + + /// + /// Gets or sets the county to geocode. + /// Optional. + /// + public string County { get; set; } + + /// + /// Gets or sets the country to geocode. + /// Optional. + /// + public string Country { get; set; } + + /// + /// Gets or sets the geographic region. + /// Optional. + /// Default: . + /// + public Region Region { get; set; } = Region.NorthAmerica; + + /// + /// Gets or sets a specific data version to query. + /// Optional. + /// + public string Dataset { get; set; } + + /// + /// Gets or sets the maximum number of results to return. + /// Optional. + /// + public int? MaxResults { get; set; } + + /// + /// Gets or sets a value indicating whether results should be limited to named roads only. + /// Optional. + /// + public bool MatchNamedRoadsOnly { get; set; } + + /// + public string Key { get; set; } + + /// + public IDictionary AdditionalParameters { get; } = new Dictionary(); + } +} diff --git a/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs b/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs new file mode 100644 index 0000000..400da9e --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Collections.Generic; + using Geo.Trimble.Models.Enums; + + /// + /// The parameters possible to use during a Trimble Maps reverse geocoding request. + /// + public class ReverseGeocodingParameters : IKeyParameters, IAdditionalParameters + { + /// + /// Gets or sets the coordinates to reverse geocode. + /// Required. + /// + public Coordinate Coordinate { get; set; } + + /// + /// Gets or sets the geographic region. + /// Optional. + /// Default: . + /// + public Region Region { get; set; } = Region.NorthAmerica; + + /// + /// Gets or sets a specific data version to query. + /// Optional. + /// + public string Dataset { get; set; } + + /// + /// Gets or sets the preferred response language. + /// Optional. + /// + public string Lang { get; set; } + + /// + /// Gets or sets a value indicating whether results should be limited to named roads only. + /// Optional. + /// + public bool MatchNamedRoadsOnly { get; set; } + + /// + /// Gets or sets the maximum distance in miles to search for the nearest road. + /// Optional. + /// + public double? MaxCleanupMiles { get; set; } + + /// + /// Gets or sets a value indicating whether to include posted speed limit data in the response. + /// Optional. + /// + public bool IncludePostedSpeedLimit { get; set; } + + /// + /// Gets or sets the vehicle type classification. + /// Optional. + /// + public string VehicleType { get; set; } + + /// + /// Gets or sets the direction of travel in degrees. + /// Optional. + /// + public double? Heading { get; set; } + + /// + /// Gets or sets a value indicating whether to include road link identifiers in the response. + /// Optional. + /// + public bool IncludeLinkInfo { get; set; } + + /// + /// Gets or sets the format to use for country abbreviations in the response. + /// Optional. + /// + public CountryAbbrevType? CountryAbbrevType { get; set; } + + /// + /// Gets or sets a value indicating whether to include Trimble place database information. + /// Optional. + /// + public bool IncludeTrimblePlaceIds { get; set; } + + /// + public string Key { get; set; } + + /// + public IDictionary AdditionalParameters { get; } = new Dictionary(); + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs b/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs new file mode 100644 index 0000000..c4bf5ef --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// The address portion of a Trimble Maps geocoding response. + /// + public class GeocodeAddress + { + /// + /// Gets or sets the street address. + /// + [JsonPropertyName("StreetAddress")] + public string StreetAddress { get; set; } + + /// + /// Gets or sets the city. + /// + [JsonPropertyName("City")] + public string City { get; set; } + + /// + /// Gets or sets the state or province. + /// + [JsonPropertyName("State")] + public string State { get; set; } + + /// + /// Gets or sets the postal/ZIP code. + /// + [JsonPropertyName("Zip")] + public string Zip { get; set; } + + /// + /// Gets or sets the county. + /// + [JsonPropertyName("County")] + public string County { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("Country")] + public string Country { get; set; } + + /// + /// Gets or sets the abbreviated country code. + /// + [JsonPropertyName("CountryAbbreviation")] + public string CountryAbbreviation { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs b/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs new file mode 100644 index 0000000..65c2515 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// The coordinate portion of a Trimble Maps geocoding response. + /// + public class GeocodeCoords + { + /// + /// Gets or sets the latitude as a string. + /// + [JsonPropertyName("Lat")] + public string Lat { get; set; } + + /// + /// Gets or sets the longitude as a string. + /// + [JsonPropertyName("Lon")] + public string Lon { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeError.cs b/src/Geo.Trimble/Models/Responses/GeocodeError.cs new file mode 100644 index 0000000..9877312 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeError.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// An error or warning returned in a Trimble Maps geocoding response. + /// + public class GeocodeError + { + /// + /// Gets or sets the error type (Warning or Exception). + /// + [JsonPropertyName("Type")] + public string Type { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("Code")] + public string Code { get; set; } + + /// + /// Gets or sets the legacy numeric error code. + /// + [JsonPropertyName("LegacyErrorCode")] + public int LegacyErrorCode { get; set; } + + /// + /// Gets or sets a human-readable description of the error. + /// + [JsonPropertyName("Description")] + public string Description { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs new file mode 100644 index 0000000..a0695ef --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Collections.Generic; + using System.Text.Json.Serialization; + + /// + /// A geocoding result returned by the Trimble Maps API. + /// + public class GeocodeResponse + { + /// + /// Gets or sets the matched address. + /// + [JsonPropertyName("Address")] + public GeocodeAddress Address { get; set; } + + /// + /// Gets or sets the coordinates of the matched location. + /// + [JsonPropertyName("Coords")] + public GeocodeCoords Coords { get; set; } + + /// + /// Gets or sets the formatted label for the location. + /// + [JsonPropertyName("Label")] + public string Label { get; set; } + + /// + /// Gets or sets the place name, which may include a Trimble place ID. + /// + [JsonPropertyName("PlaceName")] + public string PlaceName { get; set; } + + /// + /// Gets or sets the numeric geographic region code. + /// + [JsonPropertyName("Region")] + public int Region { get; set; } + + /// + /// Gets or sets the time zone name. + /// + [JsonPropertyName("TimeZone")] + public string TimeZone { get; set; } + + /// + /// Gets or sets the UTC offset string (e.g. "GMT-5:00"). + /// + [JsonPropertyName("TimeZoneOffset")] + public string TimeZoneOffset { get; set; } + + /// + /// Gets or sets a value indicating whether daylight saving time is in effect. + /// + [JsonPropertyName("isDST")] + public bool IsDst { get; set; } + + /// + /// Gets or sets the confidence level of the match (Exact, Good, Uncertain, or Failed). + /// + [JsonPropertyName("ConfidenceLevel")] + public string ConfidenceLevel { get; set; } + + /// + /// Gets or sets the distance in miles from the input coordinates to the nearest road. + /// + [JsonPropertyName("DistanceFromRoad")] + public double DistanceFromRoad { get; set; } + + /// + /// Gets or sets the speed limit information for the nearest road. + /// + [JsonPropertyName("SpeedLimitInfo")] + public SpeedLimitInfo SpeedLimitInfo { get; set; } + + /// + /// Gets the errors or warnings associated with this result. + /// + [JsonPropertyName("Errors")] + public IList Errors { get; } = new List(); + } +} diff --git a/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs new file mode 100644 index 0000000..691ce62 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// Speed limit information returned in a Trimble Maps reverse geocoding response. + /// + public class SpeedLimitInfo + { + /// + /// Gets or sets the speed limit value. + /// + [JsonPropertyName("Speed")] + public int Speed { get; set; } + + /// + /// Gets or sets the speed limit type classification. + /// + [JsonPropertyName("SpeedType")] + public int SpeedType { get; set; } + + /// + /// Gets or sets the road link identifiers. + /// + [JsonPropertyName("LinkIds")] + public long LinkIds { get; set; } + + /// + /// Gets or sets the road class designation. + /// + [JsonPropertyName("RoadClass")] + public string RoadClass { get; set; } + + /// + /// Gets or sets the measurement units (KPH or MPH). + /// + [JsonPropertyName("Units")] + public string Units { get; set; } + } +} diff --git a/src/Geo.Trimble/Properties/AssemblyInfo.cs b/src/Geo.Trimble/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b97f9f9 --- /dev/null +++ b/src/Geo.Trimble/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Localization; + +[assembly: InternalsVisibleTo("Geo.Trimble.Tests")] +[assembly: ResourceLocation("Resources")] diff --git a/src/Geo.Trimble/README.md b/src/Geo.Trimble/README.md new file mode 100644 index 0000000..6b253f4 --- /dev/null +++ b/src/Geo.Trimble/README.md @@ -0,0 +1,39 @@ +# Trimble Maps Geocoding + +This allows the simple calling of Trimble Maps geocoding APIs. The supported Trimble Maps geocoding endpoints are: +- [Geocoding](https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations) +- [Reverse Geocoding](https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations/reverse) + +## Configuration + +In the startup `ConfigureServices` method, add the configuration for the Trimble Maps service: +``` +using Geo.Extensions.DependencyInjection; +. +. +. +public void ConfigureServices(IServiceCollection services) +{ + . + . + . + var builder = services.AddTrimbleGeocoding(); + builder.AddKey(your_Trimble_Maps_api_key_here); + builder.HttpClientBuilder.ConfigureHttpClient(configure_client); + . + . + . +} +``` + +## Sample Usage + +By calling `AddTrimbleGeocoding`, the `ITrimbleGeocoding` interface has been added to the IOC container. Just request it as a DI item: +``` +public MyService(ITrimbleGeocoding trimbleGeocoding) +{ + ... +} +``` + +Now simply call the geocoding methods in the interface. diff --git a/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs new file mode 100644 index 0000000..532ee4f --- /dev/null +++ b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Geo.Trimble.Resources.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class TrimbleGeocoding { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal TrimbleGeocoding() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Geo.Trimble.Resources.Services.TrimbleGeocoding", typeof(TrimbleGeocoding).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Failed to create the Trimble Maps uri.. + /// + internal static string Failed_To_Create_Uri { + get { + return ResourceManager.GetString("Failed To Create Uri", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At least one address field must be provided.. + /// + internal static string Invalid_Address { + get { + return ResourceManager.GetString("Invalid Address", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The coordinate cannot be null or invalid.. + /// + internal static string Invalid_Coordinate { + get { + return ResourceManager.GetString("Invalid Coordinate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Trimble Maps parameters are null.. + /// + internal static string Null_Parameters { + get { + return ResourceManager.GetString("Null Parameters", resourceCulture); + } + } + } +} diff --git a/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx new file mode 100644 index 0000000..ff1cd57 --- /dev/null +++ b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Failed to create the Trimble Maps uri. + + + At least one address field must be provided. + + + The coordinate cannot be null or invalid. + + + The Trimble Maps parameters are null. + + diff --git a/src/Geo.Trimble/Services/TrimbleGeocoding.cs b/src/Geo.Trimble/Services/TrimbleGeocoding.cs new file mode 100644 index 0000000..4b726a0 --- /dev/null +++ b/src/Geo.Trimble/Services/TrimbleGeocoding.cs @@ -0,0 +1,292 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Services +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Geo.Core; + using Geo.Core.Extensions; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Enums; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Models.Responses; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Options; + + /// + /// A service to call the Trimble Maps geocoding API. + /// + public class TrimbleGeocoding : GeoClient, ITrimbleGeocoding + { + private const string GeocodeUri = "https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations"; + private const string ReverseGeocodeUri = "https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations/reverse"; + + private readonly IOptions> _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// A used for placing calls to the Trimble Maps Geocoding API. + /// An of containing Trimble Maps key information. + /// An used to create a logger used for logging information. + public TrimbleGeocoding( + HttpClient client, + IOptions> options, + ILoggerFactory loggerFactory = null) + : base(client, loggerFactory) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + /// + protected override string ApiName => "Trimble"; + + /// + public async Task> GeocodingAsync( + GeocodingParameters parameters, + CancellationToken cancellationToken = default) + { + var uri = ValidateAndBuildUri(parameters, BuildGeocodingRequest); + + return await GetAsync>(uri, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ReverseGeocodingAsync( + ReverseGeocodingParameters parameters, + CancellationToken cancellationToken = default) + { + var uri = ValidateAndBuildUri(parameters, BuildReverseGeocodingRequest); + + return await GetAsync(uri, cancellationToken).ConfigureAwait(false); + } + + /// + /// Validates the uri and builds it based on the parameter type. + /// + /// The type of the parameters. + /// The parameters to validate and create a uri from. + /// The method to use to create the uri. + /// A with the uri crafted from the parameters. + internal Uri ValidateAndBuildUri(TParameters parameters, Func uriBuilderFunction) + where TParameters : class + { + if (parameters is null) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Null_Parameters); + throw new GeoNETException(Resources.Services.TrimbleGeocoding.Null_Parameters, new ArgumentNullException(nameof(parameters))); + } + + try + { + return uriBuilderFunction(parameters); + } + catch (ArgumentException ex) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Failed_To_Create_Uri); + throw new GeoNETException(Resources.Services.TrimbleGeocoding.Failed_To_Create_Uri, ex); + } + } + + /// + /// Builds the geocoding uri based on the passed parameters. + /// + /// A with the geocoding parameters to build the uri with. + /// A with the completed Trimble Maps geocoding uri. + /// Thrown when no address fields are provided. + internal Uri BuildGeocodingRequest(GeocodingParameters parameters) + { + var uriBuilder = new UriBuilder(GeocodeUri); + var query = QueryString.Empty; + + if (string.IsNullOrWhiteSpace(parameters.Street) && + string.IsNullOrWhiteSpace(parameters.City) && + string.IsNullOrWhiteSpace(parameters.State) && + string.IsNullOrWhiteSpace(parameters.Zip) && + string.IsNullOrWhiteSpace(parameters.County) && + string.IsNullOrWhiteSpace(parameters.Country)) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Invalid_Address); + throw new ArgumentException(Resources.Services.TrimbleGeocoding.Invalid_Address, nameof(parameters)); + } + + AddAddressParameters(parameters, ref query); + AddTrimbleKey(parameters, ref query); + query = query.AddAdditionalParameters(parameters); + + uriBuilder.AddQuery(query); + + return uriBuilder.Uri; + } + + /// + /// Builds the reverse geocoding uri based on the passed parameters. + /// + /// A with the reverse geocoding parameters to build the uri with. + /// A with the completed Trimble Maps reverse geocoding uri. + /// Thrown when the parameter is null. + internal Uri BuildReverseGeocodingRequest(ReverseGeocodingParameters parameters) + { + var uriBuilder = new UriBuilder(ReverseGeocodeUri); + var query = QueryString.Empty; + + if (parameters.Coordinate is null) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Invalid_Coordinate); + throw new ArgumentException(Resources.Services.TrimbleGeocoding.Invalid_Coordinate, nameof(parameters.Coordinate)); + } + + query = query.Add("Coords", parameters.Coordinate.ToString()); + + AddReverseParameters(parameters, ref query); + AddTrimbleKey(parameters, ref query); + query = query.AddAdditionalParameters(parameters); + + uriBuilder.AddQuery(query); + + return uriBuilder.Uri; + } + + /// + /// Adds the structured address fields to the query string. + /// + /// The to read address fields from. + /// A with the query parameters. + internal void AddAddressParameters(GeocodingParameters parameters, ref QueryString query) + { + if (!string.IsNullOrWhiteSpace(parameters.Street)) + { + query = query.Add("Street", parameters.Street); + } + + if (!string.IsNullOrWhiteSpace(parameters.City)) + { + query = query.Add("City", parameters.City); + } + + if (!string.IsNullOrWhiteSpace(parameters.State)) + { + query = query.Add("State", parameters.State); + } + + if (!string.IsNullOrWhiteSpace(parameters.Zip)) + { + query = query.Add("Zip", parameters.Zip); + } + + if (!string.IsNullOrWhiteSpace(parameters.County)) + { + query = query.Add("County", parameters.County); + } + + if (!string.IsNullOrWhiteSpace(parameters.Country)) + { + query = query.Add("Country", parameters.Country); + } + + query = query.Add("Region", ((int)parameters.Region).ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(parameters.Dataset)) + { + query = query.Add("Dataset", parameters.Dataset); + } + + if (parameters.MaxResults.HasValue && parameters.MaxResults.Value > 0) + { + query = query.Add("MaxResults", parameters.MaxResults.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.MatchNamedRoadsOnly) + { + query = query.Add("MatchNamedRoadsOnly", "true"); + } + } + + /// + /// Adds the optional reverse geocoding parameters to the query string. + /// + /// The to read optional fields from. + /// A with the query parameters. + internal void AddReverseParameters(ReverseGeocodingParameters parameters, ref QueryString query) + { + query = query.Add("Region", ((int)parameters.Region).ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(parameters.Dataset)) + { + query = query.Add("Dataset", parameters.Dataset); + } + + if (!string.IsNullOrWhiteSpace(parameters.Lang)) + { + query = query.Add("lang", parameters.Lang); + } + + if (parameters.MatchNamedRoadsOnly) + { + query = query.Add("matchNamedRoadsOnly", "true"); + } + + if (parameters.MaxCleanupMiles.HasValue) + { + query = query.Add("maxCleanupMiles", parameters.MaxCleanupMiles.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.IncludePostedSpeedLimit) + { + query = query.Add("includePostedSpeedLimit", "true"); + } + + if (!string.IsNullOrWhiteSpace(parameters.VehicleType)) + { + query = query.Add("vehicleType", parameters.VehicleType); + } + + if (parameters.Heading.HasValue) + { + query = query.Add("heading", parameters.Heading.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.IncludeLinkInfo) + { + query = query.Add("includeLinkInfo", "true"); + } + + if (parameters.CountryAbbrevType.HasValue) + { + query = query.Add("countryAbbrevType", parameters.CountryAbbrevType.Value.ToString()); + } + + if (parameters.IncludeTrimblePlaceIds) + { + query = query.Add("includeTrimblePlaceIds", "true"); + } + } + + /// + /// Adds the Trimble Maps API key to the request as the authToken query parameter. + /// + /// An to conditionally get the key from. + /// A with the query parameters. + internal void AddTrimbleKey(IKeyParameters keyParameter, ref QueryString query) + { + var key = _options.Value.Key; + + if (!string.IsNullOrWhiteSpace(keyParameter.Key)) + { + key = keyParameter.Key; + } + + query = query.Add("authToken", key); + } + } +} diff --git a/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..8937849 --- /dev/null +++ b/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests.DependencyInjection +{ + using System; + using System.Net.Http; + using FluentAssertions; + using Geo.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using Xunit; + + /// + /// Unit tests for the class. + /// + public class ServiceCollectionExtensionsTests + { + [Fact] + public void AddTrimbleGeocoding_WithValidCall_ConfiguresAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var builder = services.AddTrimbleGeocoding(); + builder.AddKey("abc"); + + // Assert + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>>(); + options.Should().NotBeNull(); + options.Value.Key.Should().Be("abc"); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddTrimbleGeocoding_WithNullOptions_ConfiguresAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddTrimbleGeocoding(); + + // Assert + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>>(); + options.Should().NotBeNull(); + options.Value.Key.Should().Be(string.Empty); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddTrimbleGeocoding_WithClientConfiguration_ConfiguresHttpClientAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var builder = services.AddTrimbleGeocoding(); + builder.AddKey("abc"); + builder.HttpClientBuilder.ConfigureHttpClient(httpClient => httpClient.Timeout = TimeSpan.FromSeconds(5)); + + // Assert + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService().CreateClient("ITrimbleGeocoding"); + client.Timeout.Should().Be(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AddTrimbleGeocoding_WithNullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null; + + // Act + Action act = () => services.AddTrimbleGeocoding(); + + // Assert + act.Should().Throw(); + } + } +} diff --git a/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj b/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj new file mode 100644 index 0000000..8359b2d --- /dev/null +++ b/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj @@ -0,0 +1,15 @@ + + + + net48;netcoreapp3.1;net6.0;net8.0;net10.0 + + false + + + + + + + + + diff --git a/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs b/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs new file mode 100644 index 0000000..e2099d5 --- /dev/null +++ b/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs @@ -0,0 +1,315 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests.Services +{ + using System; + using System.Globalization; + using System.Net.Http; + using System.Threading; + using System.Web; + using FluentAssertions; + using Geo.Core; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Enums; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Services; + using Microsoft.Extensions.Options; + using Moq; + using Xunit; + + /// + /// Unit tests for the class. + /// + public class TrimbleGeocodingShould : IDisposable + { + private readonly HttpClient _httpClient; + private readonly Mock>> _options = new Mock>>(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public TrimbleGeocodingShould() + { + _options + .Setup(x => x.Value) + .Returns(new KeyOptions() + { + Key = "abc123", + }); + + _httpClient = new HttpClient(new Mock().Object); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [Fact] + public void AddTrimbleKey_WithOptions_SuccessfullyAddsKey() + { + var sut = BuildService(); + + var query = QueryString.Empty; + + sut.AddTrimbleKey(new GeocodingParameters(), ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters.Count.Should().Be(1); + queryParameters["authToken"].Should().Be("abc123"); + } + + [Fact] + public void AddTrimbleKey_WithParameterOverride_SuccessfullyAddsKey() + { + var sut = BuildService(); + + var query = QueryString.Empty; + + sut.AddTrimbleKey(new GeocodingParameters() { Key = "override123" }, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters.Count.Should().Be(1); + queryParameters["authToken"].Should().Be("override123"); + } + + [Fact] + public void AddAddressParameters_WithAllFields_AddsAllParameters() + { + var sut = BuildService(); + + var query = QueryString.Empty; + var parameters = new GeocodingParameters() + { + Street = "100 Main St", + City = "Springfield", + State = "IL", + Zip = "62701", + County = "Sangamon", + Country = "US", + Region = Region.NorthAmerica, + Dataset = "Current", + MaxResults = 5, + MatchNamedRoadsOnly = true, + }; + + sut.AddAddressParameters(parameters, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters["Street"].Should().Be("100 Main St"); + queryParameters["City"].Should().Be("Springfield"); + queryParameters["State"].Should().Be("IL"); + queryParameters["Zip"].Should().Be("62701"); + queryParameters["County"].Should().Be("Sangamon"); + queryParameters["Country"].Should().Be("US"); + queryParameters["Region"].Should().Be("0"); + queryParameters["Dataset"].Should().Be("Current"); + queryParameters["MaxResults"].Should().Be("5"); + queryParameters["MatchNamedRoadsOnly"].Should().Be("true"); + } + + [Fact] + public void AddAddressParameters_WithMinimalFields_OnlyAddsPresentFields() + { + var sut = BuildService(); + + var query = QueryString.Empty; + var parameters = new GeocodingParameters() + { + City = "Chicago", + State = "IL", + }; + + sut.AddAddressParameters(parameters, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters["City"].Should().Be("Chicago"); + queryParameters["State"].Should().Be("IL"); + queryParameters["Street"].Should().BeNull(); + queryParameters["Zip"].Should().BeNull(); + } + + [Theory] + [ClassData(typeof(CultureTestData))] + public void BuildGeocodingRequest_WithValidParameters_SuccessfullyBuildsUrl(CultureInfo culture) + { + // Arrange + var oldCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = culture; + + var sut = BuildService(); + + var parameters = new GeocodingParameters() + { + Street = "100 Main St", + City = "Springfield", + State = "IL", + Key = "abc123", + }; + + // Act + var uri = sut.BuildGeocodingRequest(parameters); + + // Assert + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("Street=100 Main St"); + query.Should().Contain("City=Springfield"); + query.Should().Contain("State=IL"); + query.Should().Contain("authToken=abc123"); + + Thread.CurrentThread.CurrentCulture = oldCulture; + } + + [Fact] + public void BuildGeocodingRequest_WithNoAddressFields_ThrowsArgumentException() + { + var sut = BuildService(); + + Action act = () => sut.BuildGeocodingRequest(new GeocodingParameters()); + + act.Should() + .Throw(); + } + + [Fact] + public void BuildGeocodingRequest_WithAdditionalParameters_AddsThemToQueryString() + { + var sut = BuildService(); + + var parameters = new GeocodingParameters() + { + City = "Chicago", + }; + + parameters.AdditionalParameters.Add("customKey1", "customValue1"); + parameters.AdditionalParameters.Add("customKey2", "customValue2"); + + var uri = sut.BuildGeocodingRequest(parameters); + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("customKey1=customValue1"); + query.Should().Contain("customKey2=customValue2"); + } + + [Theory] + [ClassData(typeof(CultureTestData))] + public void BuildReverseGeocodingRequest_WithValidParameters_SuccessfullyBuildsUrl(CultureInfo culture) + { + // Arrange + var oldCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = culture; + + var sut = BuildService(); + + var parameters = new ReverseGeocodingParameters() + { + Coordinate = new Coordinate() + { + Latitude = 40.7128, + Longitude = -74.0060, + }, + Key = "abc123", + }; + + // Act + var uri = sut.BuildReverseGeocodingRequest(parameters); + + // Assert + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("Coords=-74.006,40.7128"); + query.Should().Contain("authToken=abc123"); + + Thread.CurrentThread.CurrentCulture = oldCulture; + } + + [Fact] + public void BuildReverseGeocodingRequest_WithNullCoordinate_ThrowsArgumentException() + { + var sut = BuildService(); + + Action act = () => sut.BuildReverseGeocodingRequest(new ReverseGeocodingParameters()); + + act.Should() + .Throw() +#if NETCOREAPP3_1_OR_GREATER + .WithMessage("*(Parameter 'Coordinate')"); +#else + .WithMessage("*Parameter name: Coordinate"); +#endif + } + + [Fact] + public void BuildReverseGeocodingRequest_WithAllOptionalParameters_SuccessfullyBuildsUrl() + { + var sut = BuildService(); + + var parameters = new ReverseGeocodingParameters() + { + Coordinate = new Coordinate() { Latitude = 40.7128, Longitude = -74.0060 }, + Region = Region.Europe, + Dataset = "Current", + Lang = "en", + MatchNamedRoadsOnly = true, + MaxCleanupMiles = 0.5, + IncludePostedSpeedLimit = true, + VehicleType = "Truck", + Heading = 90.0, + IncludeLinkInfo = true, + CountryAbbrevType = CountryAbbrevType.ISO2, + IncludeTrimblePlaceIds = true, + }; + + var uri = sut.BuildReverseGeocodingRequest(parameters); + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + + query.Should().Contain("Region=3"); + query.Should().Contain("Dataset=Current"); + query.Should().Contain("lang=en"); + query.Should().Contain("matchNamedRoadsOnly=true"); + query.Should().Contain("maxCleanupMiles=0.5"); + query.Should().Contain("includePostedSpeedLimit=true"); + query.Should().Contain("vehicleType=Truck"); + query.Should().Contain("heading=90"); + query.Should().Contain("includeLinkInfo=true"); + query.Should().Contain("countryAbbrevType=ISO2"); + query.Should().Contain("includeTrimblePlaceIds=true"); + } + + [Fact] + public void ValidateAndBuildUri_WithNullParameters_ThrowsGeoNETException() + { + var sut = BuildService(); + + Action act = () => sut.ValidateAndBuildUri(null, p => new Uri("https://example.com")); + + act.Should().Throw(); + } + + /// + /// Releases resources. + /// + /// A flag indicating whether managed resources should be disposed. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _httpClient?.Dispose(); + } + + _disposed = true; + } + } + + private TrimbleGeocoding BuildService() + { + return new TrimbleGeocoding(_httpClient, _options.Object); + } + } +} diff --git a/test/Geo.Trimble.Tests/TestData/CultureTestData.cs b/test/Geo.Trimble.Tests/TestData/CultureTestData.cs new file mode 100644 index 0000000..35c6724 --- /dev/null +++ b/test/Geo.Trimble.Tests/TestData/CultureTestData.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests +{ + using System.Collections; + using System.Collections.Generic; + using System.Globalization; + + /// + /// Test data when testing different cultures. This test data returns a representative set of cultures + /// to test culture-invariant behaviour without running tests for every culture in dotnet. + /// Covers: invariant, period-decimal (en-US), comma-decimal (de-DE, fr-FR, ru-RU), Arabic, and Chinese. + /// + public class CultureTestData : IEnumerable + { + /// + /// Gets the enumerator for the test data. + /// + /// An of []. + public IEnumerator GetEnumerator() + { + yield return new object[] { CultureInfo.InvariantCulture }; + yield return new object[] { new CultureInfo("en-US") }; + yield return new object[] { new CultureInfo("de-DE") }; + yield return new object[] { new CultureInfo("fr-FR") }; + yield return new object[] { new CultureInfo("ar-SA") }; + yield return new object[] { new CultureInfo("zh-CN") }; + yield return new object[] { new CultureInfo("ru-RU") }; + } + + /// + /// Gets the enumerator for the test data. + /// + /// An . + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} From 314d2fef226b509ff14e8c0303f816d912f7c4a0 Mon Sep 17 00:00:00 2001 From: JustinCanton <67930245+JustinCanton@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:57:28 -0400 Subject: [PATCH 2/3] feat(trimble): add Trimble Maps geocoding provider with forward and reverse geocoding support --- Geo.NET.sln | 14 + .../Abstractions/ITrimbleGeocoding.cs | 38 +++ .../ServiceCollectionExtensions.cs | 44 +++ .../Extensions/LoggerExtensions.cs | 62 ++++ src/Geo.Trimble/Geo.Trimble.csproj | 45 +++ .../Models/Enums/CountryAbbrevType.cs | 28 ++ src/Geo.Trimble/Models/Enums/Region.cs | 37 ++ .../Models/Parameters/Coordinate.cs | 31 ++ .../Models/Parameters/GeocodingParameters.cs | 83 +++++ .../Parameters/ReverseGeocodingParameters.cs | 95 ++++++ .../Models/Responses/GeocodeAddress.cs | 57 ++++ .../Models/Responses/GeocodeCoords.cs | 27 ++ .../Models/Responses/GeocodeError.cs | 39 +++ .../Models/Responses/GeocodeResponse.cs | 88 +++++ .../Models/Responses/SpeedLimitInfo.cs | 45 +++ src/Geo.Trimble/Properties/AssemblyInfo.cs | 10 + src/Geo.Trimble/README.md | 39 +++ .../Services/TrimbleGeocoding.Designer.cs | 99 ++++++ .../Resources/Services/TrimbleGeocoding.resx | 132 ++++++++ src/Geo.Trimble/Services/TrimbleGeocoding.cs | 292 ++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 88 +++++ .../Geo.Trimble.Tests.csproj | 15 + .../Services/TrimbleGeocodingShould.cs | 315 ++++++++++++++++++ .../TestData/CultureTestData.cs | 40 +++ 24 files changed, 1763 insertions(+) create mode 100644 src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs create mode 100644 src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Geo.Trimble/Extensions/LoggerExtensions.cs create mode 100644 src/Geo.Trimble/Geo.Trimble.csproj create mode 100644 src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs create mode 100644 src/Geo.Trimble/Models/Enums/Region.cs create mode 100644 src/Geo.Trimble/Models/Parameters/Coordinate.cs create mode 100644 src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs create mode 100644 src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeAddress.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeCoords.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeError.cs create mode 100644 src/Geo.Trimble/Models/Responses/GeocodeResponse.cs create mode 100644 src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs create mode 100644 src/Geo.Trimble/Properties/AssemblyInfo.cs create mode 100644 src/Geo.Trimble/README.md create mode 100644 src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs create mode 100644 src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx create mode 100644 src/Geo.Trimble/Services/TrimbleGeocoding.cs create mode 100644 test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs create mode 100644 test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj create mode 100644 test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs create mode 100644 test/Geo.Trimble.Tests/TestData/CultureTestData.cs diff --git a/Geo.NET.sln b/Geo.NET.sln index be941ab..0e2f0cb 100644 --- a/Geo.NET.sln +++ b/Geo.NET.sln @@ -53,6 +53,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Positionstack", "src\Ge EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Positionstack.Tests", "test\Geo.Positionstack.Tests\Geo.Positionstack.Tests.csproj", "{E09BD60D-6E8A-4210-9274-695A2DFFE976}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Trimble", "src\Geo.Trimble\Geo.Trimble.csproj", "{7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geo.Trimble.Tests", "test\Geo.Trimble.Tests\Geo.Trimble.Tests.csproj", "{B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -131,6 +135,14 @@ Global {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Debug|Any CPU.Build.0 = Debug|Any CPU {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Release|Any CPU.ActiveCfg = Release|Any CPU {E09BD60D-6E8A-4210-9274-695A2DFFE976}.Release|Any CPU.Build.0 = Release|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90}.Release|Any CPU.Build.0 = Release|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -154,6 +166,8 @@ Global {1A320DE3-B14B-46EE-A0E6-C6783E585F73} = {67253D97-9FC9-4749-80DC-A5D84339DC05} {03C7B4AE-7905-4292-8133-3F4AA6EDCA3A} = {8F3BA9BC-542C-450C-96C9-F0D72FECC930} {E09BD60D-6E8A-4210-9274-695A2DFFE976} = {67253D97-9FC9-4749-80DC-A5D84339DC05} + {7F4A2E61-83B1-4C2D-9A5E-1D3F8B7C4E90} = {8F3BA9BC-542C-450C-96C9-F0D72FECC930} + {B2D5A7F3-6E1C-4B9A-8D2F-3C7E5A1B4D60} = {67253D97-9FC9-4749-80DC-A5D84339DC05} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C4B688C4-40EC-4577-9EB2-4CF2412DA0B1} diff --git a/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs b/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs new file mode 100644 index 0000000..d8ae4c3 --- /dev/null +++ b/src/Geo.Trimble/Abstractions/ITrimbleGeocoding.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble +{ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Models.Responses; + + /// + /// An interface for calling the Trimble Maps geocoding methods. + /// + public interface ITrimbleGeocoding + { + /// + /// Calls the Trimble Maps geocoding API and returns the results. + /// + /// A with the parameters of the request. + /// A used to cancel the request. + /// A list of with the response from Trimble Maps. + /// Thrown for multiple different reasons. Check the inner exception for more information. + Task> GeocodingAsync(GeocodingParameters parameters, CancellationToken cancellationToken = default); + + /// + /// Calls the Trimble Maps reverse geocoding API and returns the results. + /// + /// A with the parameters of the request. + /// A used to cancel the request. + /// A with the response from Trimble Maps. + /// Thrown for multiple different reasons. Check the inner exception for more information. + Task ReverseGeocodingAsync(ReverseGeocodingParameters parameters, CancellationToken cancellationToken = default); + } +} diff --git a/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs b/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9697025 --- /dev/null +++ b/src/Geo.Trimble/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Extensions.DependencyInjection +{ + using System; + using Geo.Trimble; + using Geo.Trimble.Services; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + + /// + /// Extension methods for the class. + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds the Trimble Maps geocoding services to the service collection. + /// + /// Adds the services: + /// + /// of + /// + /// + /// + /// + /// An to add the Trimble Maps services to. + /// An to configure the Trimble Maps geocoding. + /// Thrown if is null. + public static KeyBuilder AddTrimbleGeocoding(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddKeyOptions(); + + return new KeyBuilder(services.AddHttpClient()); + } + } +} diff --git a/src/Geo.Trimble/Extensions/LoggerExtensions.cs b/src/Geo.Trimble/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..6451c23 --- /dev/null +++ b/src/Geo.Trimble/Extensions/LoggerExtensions.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble +{ + using System; + using Geo.Trimble.Services; + using Microsoft.Extensions.Logging; + + /// + /// Extension methods for the class. + /// + internal static class LoggerExtensions + { + private static readonly Action _error = LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {ErrorMessage}"); + + private static readonly Action _warning = LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {WarningMessage}"); + + private static readonly Action _debug = LoggerMessage.Define( + LogLevel.Debug, + new EventId(3, nameof(TrimbleGeocoding)), + "TrimbleGeocoding: {DebugMessage}"); + + /// + /// "TrimbleGeocoding: {ErrorMessage}". + /// + /// An used to log the error message. + /// The error message to log. + public static void TrimbleError(this ILogger logger, string errorMessage) + { + _error(logger, errorMessage, null); + } + + /// + /// "TrimbleGeocoding: {WarningMessage}". + /// + /// An used to log the warning message. + /// The warning message to log. + public static void TrimbleWarning(this ILogger logger, string warningMessage) + { + _warning(logger, warningMessage, null); + } + + /// + /// "TrimbleGeocoding: {DebugMessage}". + /// + /// An used to log the debug message. + /// The debug message to log. + public static void TrimbleDebug(this ILogger logger, string debugMessage) + { + _debug(logger, debugMessage, null); + } + } +} diff --git a/src/Geo.Trimble/Geo.Trimble.csproj b/src/Geo.Trimble/Geo.Trimble.csproj new file mode 100644 index 0000000..d547a9a --- /dev/null +++ b/src/Geo.Trimble/Geo.Trimble.csproj @@ -0,0 +1,45 @@ + + + + netstandard2.0;net6.0;net8.0;net10.0 + Justin Canton + Geo.NET + Geo.NET Trimble + geocoding geo.net Trimble + A lightweight method for communicating with the Trimble Maps geocoding APIs. This includes models and interfaces for calling Trimble Maps. + MIT + https://github.com/JustinCanton/Geo.NET + true + README.md + + + + + + + + + + + True + True + TrimbleGeocoding.resx + + + + + + ResXFileCodeGenerator + TrimbleGeocoding.Designer.cs + + + + + + True + \ + Always + + + + diff --git a/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs b/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs new file mode 100644 index 0000000..76dd3dd --- /dev/null +++ b/src/Geo.Trimble/Models/Enums/CountryAbbrevType.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Enums +{ + /// + /// The format used for country abbreviations in Trimble Maps API responses. + /// + public enum CountryAbbrevType + { + /// FIPS country code. + FIPS, + + /// ISO 3166-1 alpha-2 (two-letter) country code. + ISO2, + + /// ISO 3166-1 alpha-3 (three-letter) country code. + ISO3, + + /// GENC two-letter country code. + GENC2, + + /// GENC three-letter country code. + GENC3, + } +} diff --git a/src/Geo.Trimble/Models/Enums/Region.cs b/src/Geo.Trimble/Models/Enums/Region.cs new file mode 100644 index 0000000..d3e49c7 --- /dev/null +++ b/src/Geo.Trimble/Models/Enums/Region.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Enums +{ + /// + /// The geographic region used to scope Trimble Maps API requests. + /// + public enum Region + { + /// North America (default). + NorthAmerica = 0, + + /// Africa. + Africa = 1, + + /// Asia. + Asia = 2, + + /// Europe. + Europe = 3, + + /// Oceania. + Oceania = 4, + + /// South America. + SouthAmerica = 5, + + /// Middle East. + MiddleEast = 6, + + /// Global. + Global = 7, + } +} diff --git a/src/Geo.Trimble/Models/Parameters/Coordinate.cs b/src/Geo.Trimble/Models/Parameters/Coordinate.cs new file mode 100644 index 0000000..07e4455 --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/Coordinate.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Globalization; + + /// + /// The coordinates (longitude, latitude) used for Trimble Maps reverse geocoding. + /// + public class Coordinate + { + /// + /// Gets or sets the latitude of the location. For example: "40.7128". + /// + public double Latitude { get; set; } + + /// + /// Gets or sets the longitude of the location. For example: "-74.0060". + /// + public double Longitude { get; set; } + + /// + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, "{0},{1}", Longitude, Latitude); + } + } +} diff --git a/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs b/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs new file mode 100644 index 0000000..3fccf68 --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/GeocodingParameters.cs @@ -0,0 +1,83 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Collections.Generic; + using Geo.Trimble.Models.Enums; + + /// + /// The parameters possible to use during a Trimble Maps geocoding request. + /// + public class GeocodingParameters : IKeyParameters, IAdditionalParameters + { + /// + /// Gets or sets the street address to geocode. + /// Optional. + /// + public string Street { get; set; } + + /// + /// Gets or sets the city to geocode. + /// Optional. + /// + public string City { get; set; } + + /// + /// Gets or sets the state or province to geocode. + /// Optional. + /// + public string State { get; set; } + + /// + /// Gets or sets the postal/ZIP code to geocode. + /// Optional. + /// + public string Zip { get; set; } + + /// + /// Gets or sets the county to geocode. + /// Optional. + /// + public string County { get; set; } + + /// + /// Gets or sets the country to geocode. + /// Optional. + /// + public string Country { get; set; } + + /// + /// Gets or sets the geographic region. + /// Optional. + /// Default: . + /// + public Region Region { get; set; } = Region.NorthAmerica; + + /// + /// Gets or sets a specific data version to query. + /// Optional. + /// + public string Dataset { get; set; } + + /// + /// Gets or sets the maximum number of results to return. + /// Optional. + /// + public int? MaxResults { get; set; } + + /// + /// Gets or sets a value indicating whether results should be limited to named roads only. + /// Optional. + /// + public bool MatchNamedRoadsOnly { get; set; } + + /// + public string Key { get; set; } + + /// + public IDictionary AdditionalParameters { get; } = new Dictionary(); + } +} diff --git a/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs b/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs new file mode 100644 index 0000000..400da9e --- /dev/null +++ b/src/Geo.Trimble/Models/Parameters/ReverseGeocodingParameters.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Parameters +{ + using System.Collections.Generic; + using Geo.Trimble.Models.Enums; + + /// + /// The parameters possible to use during a Trimble Maps reverse geocoding request. + /// + public class ReverseGeocodingParameters : IKeyParameters, IAdditionalParameters + { + /// + /// Gets or sets the coordinates to reverse geocode. + /// Required. + /// + public Coordinate Coordinate { get; set; } + + /// + /// Gets or sets the geographic region. + /// Optional. + /// Default: . + /// + public Region Region { get; set; } = Region.NorthAmerica; + + /// + /// Gets or sets a specific data version to query. + /// Optional. + /// + public string Dataset { get; set; } + + /// + /// Gets or sets the preferred response language. + /// Optional. + /// + public string Lang { get; set; } + + /// + /// Gets or sets a value indicating whether results should be limited to named roads only. + /// Optional. + /// + public bool MatchNamedRoadsOnly { get; set; } + + /// + /// Gets or sets the maximum distance in miles to search for the nearest road. + /// Optional. + /// + public double? MaxCleanupMiles { get; set; } + + /// + /// Gets or sets a value indicating whether to include posted speed limit data in the response. + /// Optional. + /// + public bool IncludePostedSpeedLimit { get; set; } + + /// + /// Gets or sets the vehicle type classification. + /// Optional. + /// + public string VehicleType { get; set; } + + /// + /// Gets or sets the direction of travel in degrees. + /// Optional. + /// + public double? Heading { get; set; } + + /// + /// Gets or sets a value indicating whether to include road link identifiers in the response. + /// Optional. + /// + public bool IncludeLinkInfo { get; set; } + + /// + /// Gets or sets the format to use for country abbreviations in the response. + /// Optional. + /// + public CountryAbbrevType? CountryAbbrevType { get; set; } + + /// + /// Gets or sets a value indicating whether to include Trimble place database information. + /// Optional. + /// + public bool IncludeTrimblePlaceIds { get; set; } + + /// + public string Key { get; set; } + + /// + public IDictionary AdditionalParameters { get; } = new Dictionary(); + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs b/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs new file mode 100644 index 0000000..c4bf5ef --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeAddress.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// The address portion of a Trimble Maps geocoding response. + /// + public class GeocodeAddress + { + /// + /// Gets or sets the street address. + /// + [JsonPropertyName("StreetAddress")] + public string StreetAddress { get; set; } + + /// + /// Gets or sets the city. + /// + [JsonPropertyName("City")] + public string City { get; set; } + + /// + /// Gets or sets the state or province. + /// + [JsonPropertyName("State")] + public string State { get; set; } + + /// + /// Gets or sets the postal/ZIP code. + /// + [JsonPropertyName("Zip")] + public string Zip { get; set; } + + /// + /// Gets or sets the county. + /// + [JsonPropertyName("County")] + public string County { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("Country")] + public string Country { get; set; } + + /// + /// Gets or sets the abbreviated country code. + /// + [JsonPropertyName("CountryAbbreviation")] + public string CountryAbbreviation { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs b/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs new file mode 100644 index 0000000..65c2515 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeCoords.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// The coordinate portion of a Trimble Maps geocoding response. + /// + public class GeocodeCoords + { + /// + /// Gets or sets the latitude as a string. + /// + [JsonPropertyName("Lat")] + public string Lat { get; set; } + + /// + /// Gets or sets the longitude as a string. + /// + [JsonPropertyName("Lon")] + public string Lon { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeError.cs b/src/Geo.Trimble/Models/Responses/GeocodeError.cs new file mode 100644 index 0000000..9877312 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeError.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// An error or warning returned in a Trimble Maps geocoding response. + /// + public class GeocodeError + { + /// + /// Gets or sets the error type (Warning or Exception). + /// + [JsonPropertyName("Type")] + public string Type { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("Code")] + public string Code { get; set; } + + /// + /// Gets or sets the legacy numeric error code. + /// + [JsonPropertyName("LegacyErrorCode")] + public int LegacyErrorCode { get; set; } + + /// + /// Gets or sets a human-readable description of the error. + /// + [JsonPropertyName("Description")] + public string Description { get; set; } + } +} diff --git a/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs new file mode 100644 index 0000000..a0695ef --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Collections.Generic; + using System.Text.Json.Serialization; + + /// + /// A geocoding result returned by the Trimble Maps API. + /// + public class GeocodeResponse + { + /// + /// Gets or sets the matched address. + /// + [JsonPropertyName("Address")] + public GeocodeAddress Address { get; set; } + + /// + /// Gets or sets the coordinates of the matched location. + /// + [JsonPropertyName("Coords")] + public GeocodeCoords Coords { get; set; } + + /// + /// Gets or sets the formatted label for the location. + /// + [JsonPropertyName("Label")] + public string Label { get; set; } + + /// + /// Gets or sets the place name, which may include a Trimble place ID. + /// + [JsonPropertyName("PlaceName")] + public string PlaceName { get; set; } + + /// + /// Gets or sets the numeric geographic region code. + /// + [JsonPropertyName("Region")] + public int Region { get; set; } + + /// + /// Gets or sets the time zone name. + /// + [JsonPropertyName("TimeZone")] + public string TimeZone { get; set; } + + /// + /// Gets or sets the UTC offset string (e.g. "GMT-5:00"). + /// + [JsonPropertyName("TimeZoneOffset")] + public string TimeZoneOffset { get; set; } + + /// + /// Gets or sets a value indicating whether daylight saving time is in effect. + /// + [JsonPropertyName("isDST")] + public bool IsDst { get; set; } + + /// + /// Gets or sets the confidence level of the match (Exact, Good, Uncertain, or Failed). + /// + [JsonPropertyName("ConfidenceLevel")] + public string ConfidenceLevel { get; set; } + + /// + /// Gets or sets the distance in miles from the input coordinates to the nearest road. + /// + [JsonPropertyName("DistanceFromRoad")] + public double DistanceFromRoad { get; set; } + + /// + /// Gets or sets the speed limit information for the nearest road. + /// + [JsonPropertyName("SpeedLimitInfo")] + public SpeedLimitInfo SpeedLimitInfo { get; set; } + + /// + /// Gets the errors or warnings associated with this result. + /// + [JsonPropertyName("Errors")] + public IList Errors { get; } = new List(); + } +} diff --git a/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs new file mode 100644 index 0000000..691ce62 --- /dev/null +++ b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Models.Responses +{ + using System.Text.Json.Serialization; + + /// + /// Speed limit information returned in a Trimble Maps reverse geocoding response. + /// + public class SpeedLimitInfo + { + /// + /// Gets or sets the speed limit value. + /// + [JsonPropertyName("Speed")] + public int Speed { get; set; } + + /// + /// Gets or sets the speed limit type classification. + /// + [JsonPropertyName("SpeedType")] + public int SpeedType { get; set; } + + /// + /// Gets or sets the road link identifiers. + /// + [JsonPropertyName("LinkIds")] + public long LinkIds { get; set; } + + /// + /// Gets or sets the road class designation. + /// + [JsonPropertyName("RoadClass")] + public string RoadClass { get; set; } + + /// + /// Gets or sets the measurement units (KPH or MPH). + /// + [JsonPropertyName("Units")] + public string Units { get; set; } + } +} diff --git a/src/Geo.Trimble/Properties/AssemblyInfo.cs b/src/Geo.Trimble/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b97f9f9 --- /dev/null +++ b/src/Geo.Trimble/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Localization; + +[assembly: InternalsVisibleTo("Geo.Trimble.Tests")] +[assembly: ResourceLocation("Resources")] diff --git a/src/Geo.Trimble/README.md b/src/Geo.Trimble/README.md new file mode 100644 index 0000000..6b253f4 --- /dev/null +++ b/src/Geo.Trimble/README.md @@ -0,0 +1,39 @@ +# Trimble Maps Geocoding + +This allows the simple calling of Trimble Maps geocoding APIs. The supported Trimble Maps geocoding endpoints are: +- [Geocoding](https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations) +- [Reverse Geocoding](https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations/reverse) + +## Configuration + +In the startup `ConfigureServices` method, add the configuration for the Trimble Maps service: +``` +using Geo.Extensions.DependencyInjection; +. +. +. +public void ConfigureServices(IServiceCollection services) +{ + . + . + . + var builder = services.AddTrimbleGeocoding(); + builder.AddKey(your_Trimble_Maps_api_key_here); + builder.HttpClientBuilder.ConfigureHttpClient(configure_client); + . + . + . +} +``` + +## Sample Usage + +By calling `AddTrimbleGeocoding`, the `ITrimbleGeocoding` interface has been added to the IOC container. Just request it as a DI item: +``` +public MyService(ITrimbleGeocoding trimbleGeocoding) +{ + ... +} +``` + +Now simply call the geocoding methods in the interface. diff --git a/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs new file mode 100644 index 0000000..532ee4f --- /dev/null +++ b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Geo.Trimble.Resources.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class TrimbleGeocoding { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal TrimbleGeocoding() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Geo.Trimble.Resources.Services.TrimbleGeocoding", typeof(TrimbleGeocoding).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Failed to create the Trimble Maps uri.. + /// + internal static string Failed_To_Create_Uri { + get { + return ResourceManager.GetString("Failed To Create Uri", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At least one address field must be provided.. + /// + internal static string Invalid_Address { + get { + return ResourceManager.GetString("Invalid Address", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The coordinate cannot be null or invalid.. + /// + internal static string Invalid_Coordinate { + get { + return ResourceManager.GetString("Invalid Coordinate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Trimble Maps parameters are null.. + /// + internal static string Null_Parameters { + get { + return ResourceManager.GetString("Null Parameters", resourceCulture); + } + } + } +} diff --git a/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx new file mode 100644 index 0000000..ff1cd57 --- /dev/null +++ b/src/Geo.Trimble/Resources/Services/TrimbleGeocoding.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Failed to create the Trimble Maps uri. + + + At least one address field must be provided. + + + The coordinate cannot be null or invalid. + + + The Trimble Maps parameters are null. + + diff --git a/src/Geo.Trimble/Services/TrimbleGeocoding.cs b/src/Geo.Trimble/Services/TrimbleGeocoding.cs new file mode 100644 index 0000000..4b726a0 --- /dev/null +++ b/src/Geo.Trimble/Services/TrimbleGeocoding.cs @@ -0,0 +1,292 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Services +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Geo.Core; + using Geo.Core.Extensions; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Enums; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Models.Responses; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Options; + + /// + /// A service to call the Trimble Maps geocoding API. + /// + public class TrimbleGeocoding : GeoClient, ITrimbleGeocoding + { + private const string GeocodeUri = "https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations"; + private const string ReverseGeocodeUri = "https://pcmiler.alk.com/apis/rest/v1.0/service.svc/locations/reverse"; + + private readonly IOptions> _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// A used for placing calls to the Trimble Maps Geocoding API. + /// An of containing Trimble Maps key information. + /// An used to create a logger used for logging information. + public TrimbleGeocoding( + HttpClient client, + IOptions> options, + ILoggerFactory loggerFactory = null) + : base(client, loggerFactory) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + /// + protected override string ApiName => "Trimble"; + + /// + public async Task> GeocodingAsync( + GeocodingParameters parameters, + CancellationToken cancellationToken = default) + { + var uri = ValidateAndBuildUri(parameters, BuildGeocodingRequest); + + return await GetAsync>(uri, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ReverseGeocodingAsync( + ReverseGeocodingParameters parameters, + CancellationToken cancellationToken = default) + { + var uri = ValidateAndBuildUri(parameters, BuildReverseGeocodingRequest); + + return await GetAsync(uri, cancellationToken).ConfigureAwait(false); + } + + /// + /// Validates the uri and builds it based on the parameter type. + /// + /// The type of the parameters. + /// The parameters to validate and create a uri from. + /// The method to use to create the uri. + /// A with the uri crafted from the parameters. + internal Uri ValidateAndBuildUri(TParameters parameters, Func uriBuilderFunction) + where TParameters : class + { + if (parameters is null) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Null_Parameters); + throw new GeoNETException(Resources.Services.TrimbleGeocoding.Null_Parameters, new ArgumentNullException(nameof(parameters))); + } + + try + { + return uriBuilderFunction(parameters); + } + catch (ArgumentException ex) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Failed_To_Create_Uri); + throw new GeoNETException(Resources.Services.TrimbleGeocoding.Failed_To_Create_Uri, ex); + } + } + + /// + /// Builds the geocoding uri based on the passed parameters. + /// + /// A with the geocoding parameters to build the uri with. + /// A with the completed Trimble Maps geocoding uri. + /// Thrown when no address fields are provided. + internal Uri BuildGeocodingRequest(GeocodingParameters parameters) + { + var uriBuilder = new UriBuilder(GeocodeUri); + var query = QueryString.Empty; + + if (string.IsNullOrWhiteSpace(parameters.Street) && + string.IsNullOrWhiteSpace(parameters.City) && + string.IsNullOrWhiteSpace(parameters.State) && + string.IsNullOrWhiteSpace(parameters.Zip) && + string.IsNullOrWhiteSpace(parameters.County) && + string.IsNullOrWhiteSpace(parameters.Country)) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Invalid_Address); + throw new ArgumentException(Resources.Services.TrimbleGeocoding.Invalid_Address, nameof(parameters)); + } + + AddAddressParameters(parameters, ref query); + AddTrimbleKey(parameters, ref query); + query = query.AddAdditionalParameters(parameters); + + uriBuilder.AddQuery(query); + + return uriBuilder.Uri; + } + + /// + /// Builds the reverse geocoding uri based on the passed parameters. + /// + /// A with the reverse geocoding parameters to build the uri with. + /// A with the completed Trimble Maps reverse geocoding uri. + /// Thrown when the parameter is null. + internal Uri BuildReverseGeocodingRequest(ReverseGeocodingParameters parameters) + { + var uriBuilder = new UriBuilder(ReverseGeocodeUri); + var query = QueryString.Empty; + + if (parameters.Coordinate is null) + { + _logger.TrimbleError(Resources.Services.TrimbleGeocoding.Invalid_Coordinate); + throw new ArgumentException(Resources.Services.TrimbleGeocoding.Invalid_Coordinate, nameof(parameters.Coordinate)); + } + + query = query.Add("Coords", parameters.Coordinate.ToString()); + + AddReverseParameters(parameters, ref query); + AddTrimbleKey(parameters, ref query); + query = query.AddAdditionalParameters(parameters); + + uriBuilder.AddQuery(query); + + return uriBuilder.Uri; + } + + /// + /// Adds the structured address fields to the query string. + /// + /// The to read address fields from. + /// A with the query parameters. + internal void AddAddressParameters(GeocodingParameters parameters, ref QueryString query) + { + if (!string.IsNullOrWhiteSpace(parameters.Street)) + { + query = query.Add("Street", parameters.Street); + } + + if (!string.IsNullOrWhiteSpace(parameters.City)) + { + query = query.Add("City", parameters.City); + } + + if (!string.IsNullOrWhiteSpace(parameters.State)) + { + query = query.Add("State", parameters.State); + } + + if (!string.IsNullOrWhiteSpace(parameters.Zip)) + { + query = query.Add("Zip", parameters.Zip); + } + + if (!string.IsNullOrWhiteSpace(parameters.County)) + { + query = query.Add("County", parameters.County); + } + + if (!string.IsNullOrWhiteSpace(parameters.Country)) + { + query = query.Add("Country", parameters.Country); + } + + query = query.Add("Region", ((int)parameters.Region).ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(parameters.Dataset)) + { + query = query.Add("Dataset", parameters.Dataset); + } + + if (parameters.MaxResults.HasValue && parameters.MaxResults.Value > 0) + { + query = query.Add("MaxResults", parameters.MaxResults.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.MatchNamedRoadsOnly) + { + query = query.Add("MatchNamedRoadsOnly", "true"); + } + } + + /// + /// Adds the optional reverse geocoding parameters to the query string. + /// + /// The to read optional fields from. + /// A with the query parameters. + internal void AddReverseParameters(ReverseGeocodingParameters parameters, ref QueryString query) + { + query = query.Add("Region", ((int)parameters.Region).ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(parameters.Dataset)) + { + query = query.Add("Dataset", parameters.Dataset); + } + + if (!string.IsNullOrWhiteSpace(parameters.Lang)) + { + query = query.Add("lang", parameters.Lang); + } + + if (parameters.MatchNamedRoadsOnly) + { + query = query.Add("matchNamedRoadsOnly", "true"); + } + + if (parameters.MaxCleanupMiles.HasValue) + { + query = query.Add("maxCleanupMiles", parameters.MaxCleanupMiles.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.IncludePostedSpeedLimit) + { + query = query.Add("includePostedSpeedLimit", "true"); + } + + if (!string.IsNullOrWhiteSpace(parameters.VehicleType)) + { + query = query.Add("vehicleType", parameters.VehicleType); + } + + if (parameters.Heading.HasValue) + { + query = query.Add("heading", parameters.Heading.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parameters.IncludeLinkInfo) + { + query = query.Add("includeLinkInfo", "true"); + } + + if (parameters.CountryAbbrevType.HasValue) + { + query = query.Add("countryAbbrevType", parameters.CountryAbbrevType.Value.ToString()); + } + + if (parameters.IncludeTrimblePlaceIds) + { + query = query.Add("includeTrimblePlaceIds", "true"); + } + } + + /// + /// Adds the Trimble Maps API key to the request as the authToken query parameter. + /// + /// An to conditionally get the key from. + /// A with the query parameters. + internal void AddTrimbleKey(IKeyParameters keyParameter, ref QueryString query) + { + var key = _options.Value.Key; + + if (!string.IsNullOrWhiteSpace(keyParameter.Key)) + { + key = keyParameter.Key; + } + + query = query.Add("authToken", key); + } + } +} diff --git a/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..8937849 --- /dev/null +++ b/test/Geo.Trimble.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests.DependencyInjection +{ + using System; + using System.Net.Http; + using FluentAssertions; + using Geo.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using Xunit; + + /// + /// Unit tests for the class. + /// + public class ServiceCollectionExtensionsTests + { + [Fact] + public void AddTrimbleGeocoding_WithValidCall_ConfiguresAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var builder = services.AddTrimbleGeocoding(); + builder.AddKey("abc"); + + // Assert + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>>(); + options.Should().NotBeNull(); + options.Value.Key.Should().Be("abc"); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddTrimbleGeocoding_WithNullOptions_ConfiguresAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddTrimbleGeocoding(); + + // Assert + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>>(); + options.Should().NotBeNull(); + options.Value.Key.Should().Be(string.Empty); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddTrimbleGeocoding_WithClientConfiguration_ConfiguresHttpClientAllServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var builder = services.AddTrimbleGeocoding(); + builder.AddKey("abc"); + builder.HttpClientBuilder.ConfigureHttpClient(httpClient => httpClient.Timeout = TimeSpan.FromSeconds(5)); + + // Assert + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService().CreateClient("ITrimbleGeocoding"); + client.Timeout.Should().Be(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AddTrimbleGeocoding_WithNullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null; + + // Act + Action act = () => services.AddTrimbleGeocoding(); + + // Assert + act.Should().Throw(); + } + } +} diff --git a/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj b/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj new file mode 100644 index 0000000..8359b2d --- /dev/null +++ b/test/Geo.Trimble.Tests/Geo.Trimble.Tests.csproj @@ -0,0 +1,15 @@ + + + + net48;netcoreapp3.1;net6.0;net8.0;net10.0 + + false + + + + + + + + + diff --git a/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs b/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs new file mode 100644 index 0000000..e2099d5 --- /dev/null +++ b/test/Geo.Trimble.Tests/Services/TrimbleGeocodingShould.cs @@ -0,0 +1,315 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests.Services +{ + using System; + using System.Globalization; + using System.Net.Http; + using System.Threading; + using System.Web; + using FluentAssertions; + using Geo.Core; + using Geo.Core.Models.Exceptions; + using Geo.Trimble.Models.Enums; + using Geo.Trimble.Models.Parameters; + using Geo.Trimble.Services; + using Microsoft.Extensions.Options; + using Moq; + using Xunit; + + /// + /// Unit tests for the class. + /// + public class TrimbleGeocodingShould : IDisposable + { + private readonly HttpClient _httpClient; + private readonly Mock>> _options = new Mock>>(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public TrimbleGeocodingShould() + { + _options + .Setup(x => x.Value) + .Returns(new KeyOptions() + { + Key = "abc123", + }); + + _httpClient = new HttpClient(new Mock().Object); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [Fact] + public void AddTrimbleKey_WithOptions_SuccessfullyAddsKey() + { + var sut = BuildService(); + + var query = QueryString.Empty; + + sut.AddTrimbleKey(new GeocodingParameters(), ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters.Count.Should().Be(1); + queryParameters["authToken"].Should().Be("abc123"); + } + + [Fact] + public void AddTrimbleKey_WithParameterOverride_SuccessfullyAddsKey() + { + var sut = BuildService(); + + var query = QueryString.Empty; + + sut.AddTrimbleKey(new GeocodingParameters() { Key = "override123" }, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters.Count.Should().Be(1); + queryParameters["authToken"].Should().Be("override123"); + } + + [Fact] + public void AddAddressParameters_WithAllFields_AddsAllParameters() + { + var sut = BuildService(); + + var query = QueryString.Empty; + var parameters = new GeocodingParameters() + { + Street = "100 Main St", + City = "Springfield", + State = "IL", + Zip = "62701", + County = "Sangamon", + Country = "US", + Region = Region.NorthAmerica, + Dataset = "Current", + MaxResults = 5, + MatchNamedRoadsOnly = true, + }; + + sut.AddAddressParameters(parameters, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters["Street"].Should().Be("100 Main St"); + queryParameters["City"].Should().Be("Springfield"); + queryParameters["State"].Should().Be("IL"); + queryParameters["Zip"].Should().Be("62701"); + queryParameters["County"].Should().Be("Sangamon"); + queryParameters["Country"].Should().Be("US"); + queryParameters["Region"].Should().Be("0"); + queryParameters["Dataset"].Should().Be("Current"); + queryParameters["MaxResults"].Should().Be("5"); + queryParameters["MatchNamedRoadsOnly"].Should().Be("true"); + } + + [Fact] + public void AddAddressParameters_WithMinimalFields_OnlyAddsPresentFields() + { + var sut = BuildService(); + + var query = QueryString.Empty; + var parameters = new GeocodingParameters() + { + City = "Chicago", + State = "IL", + }; + + sut.AddAddressParameters(parameters, ref query); + + var queryParameters = HttpUtility.ParseQueryString(query.ToString()); + queryParameters["City"].Should().Be("Chicago"); + queryParameters["State"].Should().Be("IL"); + queryParameters["Street"].Should().BeNull(); + queryParameters["Zip"].Should().BeNull(); + } + + [Theory] + [ClassData(typeof(CultureTestData))] + public void BuildGeocodingRequest_WithValidParameters_SuccessfullyBuildsUrl(CultureInfo culture) + { + // Arrange + var oldCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = culture; + + var sut = BuildService(); + + var parameters = new GeocodingParameters() + { + Street = "100 Main St", + City = "Springfield", + State = "IL", + Key = "abc123", + }; + + // Act + var uri = sut.BuildGeocodingRequest(parameters); + + // Assert + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("Street=100 Main St"); + query.Should().Contain("City=Springfield"); + query.Should().Contain("State=IL"); + query.Should().Contain("authToken=abc123"); + + Thread.CurrentThread.CurrentCulture = oldCulture; + } + + [Fact] + public void BuildGeocodingRequest_WithNoAddressFields_ThrowsArgumentException() + { + var sut = BuildService(); + + Action act = () => sut.BuildGeocodingRequest(new GeocodingParameters()); + + act.Should() + .Throw(); + } + + [Fact] + public void BuildGeocodingRequest_WithAdditionalParameters_AddsThemToQueryString() + { + var sut = BuildService(); + + var parameters = new GeocodingParameters() + { + City = "Chicago", + }; + + parameters.AdditionalParameters.Add("customKey1", "customValue1"); + parameters.AdditionalParameters.Add("customKey2", "customValue2"); + + var uri = sut.BuildGeocodingRequest(parameters); + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("customKey1=customValue1"); + query.Should().Contain("customKey2=customValue2"); + } + + [Theory] + [ClassData(typeof(CultureTestData))] + public void BuildReverseGeocodingRequest_WithValidParameters_SuccessfullyBuildsUrl(CultureInfo culture) + { + // Arrange + var oldCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = culture; + + var sut = BuildService(); + + var parameters = new ReverseGeocodingParameters() + { + Coordinate = new Coordinate() + { + Latitude = 40.7128, + Longitude = -74.0060, + }, + Key = "abc123", + }; + + // Act + var uri = sut.BuildReverseGeocodingRequest(parameters); + + // Assert + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + query.Should().Contain("Coords=-74.006,40.7128"); + query.Should().Contain("authToken=abc123"); + + Thread.CurrentThread.CurrentCulture = oldCulture; + } + + [Fact] + public void BuildReverseGeocodingRequest_WithNullCoordinate_ThrowsArgumentException() + { + var sut = BuildService(); + + Action act = () => sut.BuildReverseGeocodingRequest(new ReverseGeocodingParameters()); + + act.Should() + .Throw() +#if NETCOREAPP3_1_OR_GREATER + .WithMessage("*(Parameter 'Coordinate')"); +#else + .WithMessage("*Parameter name: Coordinate"); +#endif + } + + [Fact] + public void BuildReverseGeocodingRequest_WithAllOptionalParameters_SuccessfullyBuildsUrl() + { + var sut = BuildService(); + + var parameters = new ReverseGeocodingParameters() + { + Coordinate = new Coordinate() { Latitude = 40.7128, Longitude = -74.0060 }, + Region = Region.Europe, + Dataset = "Current", + Lang = "en", + MatchNamedRoadsOnly = true, + MaxCleanupMiles = 0.5, + IncludePostedSpeedLimit = true, + VehicleType = "Truck", + Heading = 90.0, + IncludeLinkInfo = true, + CountryAbbrevType = CountryAbbrevType.ISO2, + IncludeTrimblePlaceIds = true, + }; + + var uri = sut.BuildReverseGeocodingRequest(parameters); + var query = HttpUtility.UrlDecode(uri.PathAndQuery); + + query.Should().Contain("Region=3"); + query.Should().Contain("Dataset=Current"); + query.Should().Contain("lang=en"); + query.Should().Contain("matchNamedRoadsOnly=true"); + query.Should().Contain("maxCleanupMiles=0.5"); + query.Should().Contain("includePostedSpeedLimit=true"); + query.Should().Contain("vehicleType=Truck"); + query.Should().Contain("heading=90"); + query.Should().Contain("includeLinkInfo=true"); + query.Should().Contain("countryAbbrevType=ISO2"); + query.Should().Contain("includeTrimblePlaceIds=true"); + } + + [Fact] + public void ValidateAndBuildUri_WithNullParameters_ThrowsGeoNETException() + { + var sut = BuildService(); + + Action act = () => sut.ValidateAndBuildUri(null, p => new Uri("https://example.com")); + + act.Should().Throw(); + } + + /// + /// Releases resources. + /// + /// A flag indicating whether managed resources should be disposed. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _httpClient?.Dispose(); + } + + _disposed = true; + } + } + + private TrimbleGeocoding BuildService() + { + return new TrimbleGeocoding(_httpClient, _options.Object); + } + } +} diff --git a/test/Geo.Trimble.Tests/TestData/CultureTestData.cs b/test/Geo.Trimble.Tests/TestData/CultureTestData.cs new file mode 100644 index 0000000..35c6724 --- /dev/null +++ b/test/Geo.Trimble.Tests/TestData/CultureTestData.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) Geo.NET. +// Licensed under the MIT license. See the LICENSE file in the solution root for full license information. +// + +namespace Geo.Trimble.Tests +{ + using System.Collections; + using System.Collections.Generic; + using System.Globalization; + + /// + /// Test data when testing different cultures. This test data returns a representative set of cultures + /// to test culture-invariant behaviour without running tests for every culture in dotnet. + /// Covers: invariant, period-decimal (en-US), comma-decimal (de-DE, fr-FR, ru-RU), Arabic, and Chinese. + /// + public class CultureTestData : IEnumerable + { + /// + /// Gets the enumerator for the test data. + /// + /// An of []. + public IEnumerator GetEnumerator() + { + yield return new object[] { CultureInfo.InvariantCulture }; + yield return new object[] { new CultureInfo("en-US") }; + yield return new object[] { new CultureInfo("de-DE") }; + yield return new object[] { new CultureInfo("fr-FR") }; + yield return new object[] { new CultureInfo("ar-SA") }; + yield return new object[] { new CultureInfo("zh-CN") }; + yield return new object[] { new CultureInfo("ru-RU") }; + } + + /// + /// Gets the enumerator for the test data. + /// + /// An . + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} From 1755bd5294f962bc85e0d19701afc3de741ac7b4 Mon Sep 17 00:00:00 2001 From: JustinCanton <67930245+JustinCanton@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:02:11 -0400 Subject: [PATCH 3/3] fix(trimble): fixing null references when numbers aren't returned --- Geo.NET.sln | 4 ++-- src/Geo.Trimble/Models/Responses/GeocodeError.cs | 2 +- src/Geo.Trimble/Models/Responses/GeocodeResponse.cs | 6 +++--- src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Geo.NET.sln b/Geo.NET.sln index 0e2f0cb..9364d4e 100644 --- a/Geo.NET.sln +++ b/Geo.NET.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32421.90 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11520.95 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{B3BD11A7-1752-49E3-AE50-A65B5FE7B7B2}" ProjectSection(SolutionItems) = preProject diff --git a/src/Geo.Trimble/Models/Responses/GeocodeError.cs b/src/Geo.Trimble/Models/Responses/GeocodeError.cs index 9877312..a091495 100644 --- a/src/Geo.Trimble/Models/Responses/GeocodeError.cs +++ b/src/Geo.Trimble/Models/Responses/GeocodeError.cs @@ -28,7 +28,7 @@ public class GeocodeError /// Gets or sets the legacy numeric error code. /// [JsonPropertyName("LegacyErrorCode")] - public int LegacyErrorCode { get; set; } + public int? LegacyErrorCode { get; set; } /// /// Gets or sets a human-readable description of the error. diff --git a/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs index a0695ef..2f45bcc 100644 --- a/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs +++ b/src/Geo.Trimble/Models/Responses/GeocodeResponse.cs @@ -41,7 +41,7 @@ public class GeocodeResponse /// Gets or sets the numeric geographic region code. /// [JsonPropertyName("Region")] - public int Region { get; set; } + public int? Region { get; set; } /// /// Gets or sets the time zone name. @@ -59,7 +59,7 @@ public class GeocodeResponse /// Gets or sets a value indicating whether daylight saving time is in effect. /// [JsonPropertyName("isDST")] - public bool IsDst { get; set; } + public bool? IsDst { get; set; } /// /// Gets or sets the confidence level of the match (Exact, Good, Uncertain, or Failed). @@ -71,7 +71,7 @@ public class GeocodeResponse /// Gets or sets the distance in miles from the input coordinates to the nearest road. /// [JsonPropertyName("DistanceFromRoad")] - public double DistanceFromRoad { get; set; } + public double? DistanceFromRoad { get; set; } /// /// Gets or sets the speed limit information for the nearest road. diff --git a/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs index 691ce62..1ce8e34 100644 --- a/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs +++ b/src/Geo.Trimble/Models/Responses/SpeedLimitInfo.cs @@ -16,19 +16,19 @@ public class SpeedLimitInfo /// Gets or sets the speed limit value. /// [JsonPropertyName("Speed")] - public int Speed { get; set; } + public int? Speed { get; set; } /// /// Gets or sets the speed limit type classification. /// [JsonPropertyName("SpeedType")] - public int SpeedType { get; set; } + public int? SpeedType { get; set; } /// /// Gets or sets the road link identifiers. /// [JsonPropertyName("LinkIds")] - public long LinkIds { get; set; } + public long? LinkIds { get; set; } /// /// Gets or sets the road class designation.