SomniaGuestLog - Address search/query using Nominatim - Initial implementation

Initial implementation for address search/query using external APIs (OSM Nominatim) & Added required classes for supporting more APIs in the future.
This commit is contained in:
2026-08-07 22:08:25 +02:00
parent 2335c223ea
commit 3fe558c231
12 changed files with 322 additions and 5 deletions
@@ -0,0 +1,65 @@
@page "/debug/addrSearch"
@using Somnia.GuestLog.Models.DTOs;
@using Somnia.GuestLog.Services.AddressQuery;
@inject ILogger<AddressQuery> logger;
@inject IAddressQueryFactory addressQueryFactory;
@inject ISnackbar Snackbar
<PageTitle>DEBUG - Address Query</PageTitle>
<MudText Typo="Typo.h3" GutterBottom="true">Address Search</MudText>
<MudTextField @ref="txtSearch" @bind-Value="SearchText" Label="Search address" Required />
<MudButton Color="Color.Primary" Variant="Variant.Filled" @onclick="btnSearchAddress">Search</MudButton>
<MudPaper>
@if (addressResults.Count == 0)
{
<MudText Typo="Typo.body1">No results!</MudText>
}
@foreach (AddressResultDTO result in addressResults)
{
<MudItem>
<MudStack>
<MudText>Address: @result.AddressRoad , @result.HouseNumber</MudText>
<MudText>City: @result.AddressCity</MudText>
<MudText>Province: @result.AddressProvince</MudText>
<MudText>State: @result.AddressState</MudText>
<MudText>Country: @result.AddressCountry</MudText>
<MudText>ZipCode: @result.ZipCode</MudText>
</MudStack>
</MudItem>
}
</MudPaper>
@code {
public string SearchText { get; set; } = "";
private List<AddressResultDTO> addressResults = [];
private MudTextField<string> txtSearch = null!;
private async Task btnSearchAddress()
{
if (SearchText.Length == 0)
{
return;
}
try
{
var queryService = addressQueryFactory.GetService();
addressResults = await queryService.SearchAddressAsync(SearchText);
}
catch (Exception ex)
{
logger.LogError(ex, "SearchAddress returned an error!");
Snackbar.Add($"Error searching address: {ex.Message}", Severity.Error);
}
}
}
@@ -0,0 +1,21 @@
namespace Somnia.GuestLog.Models.Configuration;
public class AddressQueryConfig
{
public const string SectionName = "AddressQuery";
public bool EnableAddressQuery { get; set; }
public AddressQueryProviderEnum? Provider { get; set; }
public string? ServiceURL { get; set; }
public string? ApiKey { get; set; }
public string? UserAgent { get; set; }
public enum AddressQueryProviderEnum
{
Nominatim,
GoogleApi
}
}
@@ -0,0 +1,12 @@
namespace Somnia.GuestLog.Models.DTOs;
public class AddressResultDTO
{
public string? HouseNumber { get; set; }
public string? AddressRoad { get; set; }
public string? AddressCity { get; set; }
public string? AddressProvince { get; set; }
public string? AddressState { get; set; }
public string? AddressCountry { get; set; }
public string? ZipCode { get; set; }
}
+13 -2
View File
@@ -4,8 +4,11 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using Somnia.GuestLog.Components; using Somnia.GuestLog.Components;
using Somnia.GuestLog.Infrastructure; using Somnia.GuestLog.Infrastructure;
using Somnia.GuestLog.Models.Configuration;
using Somnia.GuestLog.Models.DTOs; using Somnia.GuestLog.Models.DTOs;
using Somnia.GuestLog.Services; using Somnia.GuestLog.Services;
using Somnia.GuestLog.Services.AddressQuery;
using Somnia.GuestLog.Utils;
using Somnia.GuestLog.Validators; using Somnia.GuestLog.Validators;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -22,10 +25,13 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
// Add MudBlazor services // Add MudBlazor services
builder.Services.AddMudServices(); builder.Services.AddMudServices();
// Add services to the container. // Add components services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddLocalization();
builder.Services.AddHttpClient();
// Add Validation Services // Add Validation Services
builder.Services.AddSingleton<IValidator<GuestDocumentDTO>, GuestDocumentDTOValidator>(); builder.Services.AddSingleton<IValidator<GuestDocumentDTO>, GuestDocumentDTOValidator>();
builder.Services.AddSingleton<IValidator<GuestDocIdDTO>, GuestDocIdDTOValidator>(); builder.Services.AddSingleton<IValidator<GuestDocIdDTO>, GuestDocIdDTOValidator>();
@@ -34,7 +40,12 @@ builder.Services.AddSingleton<IValidator<GuestDocIdDTO>, GuestDocIdDTOValidator>
builder.Services.AddSingleton<IGuestDocumentService, GuestDocumentService>(); builder.Services.AddSingleton<IGuestDocumentService, GuestDocumentService>();
builder.Services.AddSingleton<IDataListService, StaticDataListService>(); builder.Services.AddSingleton<IDataListService, StaticDataListService>();
builder.Services.AddLocalization(); // Add Factories (and it's services)
builder.Services.AddFactoryDI<IAddressQueryFactory, ConfigFileAddressQueryFactory>();
// Add Options config
builder.Services.AddOptions<AddressQueryConfig>()
.Bind(builder.Configuration.GetSection(AddressQueryConfig.SectionName));
var app = builder.Build(); var app = builder.Build();
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Options;
using Somnia.GuestLog.Models.Configuration;
using Somnia.GuestLog.Utils.DI;
namespace Somnia.GuestLog.Services.AddressQuery;
public class ConfigFileAddressQueryFactory(
IServiceProvider serviceProvider,
IOptions<AddressQueryConfig> config
) : IAddressQueryFactory, IFactoryDI
{
public static void RegisterFactoryItems(IServiceCollection services)
{
services.AddKeyedTransient<IAddressQueryService, NominatimAddressQueryService>(AddressQueryConfig.AddressQueryProviderEnum.Nominatim);
}
public IAddressQueryService GetService()
{
if (config.Value.EnableAddressQuery == false)
{
throw new InvalidOperationException("AddressQuery is disabled. Enable and configure it first!");
}
return serviceProvider.GetKeyedService<IAddressQueryService>(config.Value.Provider)
?? throw new InvalidOperationException($"AddressQueryService not found for provider: '{config.Value.Provider}'");
}
}
@@ -0,0 +1,9 @@
namespace Somnia.GuestLog.Services.AddressQuery;
public interface IAddressQueryFactory
{
/// <summary>
/// Returns the AddressQuery service implementation given current configuration.
/// </summary>
public IAddressQueryService GetService();
}
@@ -0,0 +1,19 @@
using Somnia.GuestLog.Models.DTOs;
namespace Somnia.GuestLog.Services.AddressQuery;
public interface IAddressQueryService
{
/// <summary>
/// Search an address by plain text query.
/// Data is returned as a structured address.
/// This service allow to complete and validate addresses.
///
/// Note: Not all fields are always available for a given query.
/// </summary>
/// <param name="query">Address to search</param>
/// <returns>List of resulting structured addresses</returns>
public Task<List<AddressResultDTO>> SearchAddressAsync(string query);
}
@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using Somnia.GuestLog.Models.Configuration;
using Somnia.GuestLog.Models.DTOs;
namespace Somnia.GuestLog.Services.AddressQuery;
public class NominatimAddressQueryService(
IHttpClientFactory httpClientFactory,
ILogger<NominatimAddressQueryService> logger,
IOptions<AddressQueryConfig> configOptions
) : IAddressQueryService
{
private const string defaultBaseAddr = "https://nominatim.openstreetmap.org";
private const string defaultUserAgent = "Somnia-GuestLog";
private const string apiUri = "/search";
private readonly AddressQueryConfig config = configOptions.Value;
public async Task<List<AddressResultDTO>> SearchAddressAsync(string query)
{
var http = GetHttpClient();
List<AddressResultDTO> rsList = [];
try
{
string uri = GetRequestUri(query);
List<ApiResult> apiList = (await http.GetFromJsonAsync<List<ApiResult>>(uri)) ?? [];
foreach (ApiResult result in apiList)
{
if (result.Address is null)
{
logger.LogWarning("Skipping result without address details. '{result}'", result);
continue;
}
rsList.Add(new()
{
HouseNumber = result.Address.House_number,
AddressRoad = result.Address.Road ?? result.Address.Square,
AddressCity = result.Address.City ?? result.Address.Municipality ?? result.Address.Village ?? result.Address.Village,
AddressProvince = result.Address.Province,
AddressState = result.Address.State,
AddressCountry = result.Address.Country,
ZipCode = result.Address.Postcode
});
}
return rsList;
}
catch (System.Exception ex)
{
logger.LogError(ex, "Error getting info from API!");
return [];
}
}
private HttpClient GetHttpClient()
{
HttpClient client = httpClientFactory.CreateClient();
Uri baseAddr = new(config.ServiceURL ?? defaultBaseAddr);
client.BaseAddress = baseAddr;
client.DefaultRequestHeaders.UserAgent.ParseAdd(config.UserAgent ?? defaultUserAgent);
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("es");
return client;
}
private static string GetRequestUri(string query)
{
return QueryHelpers.AddQueryString(apiUri, [
new KeyValuePair<string, string?>("format", "jsonv2"),
new KeyValuePair<string, string?>("addressdetails", "1"),
new KeyValuePair<string, string?>("layer", "address"),
new KeyValuePair<string, string?>("q", query),
]);
}
private record class ApiResultAddress(
string? House_number = null,
string? Road = null,
string? Square = null,
string? City = null,
string? Municipality = null,
string? Village = null,
string? Town = null,
string? Province = null,
string? State = null,
string? Country = null,
string? Postcode = null
);
private record class ApiResult(
string? Osm_type = null,
long? Osm_id = null,
string? Category = null,
string? AddressType = null,
string? Name = null,
string? Display_name = null,
ApiResultAddress? Address = null
);
}
@@ -13,6 +13,4 @@ public interface IGuestDocumentService
public Task<IEnumerable<GuestDocumentDTO>> GetDtoListByFilterAsync(GuestDocFilterDTO filter, int limit = 0); public Task<IEnumerable<GuestDocumentDTO>> GetDtoListByFilterAsync(GuestDocFilterDTO filter, int limit = 0);
public Task<IEnumerable<GuestDocumentDTO>> GetDtoListAsync(); public Task<IEnumerable<GuestDocumentDTO>> GetDtoListAsync();
//TODO: Add Filter
} }
@@ -0,0 +1,15 @@
namespace Somnia.GuestLog.Utils.DI;
/// <summary>
/// Interface for Factories that need to register DI services.
/// <br/><see cref="ServiceCollectionExtensions.AddFactoryDI{TFactory, TFactoryImpl}(IServiceCollection)"/>
/// </summary>
public interface IFactoryDI
{
/// <summary>
/// Register required services in the ServiceCollection.
/// This method is automaticaly call when the factory is register.
/// </summary>
/// <param name="services"></param>
public abstract static void RegisterFactoryItems(IServiceCollection services);
}
@@ -0,0 +1,24 @@
using Somnia.GuestLog.Utils.DI;
namespace Somnia.GuestLog.Utils;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Helper for registering factories using IFactoryDI interface.
/// Those factories register the required services directly to the service collection.
/// This helper ensures all required methods are called.
/// </summary>
/// <typeparam name="TFactory">Factory Interface</typeparam>
/// <typeparam name="TFactoryImpl">Factory Implementation</typeparam>
public static IServiceCollection AddFactoryDI<TFactory, TFactoryImpl>(this IServiceCollection services)
where TFactory : class
where TFactoryImpl : class, TFactory, IFactoryDI
{
services.AddSingleton<TFactory, TFactoryImpl>();
TFactoryImpl.RegisterFactoryItems(services);
return services;
}
}
+8 -1
View File
@@ -8,5 +8,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"AddressQuery": {
"EnableAddressQuery": true,
"Provider": "Nominatim",
"ServiceURL": "https://nominatim.openstreetmap.org/",
"ApiKey": "",
"UserAgent": "Somnia-GuestLog-Dev"
}
} }