diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-02 01:08:41 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-02 01:08:41 +0100 |
| commit | a3eb2d0441ae18f75604a4bee64db18391469837 (patch) | |
| tree | 8994c1987afdd9436ba0699236439d3eb6c3f04d /src/Enmarcha.Backend/Services/Geocoding | |
| parent | dd544d713a2af4713c61ae0d2050f2861cc0892a (diff) | |
feat: Integrate Geoapify geocoding service and update configuration
Diffstat (limited to 'src/Enmarcha.Backend/Services/Geocoding')
3 files changed, 220 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs new file mode 100644 index 0000000..d6cf5f6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types.Geoapify; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Geocoding; + +public class GeoapifyGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger<GeoapifyGeocodingService> _logger; + private readonly AppConfiguration _config; + + private static readonly string[] ForbiddenResultTypes = ["city", "state", "county", "postcode"]; + + public GeoapifyGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<GeoapifyGeocodingService> logger, IOptions<AppConfiguration> config) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _config = config.Value; + + // Geoapify requires a User-Agent + if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) + { + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + } + } + + public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null) + { + return cachedResults; + } + + var url = $"https://api.geoapify.com/v1/geocode/autocomplete?text={Uri.EscapeDataString(query)}&lang=gl&limit=5&filter=rect:-9.449497230816405,41.89720361654395,-6.581039728137625,43.92616367306067&format=json"; + + try + { + var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}"); + + + var results = response?.results + .Where(x => !ForbiddenResultTypes.Contains(x.result_type)) + .Select(MapToPlannerSearchResult) + .ToList() ?? []; + + _cache.Set(cacheKey, results, TimeSpan.FromMinutes(60)); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url); + return new List<PlannerSearchResult>(); + } + } + + public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon) + { + var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}"; + if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null) + { + return cachedResult; + } + + var url = + $"https://api.geoapify.com/v1/geocode/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=gl&format=json"; + try + { + var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}"); + + if (response == null) return null; + + var result = MapToPlannerSearchResult(response.results[0]); + + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify reverse geocode results from {Url}", url); + return null; + } + } + + private PlannerSearchResult MapToPlannerSearchResult(Result result) + { + var name = result.name ?? result.address_line1; + var label = $"{result.street} ({result.postcode} {result.city}, {result.county})"; + + return new PlannerSearchResult + { + Name = name, + Label = label, + Lat = result.lat, + Lon = result.lon, + Layer = result.result_type + }; + } +} diff --git a/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs new file mode 100644 index 0000000..8619b0a --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs @@ -0,0 +1,9 @@ +using Enmarcha.Backend.Types.Planner; + +namespace Enmarcha.Backend.Services.Geocoding; + +public interface IGeocodingService +{ + Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query); + Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon); +} diff --git a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs new file mode 100644 index 0000000..c38b1e6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types.Nominatim; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Geocoding; + +public class NominatimGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger<NominatimGeocodingService> _logger; + private readonly AppConfiguration _config; + + private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; + + public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<NominatimGeocodingService> logger, IOptions<AppConfiguration> config) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _config = config.Value; + + // Nominatim requires a User-Agent + if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) + { + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + } + } + + public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>(); + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null) + { + return cachedResults; + } + + try + { + var url = $"{_config.NominatimBaseUrl}/search?q={Uri.EscapeDataString(query)}&format=jsonv2&viewbox={GaliciaBounds}&bounded=1&countrycodes=es&addressdetails=1"; + var response = await _httpClient.GetFromJsonAsync<List<NominatimSearchResult>>(url); + + var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List<PlannerSearchResult>(); + + _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30)); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Nominatim autocomplete results from {Url}", _config.NominatimBaseUrl); + return new List<PlannerSearchResult>(); + } + } + + public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon) + { + var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}"; + if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null) + { + return cachedResult; + } + + try + { + var url = $"{_config.NominatimBaseUrl}/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&format=jsonv2&addressdetails=1"; + var response = await _httpClient.GetFromJsonAsync<NominatimSearchResult>(url); + + if (response == null) return null; + + var result = MapToPlannerSearchResult(response); + + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Nominatim reverse geocode results from {Url}", _config.NominatimBaseUrl); + return null; + } + } + + private PlannerSearchResult MapToPlannerSearchResult(NominatimSearchResult result) + { + var name = result.Address?.Road ?? result.DisplayName?.Split(',').FirstOrDefault(); + var label = result.DisplayName; + + return new PlannerSearchResult + { + Name = name, + Label = label, + Lat = double.TryParse(result.Lat, CultureInfo.InvariantCulture, out var lat) ? lat : 0, + Lon = double.TryParse(result.Lon, CultureInfo.InvariantCulture, out var lon) ? lon : 0, + Layer = result.Type + }; + } +} |
