diff --git a/Dfe.Data.Common.Infrastructure.CognitiveSearch/CompositionRoot.cs b/Dfe.Data.Common.Infrastructure.CognitiveSearch/CompositionRoot.cs index 5de42ba..add60a2 100644 --- a/Dfe.Data.Common.Infrastructure.CognitiveSearch/CompositionRoot.cs +++ b/Dfe.Data.Common.Infrastructure.CognitiveSearch/CompositionRoot.cs @@ -50,6 +50,13 @@ public static void AddAzureSearchServices(this IServiceCollection services, ICon services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + + // Register the IOptions object for ISearchRule + services.Configure(configuration.GetSection("SearchRuleOptions")); + // Explicitly register the settings object by delegating to the IOptions object + services.AddSingleton(resolver => + resolver.GetRequiredService>().Value); services.AddOptions() .Configure( @@ -95,14 +102,6 @@ public static void AddAzureGeoLocationSearchServices(this IServiceCollection ser .ValidateDataAnnotations() .ValidateOnStart(); - // Register the IOptions object - services.Configure(configuration.GetSection("SearchRuleOptions")); - // Explicitly register the settings object by delegating to the IOptions object - services.AddSingleton(resolver => - resolver.GetRequiredService>().Value); - - services.AddSingleton(); - services.AddHttpClient("GeoLocationHttpClient", config => { var geoLocationOptions = diff --git a/Dfe.Data.Common.Infrastructure.CognitiveSearch/SearchByKeyword/DefaultSearchByKeywordService.cs b/Dfe.Data.Common.Infrastructure.CognitiveSearch/SearchByKeyword/DefaultSearchByKeywordService.cs index dda8264..f4c3aae 100644 --- a/Dfe.Data.Common.Infrastructure.CognitiveSearch/SearchByKeyword/DefaultSearchByKeywordService.cs +++ b/Dfe.Data.Common.Infrastructure.CognitiveSearch/SearchByKeyword/DefaultSearchByKeywordService.cs @@ -1,9 +1,7 @@ using Azure; using Azure.Search.Documents; using Azure.Search.Documents.Models; -using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options; using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers; -using Microsoft.Extensions.Options; namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword; diff --git a/README.md b/README.md index 059db73..558f4ae 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,138 @@ # infrastructure-cognitive-search -A library to provide an accessible API for working with Azure cognitive search. The package contains a fully configured default service for searching by keyword, as well as a geo-location service which allows searches to be made by town, or post-code. The package is intended to take the heavy-lifting away in terms of setup and configurartion and allow for an easy, pluggable set of components that can be used across projects. +A library to provide an accessible API for working with Azure AI search and Azure maps search. +The package contains a fully configured default service for searching by keyword, as well as a Azure maps search service which allows searches to +be made by town, or post-code. The package is intended to take the heavy-lifting away in terms of setup and configuration and allow for an easy, +pluggable set of components that can be used across projects. ## Getting Started -The dependencies can be used in isolation or registered as a whole under a single composition root. For example, you could opt to use the GeoLocationClientProvider and create your own concrete geo-location service implementation, rather than using the default provided. +The dependencies can be used in isolation or registered as using the extension methods provided via the composition root. +For example, you could opt to use the GeoLocationClientProvider and create your own concrete geo-location service implementation, rather than using the default provided. -### Prerequisites +Search service dependencies can be registered using the extension methods provided: -In order to use the default search services it is possible to register all dependencies listed under the default composition root, in one registration, as follows: +```csharp +builder.Services.AddAzureSearchServices(builder.Configuration); +builder.Services.AddAzureSearchFilterServices(builder.Configuration); +builder.Services.AddAzureGeoLocationSearchServices(builder.Configuration); +``` + +## Execute a search against Azure AI search +```csharp +builder.Services.AddAzureSearchServices(builder.Configuration); +``` +Registers the basic functionality to send a search request to Azure AI Search. +This includes the following services: + +- An implementation of `ISearchByKeywordService` which is the main class to use to submit a search request to Azure AI search +- An implementation of `ISearchByKeywordClientProvider` which is used to create an instance of the search client to connect to Azure AI search. +It requires the following appsettings: +```json +{ + "AzureSearchConnectionOptions": { + "EndpointUri": "https://your-search-service-name.search.windows.net/", + "Credentials": "your-search-service-api-key - served from Azure key vault or other" + } +} +``` +### Basic usage +```csharp +public async Task>> UseSearchService(ISearchByKeywordService searchService, string searchKeyword, string indexName, SearchOptions searchOptions) +{ + return await searchService.SearchAsync(searchKeyword, indexName, searchOptions); +} +``` +where +```search-keyword``` is the keyword to search for, +```index-name``` is the name of the index in Azure AI search to search in and +`searchOptions` is the object of type [SearchOptions](https://learn.microsoft.com/en-us/dotnet/api/azure.search.documents.searchoptions?view=azure-dotnet&devlangs=csharp&f1url=%3FappId%3DDev17IDEF1%26l%3DEN-US%26k%3Dk(Azure.Search.Documents.SearchOptions)%3Bk(DevLang-csharp)%26rd%3Dtrue) +that specifies the search request to be submitted. + +## Add filtering to search + +Filtering a search can be accomplished using only the simple search service explained above and by formatting the ```Filter``` property of the ```SearchOptions``` object. +However, the SearchFilterServices provides additional services to facilitate the construction of the filter expression used by the Azure AI search API. +```csharp +builder.Services.AddAzureSearchFilterServices(builder.Configuration); +``` + +This includes the following services: +- An implementation of ```ISearchFilterExpressionsBuilder``` which co-ordinates the build of the filter expression given the config (explained below) +- Three implementations of ```ISearchFilterExpression``` - ```SearchInFilterExpression```, ```LessThanOrEqualToExpression```, ```SearchGeoLocationFilterExpression``` +- Two implementations of ```ILogicalOperator``` - ```AndLogicalOperator```, ```OrLogicalOperator``` that determine how the filters are combined if more than one filter field is used +These interfaces can be extended with your own custom implementations to add more filter expressions and logical operators as needed. + +To use the filter services, you must configure the filter fields and their settings in appsettings. For example, the following appsettings +show a filter field ```PHASEOFEDUCATION``` specified to use the odata ```Search.in``` expression when filter values are applied to this field. +```json +{ + "FilterKeyToFilterExpressionMapOptions": { + "SearchFilterToExpressionMap": { + "PHASEOFEDUCATION": { + "FilterExpressionKey": "SearchInFilterExpression", + "FilterExpressionValuesDelimiter": "," + } + } + } +} +``` +When a call is made to the ```BuildSearchFilterExpressions``` method of the ISearchFilterExpressionsBuilder, the filter expression is built using the filter expression key and the filter values. +The filter values are split by the ```FilterExpressionValuesDelimiter``` specified in the appsettings. For example, the following code snippet shows how the filter expression is built using the filter values "Primary", "Secondary": +```csharp +_searchOptions.Filter = + _searchFilterExpressionsBuilder.BuildSearchFilterExpressions( + new SearchFilterRequest("PHASEOFEDUCATION", new List(){"Primary", "Secondary"})); +``` +The result is the odata filter expression +```"search.in(PHASEOFEDUCATION, 'Primary,Secondary', ',')"``` +which can be assigned directly to the Azure SearchOptions.Filter property + +When more than one filter field is to be used, the filter expression can include chained filters using any of the +implementations of ```ILogicalOperator``` by adding the ```FilterChainingLogicalOperator``` property to appsettings: +```json +{ + "FilterKeyToFilterExpressionMapOptions": { + "SearchFilterToExpressionMap": { + "FilterChainingLogicalOperator": "AndLogicalOperator" + } + } +} +``` + +## Azure maps search +```csharp +builder.Services.AddAzureGeoLocationSearchServices(builder.Configuration); +``` +Registers the basic functionality to send a location search request to the [Azure maps search address API](https://learn.microsoft.com/en-us/rest/api/maps/search/get-search-address?view=rest-maps-1.0&tabs=HTTP). +It registers the following services: + +- An implementation of `IGeoLocationService` which is the main class to use to submit a search request to the Azure maps search API +- An implementation of `IGeoLocationClientProvider` which is used to create an instance of the search client to connect to Azure maps search. + +It requires the following appsettings: +```json +{ + "AzureGeoLocationOptions": { + "MapsServiceUri": "https://atlas.microsoft.com/", + "SearchEndpointUri": "" + } +} +``` +```SearchEndpointUri``` should be in the format +```search/address/json?api-version=1.0&countrySet=GB&typeahead=true&limit=10&query={0}&subscription-key={1}"``` + +### Basic usage ```csharp -builder.Services.AddDefaultCognitiveSearchServices(builder.Configuration); + GeoLocationServiceResponse? response = + await geoLocationService?.SearchGeoLocationAsync("")!; ``` -Alternatively, the registrations can be configured in the consuming application's IOC container, with a typical registration configured similar to the following: +## Custom registration +Instead of using the extension methods to register the required services, the registrations can be configured manually in the consuming application's IOC container, with a typical registration configured similar to the following: ```csharp services.TryAddSingleton(); @@ -58,55 +175,11 @@ services.AddHttpClient("GeoLocationHttpClient", config => }); ``` -### Code Usage/Examples - -Typical dependency injection and search request would look something like the following, - -```csharp -public sealed class CognitiveSearchServiceAdapter : ISearchServiceAdapter where TSearchResult : class -{ - private readonly ISearchService _cognitiveSearchService; - private readonly ISearchOptionsFactory _searchOptionsFactory; - private readonly IMapper>, EstablishmentResults> _searchResponseMapper; - - public CognitiveSearchServiceAdapter( - ISearchService cognitiveSearchService, - ISearchOptionsFactory searchOptionsFactory, - IMapper>, EstablishmentResults> searchResponseMapper) - { - _searchOptionsFactory = searchOptionsFactory; - _cognitiveSearchService = cognitiveSearchService; - _searchResponseMapper = searchResponseMapper; - } - - public async Task SearchAsync(SearchContext searchContext) - { - SearchOptions searchOptions = - _searchOptionsFactory.GetSearchOptions(searchContext.TargetCollection) ?? - throw new ApplicationException( - $"Search options cannot be derived for {searchContext.TargetCollection}."); - - Response> searchResults = - await _cognitiveSearchService.SearchAsync( - searchContext.SearchKeyword, - searchContext.TargetCollection, - searchOptions - ) - .ConfigureAwait(false) ?? - throw new ApplicationException( - $"Unable to derive search results based on input {searchContext.SearchKeyword}."); - - return _searchResponseMapper.MapFrom(searchResults); - } -} -``` - ## Built With * [.Net 8](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-8/overview) - Core framework used * [Azure](https://learn.microsoft.com/en-us/azure/search/) - Cloud services provider (cognitive search) - ## Versioning We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/DFE-Digital/infrastructure-cognitive-search/tags). @@ -136,12 +209,6 @@ You can use the Nuget Registry from a GitHub action pipeline without need for a - name: Add nuget package source run: dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json" ``` -## Authors - -* **Spencer O'Hegarty** -* **Catherine Lawlor** -* **Asia Witek** -* **Roger Howell** ## License