Integrating Azure Search into MVC projects

Kentico does not provide any built-in functionality for searching through Azure Search indexes and displaying results on your website’s pages.

To allow visitors to search your website using Azure Search, you need to develop and maintain custom functionality. This way, you can create a search solution that best matches the implementation of your website and your search requirements. You have full control over the output of your search results and other interface elements, and the flexibility to use the wide range of Azure Search features.

Interact with your Azure Search indexes using the Azure Search .NET SDK (provided via the Microsoft.Azure.Search NuGet package). For typical search solutions, the development process consists of the following general steps:

  • Create interface elements that collect input from users
  • Prepare full-text search queries and apply filters (based on the user input)
  • Search for matching documents in your index
  • Process the response to display search results, faceted navigation, etc.

To get a deeper understanding of how search queries work in Azure Search, refer to the How full text search works in Azure Search article.

Example

The following example demonstrates how to develop a basic search interface that uses an Azure Search index. The example works with the data of coffee products from the Dancing Goat Mvc sample site, and provides the following:

  • An input for search text
  • Faceted navigation for filtering based on coffee farms and regions
  • Search results containing the names and descriptions of matching coffee products

Note: If you wish to run the example in your own MVC project, you need to install the Dancing Goat MVC sample site and connect your project to the given database.

Start by creating an Azure Search index for the store content of the Dancing Goat sample site:

  1. Open the Smart search application.

  2. On the Azure indexes tab, click New index.

  3. Fill in the index properties:

    • Display name: DG store

    • Code name: dg-store

    • Index type: Pages

    • Service namethe name of your Azure Search service

    • Admin keya valid admin key for your Azure Search service

    • Query keya valid query key for your Azure Search service

      To learn more about managing Azure Search services, refer to the Create an Azure Search service in the portal article.

  4. Click Save.

  5. Make sure the index is assigned to the DancingGoatMvc sample site on the Sites tab.

  6. Switch to the Cultures tab, and add at least one culture to the index (for example English - United States).

  7. Open the Indexed content tab and click Add allowed content.

  8. Set the Path value to: /Products/%

  9. Click Save.

    Note: The index includes all product and page types under the website’s /Products section, even though the search interface in this example only works with coffee products. When creating your own search implementations, you can use a single broad index with multiple specialized search components that work with different types of data.

Configure the search field settings required for the scenario:

  1. Open the Modules application in Kentico.

  2. Edit the E-commerce module.

  3. Select the Classes tab, edit the SKU class.

  4. Open the Search tab and click Customize.

  5. Enable the following search field options in the Azure section of the grid:

    • SKUName – Content, Retrievable, Searchable
    • SKUDescription – Content, Searchable
    • SKUShortDescription – Content, Retrievable, Searchable
    • NodeAliasPath – Searchable
  6. Click Save.

  7. Open the Page types application.

  8. Edit the Coffee (MVC) page type.

  9. Select the Search fields tab.

  10. Enable the following search field options in the Azure section of the grid:

    • CoffeeFarm – Content, Searchable, Facetable, Filterable
    • CoffeeCountry – Content, Searchable, Facetable, Filterable
  11. Click Save.

  12. Open the Smart search application and Rebuild the DG store index (on the Azure indexes tab).

Next you need to create the search interface that provides faceted navigation, processes search requests, and displays search results on the MVC front-end.

  1. Open your MVC solution in Visual Studio.

  2. Create a new controller class containing actions that render the search interface and process search requests.

    
    
    
     using System;
     using System.Linq;
     using System.Collections.Generic;
     using System.Text;
     using System.Web.Mvc;
    
     using CMS.Search.Azure;
    
     using Microsoft.Azure.Search;
     using Microsoft.Azure.Search.Models;
    
     using LearningKit.Models.Search.AzureSearch;
    
     namespace LearningKit.Controllers
     {
         public class AzureSearchController : Controller
         {
             private ISearchIndexClient searchIndexClient = InitializeIndex("dg-store");
    
             // The fields used for faceted navigation
             private const string FACET_COFFEE_COUNTRY = "coffeecountry";
    
             private const string FACET_COFFEE_FARM = "coffeefarm";
    
             public readonly List<string> Facets = new List<string>
             {
                 FACET_COFFEE_COUNTRY,
                 FACET_COFFEE_FARM
             };
    
             // Returns an initialized 'SearchServiceClient' instance for the specified index
             private static ISearchIndexClient InitializeIndex(string indexCodeName)
             {
                 // Converts the Kentico index code name to a valid Azure Search index name (if necessary)
                 indexCodeName = NamingHelper.GetValidIndexName(indexCodeName);
    
                 CMS.Search.SearchIndexInfo index = CMS.Search.SearchIndexInfoProvider.GetSearchIndexInfo(indexCodeName);
                 SearchServiceClient client = new SearchServiceClient(index.IndexSearchServiceName, new SearchCredentials(index.IndexQueryKey));
    
                 return client.Indexes.GetClient(indexCodeName);
             }
    
             // Displays a search interface, listing search results from the entire index
             public ActionResult Index()
             {
                 // Prepares a list of filter queries for the search request
                 IList<string> filterQueries = InitializeFilterQueries();
    
                 // Configures the 'SearchParameters' object
                 SearchParameters searchParams = ConfigureSearchParameters(filterQueries);
    
                 // Returns all results on initial page load
                 string searchString = "*";
    
                 // Prepares a view model used to hold the search results and search configuration
                 AzureSearchViewModel model = new AzureSearchViewModel();
    
                 // Performs a search request on the specified Azure Search index with the configured 'SearchParameters' object
                 DocumentSearchResult result = searchIndexClient.Documents.Search(searchString, searchParams);
    
                 // Fills the corresponding view model with facets retrieved from the search query
                 if (result.Facets != null)
                 { 
                     foreach (var facet in result.Facets)
                     {
                         foreach (FacetResult value in facet.Value)
                         {
                             AddFacet(model, value, facet.Key);
                         }
                     }
                 }
    
                 // Fills the view model with search results and displays them in a corresponding view
                 return View("AzureSearch", PrepareSearchResultsViewModel(result, model));
             }
    
             // Processes the submitted search request and displays a list of results
             [HttpPost]
             public ActionResult Search(AzureSearchViewModel searchSettings)
             {
                 // Prepares a list of filter queries for the search request
                 IList<string> filterQueries = InitializeFilterQueries();
    
                 // Adds filter queries based on the selected options in the faceted navigation (coffee farm and region)
                 IEnumerable<FacetViewModel> selectedCountries = searchSettings.FilterCountry.Where(x => x.Selected);
                 IEnumerable<FacetViewModel> selectedFarms = searchSettings.FilterFarm.Where(x => x.Selected);
    
                 if (selectedCountries.Any())
                 {
                     filterQueries.Add(GetFilterQuery(selectedCountries, FACET_COFFEE_COUNTRY));
                 }
                 if (selectedFarms.Any())
                 {
                     filterQueries.Add(GetFilterQuery(selectedFarms, FACET_COFFEE_FARM));
                 }
    
                 // Prepares the search parameters
                 SearchParameters searchParams = ConfigureSearchParameters(filterQueries);
    
                 // Gets the search text from the input
                 string searchString = searchSettings.SearchString;
    
                 // Performs the search request for the specified Azure Search index and parameters
                 DocumentSearchResult result = searchIndexClient.Documents.Search(searchString, searchParams);
    
                 // Fills or updates the faceted navigation options
                 if (result.Facets != null)
                 {
                     foreach (FacetViewModel item in searchSettings.FilterCountry)
                     {
                         UpdateFacets(result.Facets[FACET_COFFEE_COUNTRY], item);
                     }
    
                     foreach (FacetViewModel item in searchSettings.FilterFarm)
                     {
                         UpdateFacets(result.Facets[FACET_COFFEE_FARM], item);
                     }
                 }
    
                 // Displays the search results
                 return View("AzureSearch", PrepareSearchResultsViewModel(result, searchSettings));
             }
    
             // Initializes a list of filter queries
             private static List<string> InitializeFilterQueries()
             {
                 var filterQueries = new List<string>()
                 {
                     // Filters the search results to display only pages (products) of the 'dancinggoat.coffee' type
                     "classname eq 'dancinggoatmvc.coffee'"
                 };
    
                 return filterQueries;
             }
    
             // Configures the 'SearchParameters' object
             private SearchParameters ConfigureSearchParameters(IList<string> filterQueries)
             {
                 var searchParams = new SearchParameters
                 {
                     Facets = Facets,
                     Filter = String.Join(" and ", filterQueries),
                     HighlightPreTag = "<strong>",
                     HighlightPostTag = "</strong>",
                     // All fields used for text highlighting must be configured as 'searchable'
                     HighlightFields = new List<string>
                     {
                         FACET_COFFEE_COUNTRY,
                         FACET_COFFEE_FARM,
                         "nodealiaspath",
                         "skudescription",
                         "skuname"
                     }
                 };
    
                 return searchParams;
             }
    
             // Builds a filter query based on selected faceted navigation options
             private string GetFilterQuery(IEnumerable<FacetViewModel> selectedItems, string column)
             {
                 var queries = selectedItems.Select(item => $"{column} eq '{item.Value.Replace("'", "''")}'");
                 return String.Join(" or ", queries);
             }
    
             // Adds a retrieved 'FacetResult' to the list of facets in the corresponding model
             private void AddFacet(AzureSearchViewModel model, FacetResult facetResult, string resultFacetKey)
             {
                 FacetViewModel item = new FacetViewModel() { Name = $"{facetResult.Value} ({facetResult.Count})", Value = facetResult.Value.ToString() };
                 switch (resultFacetKey)
                 {
                     case FACET_COFFEE_COUNTRY:
                         model.FilterCountry.Add(item);
                         break;
                     case FACET_COFFEE_FARM:
                         model.FilterFarm.Add(item);
                         break;
                     default:
                         break;
                 }
             }
    
             // Updates the counts of matching results for the processed query
             private static void UpdateFacets(IEnumerable<FacetResult> facetResults, FacetViewModel listItem)
             {
                 long? count = 0;
                 foreach (var items in facetResults)
                 {
                     if (items.Value.Equals(listItem.Value))
                     {
                         count = items.Count;
                         break;
                     }
                 }
    
                 listItem.Name = $"{listItem.Value} ({count})";
             }
    
             // Fills a view model with retrieved search results and faceted navigation options
             private AzureSearchViewModel PrepareSearchResultsViewModel(DocumentSearchResult searchResult, AzureSearchViewModel model)
             {
                 if (searchResult.Results.Count == 0)
                 {
                     model.SearchResults = null;
                     return model;
                 }
    
                 foreach (SearchResult result in searchResult.Results)
                 {
                     model.SearchResults.Add(new DocumentViewModel()
                     {
                         DocumentTitle = $"{result.Document["skuname"]}",
                         DocumentShortDescription = $"{result.Document["skushortdescription"]}",
                         Highlights = result.Highlights
                     });
                 }
    
                 return model;
             }
         }
     }
    
    
     
  3. Add new model classes to hold the required data.

    AzureSearchViewModel.cs
    
    
    
     using System.Collections.Generic;
    
     namespace LearningKit.Models.Search.AzureSearch
     {
         // Encapsulates search request data and search results
         public class AzureSearchViewModel
         {        
             public string SearchString { get; set; }
    
             public IList<DocumentViewModel> SearchResults { get; set; }
    
             public IList<FacetViewModel> FilterFarm { get; set; }
    
             public IList<FacetViewModel> FilterCountry { get; set; }
    
             public AzureSearchViewModel()
             {
                 FilterCountry = new List<FacetViewModel>();
                 FilterFarm = new List<FacetViewModel>();
                 SearchResults = new List<DocumentViewModel>();
             }
         }
     }
    
    
     
    DocumentViewModel.cs
    
    
    
     using System.Collections.Generic;
    
     namespace LearningKit.Models.Search.AzureSearch
     {
         // Encapsulates document search results
         public class DocumentViewModel
         {
             public string DocumentTitle { get; set; }
    
             public string DocumentShortDescription { get; set; }
    
             public IDictionary<string, IList<string>> Highlights { get; set; }
         }
     }
    
    
     
    FacetViewModel.cs
    
    
    
     namespace LearningKit.Models.Search.AzureSearch
     {
         // Encapsulates facet data
         public class FacetViewModel
         {
             public string Name { get; set; }
    
             public string Value { get; set; }
    
             public bool Selected { get; set; }
         }
     }
    
    
     
  4. Create a corresponding view to render the search interface and results. 

    
    
    
     @model LearningKit.Models.Search.AzureSearch.AzureSearchViewModel
    
     <h2>Azure Search</h2>
    
     <div>
         @* Renders the search interface *@
         @using (Html.BeginForm("Search", "AzureSearch", FormMethod.Post))
         {
             <div>
                 @Html.TextBoxFor(m => m.SearchString, new { placeholder = "Search..." })
                 <button type="submit" id="search">Search</button>
             </div>
    
             <p><strong>Country</strong></p>
    
             for (int i = 0; i < Model.FilterCountry.Count; i++)
             {
                 @Html.HiddenFor(m => m.FilterCountry[i].Name)
                 @Html.HiddenFor(m => m.FilterCountry[i].Value)
                 @Html.EditorFor(m => m.FilterCountry[i].Selected)
                 @Html.LabelFor(m => m.FilterCountry[i].Selected, Model.FilterCountry[i].Name)
             }
    
             <p><strong>Farm</strong></p>
    
             for (int i = 0; i < Model.FilterFarm.Count; i++)
             {
                 @Html.HiddenFor(m => m.FilterFarm[i].Name)
                 @Html.HiddenFor(m => m.FilterFarm[i].Value)
                 @Html.EditorFor(m => m.FilterFarm[i].Selected)
                 @Html.LabelFor(m => m.FilterFarm[i].Selected, Model.FilterFarm[i].Name)
             }
         }
     </div>
    
     <h2><strong>Search Results</strong></h2>
    
     <div>
         @* Renders the search results *@
         @if (Model.SearchResults == null)
         {
             @Html.Raw($"No results found for {Model.SearchString}.")
         }
         else
         {
             for (int i = 0; i < Model.SearchResults.Count; i++)
             {
                 if (Model.SearchResults[i].Highlights == null)
                 {
                     <h3>@Html.Raw(Model.SearchResults[i].DocumentTitle)</h3>
                     @Html.Raw(Model.SearchResults[i].DocumentShortDescription)
                 }
                 else
                 {
                     string highlightedFragments = String.Join(" || ", Model.SearchResults[i].Highlights.Values.Select(value => String.Join(" ", value)));
                     <h3>@Html.Raw(Model.SearchResults[i].DocumentTitle)</h3>
                     @Html.Raw(highlightedFragments)
                 }
             }
         }
     </div>
    
    
    
     
  5. Build your MVC project.

You have now created a basic search interface that works with the content of the defined Azure Search index.

Adding pagination to search results

You can further extend this example by adding pagination for retrieved search results. Use the Count property of the DocumentSearchResult class to calculate the total number of pages. From there, you can use conventional MVC logic to compute the range of pages to display, the currently accessed page, etc. See this tutorial in Microsoft’s documentation for more information.

Resulting search interface

Logging internal search activities

By default, Kentico on-line marketing features cannot track search actions that occur through custom Azure Search components. This includes logging of the Internal search activity for contacts.

If you wish to use search activity tracking together with Azure Search, you need to manually perform the required logging in your search code.




using System;

using CMS.Core;
using CMS.WebAnalytics;

...

// Performs on-line marketing logging only if search text is specified
if (!String.IsNullOrWhiteSpace(searchString))
{
    // Logs the 'Internal search' activity for the current contact
    IPagesActivityLogger activityLogger = Service.Resolve<IPagesActivityLogger>();
    activityLogger.LogInternalSearch(searchString);
}


Tip: To ensure that the logging only occurs for valid search requests, add the code after you call the ISearchIndexClient.Documents.Search method.