aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
blob: 86386e8a62b4c83833ff77bc9c8fe8446854267f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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)
    {
        using var activity = Telemetry.Source.StartActivity("GeoapifyAutocomplete");
        activity?.SetTag("query", query);

        if (string.IsNullOrWhiteSpace(query))
        {
            return [];
        }

        var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}";
        var cacheHit = _cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults);
        activity?.SetTag("cache.hit", cacheHit);

        if (cacheHit && 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() ?? [];

            activity?.SetTag("results.count", results.Count);
            _cache.Set(cacheKey, results, TimeSpan.FromMinutes(60));
            return results;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
            _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url);
            return new List<PlannerSearchResult>();
        }
    }

    public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
    {
        using var activity = Telemetry.Source.StartActivity("GeoapifyReverseGeocode");
        activity?.SetTag("lat", lat);
        activity?.SetTag("lon", lon);

        var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}";
        var cacheHit = _cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult);
        activity?.SetTag("cache.hit", cacheHit);

        if (cacheHit && 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)
        {
            activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
            _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
        };
    }
}