A way of consolidating EPiServer Find Unified Search over multiple websites

At my current client’s we are building a common platform containing shared code between several EPiServer multisite installations. Due to reasons the way we implemented searches with EPiServer Find had diverged to a point where it was necessary to take a step back and refactor it. This is what we came up with.

For an implementation of the specific search class, please see article Example: Pluggable EPiServer Find UnifiedSearch for selected types. Also see Ajax support for the pluggable EPiServer Find UnifiedSearch implementation.

Pluggable EPiServer Find Unified Search

The main concern for us was that we wanted to keep code that was common for all websites in a single place (in our shared platform), while easily being able to tweak the searches for various websites or even types of searches within the same websites.

The UnifiedSearch SearchService

The main code is located in the SearchService class, which have only one method – SearchFor. It takes a request as well as a SpecificSearchBase object which we will get back to in more detail in a bit. In short, it contains information on how to customize the generic search.

The ambition was to keep this as clean as possible, so there are a couple of extension methods making a fluent design possible. The first thing that happens is that we create an object for keeping all the input parameters for the search – SearchParameters.

This creation first adds important data from the submitted request, then from EPiServer Find’s settings (for us it’s just regarding supported language), before complementing with the custom search specific data.

Please note that conventions are added during initialization, more on this later.

public class SearchService : ISearchService
{
  private readonly IClient _searchClient;

  public SearchService(IClient searchClient)
  {
    _searchClient = searchClient ?? throw new ArgumentNullException(nameof(searchClient));
  }

  public SearchResult SearchFor(HttpRequestBase request, SpecificSearchBase specificSearch)
  {
    SearchParameters parameters = new SearchParameters()
      .AddFrom(request)
      .AddFrom(_searchClient.Settings)
      .AddFrom(specificSearch);

    IQueriedSearch<ISearchContent, QueryStringQuery> query = _searchClient
      .UnifiedSearch()
      .For(parameters.Query)
      .WithAndAsDefaultOperator()
      .UsingSynonyms()
      .SetBatch(parameters.SearchBatch)
      .AddQuerySpecifics(specificSearch, parameters)
      .OrderBy(parameters.FilterSortOrder)
      .ApplyBestBets();

    if (parameters.IsTracked)
    {
      query = query.Track();
    }

    HitSpecification specification = specificSearch.HitSpecification();
    UnifiedSearchResults result = query.GetResult(specification);

    SearchResult searchResult = new SearchResult()
      .AddGenericMetaFrom(result, parameters)
      .AddSpecificMetaFrom(result, parameters, specificSearch)
      .AddSearchHits(result, specificSearch);

    return searchResult;
  }
}

Next, we build the search query in the same fashion. Since UnifiedSearch appends FilterForVisitor, FilterOnCurrentSite and filters on language branch by itself, we do not need to consider this.

The query is constructed mostly using data from the parameter object. Generic query information like batches, synonyms, operators and so on are added. The specific search object is also used to add any additional conditions specific to each search.

After tracking is added, there is a possibility to inject custom hit specifications before we get the result.

Lastly, the search result object is constructed using both generic and specific meta information before applying specific search hit mappings to the search hits.

Extending the fluent design and adding from the generic platform

The fluent design is created using extension methods.

Search parameters extensions

The search parameter extensions all have the same method names, but vary on input parameters. From the request we can both get information on things like EPiServer Find’s tracking functionality as well as search query, batch information and so on.

public static SearchParameters AddFrom(this SearchParameters parameters, HttpRequestBase request)
{
  return parameters
    .TryAddTracking(request)
    .TryAddQueryString(request);
}

private static SearchParameters TryAddTracking(this SearchParameters parameters, HttpRequestBase request)
{
  string doNotTrackHeader = request?.Headers?.Get("DNT");
  parameters.IsTracked = doNotTrackHeader == null || doNotTrackHeader == "0";
  return parameters;
}

private static SearchParameters TryAddQueryString(this SearchParameters parameters, HttpRequestBase request)
{
  NameValueCollection queryCollection = request?.Unvalidated?.QueryString;
  if (queryCollection == null)
  {
    return parameters;
  }

  ISearchQueryService service = ServiceLocator.Current.GetInstance<ISearchQueryService>();

  int batch = service.GetBatchFor(queryCollection);
  int page = service.GetPageFor(queryCollection);
  parameters.SearchBatch = new SearchBatch(batch, page);
  parameters.Query = service.GetQueryFor(queryCollection);
  parameters.FilterSortOrder = service.GetSortOrderFor(queryCollection);

  return parameters;
}

From the EPiServer Find settings we add information about supported language.

public static SearchParameters AddFrom(this SearchParameters parameters, EPiServer.Find.Api.Settings findSettings)
{
  parameters.Language = findSettings.Languages .GetSupportedLanguage(CultureInfo.CurrentCulture) ?? Language.None;
  return parameters;
}

And at last, we add any parameters specified in the specific search object.

public static SearchParameters AddFrom(this SearchParameters parameters, SpecificSearchBase siteSpecificSearch)
{
  return siteSpecificSearch.AddSearchParameters(parameters);
}

IQueriedSearch extensions for creating the search query

The only thing we really need to do here is add search query information specified in the specific search object, as well as adding information about the search batch (and whatever other extensions might be needed).

public static IQueriedSearch<ISearchContent, QueryStringQuery> AddQuerySpecifics(this IQueriedSearch<ISearchContent, QueryStringQuery> search, SpecificSearchBase specific, SearchParameters parameters)
{
  return specific.AddQuerySpecifics(search, parameters);
}

public static IQueriedSearch<ISearchContent, QueryStringQuery> SetBatch(this IQueriedSearch<ISearchContent, QueryStringQuery> search, SearchBatch searchBatch)
{
  if (searchBatch == null)
  {
    return search;
  }

  return search.Skip(searchBatch.Skip).Take(searchBatch.Take);
}

// ...

The batch object is just a helper object containing batch data and convenient methods for managing search batches.

Extensions for search result metadata and hit mappings

In the extensions relating to the search result object (which is just the object used for transmitting the entire search result including metadata), we add generic things like total matching hits, integers specifying next page, query suggestion texts etc.

public static SearchResult AddGenericMetaFrom(this SearchResult result, UnifiedSearchResults findResult, SearchParameters parameters)
{
  ISearchResultService resultService = ServiceLocator.Current.GetInstance<ISearchResultService>();
  ISearchBatchService batchService = ServiceLocator.Current.GetInstance<ISearchBatchService>();
  IQuerySuggestionService _querySuggestionService = ServiceLocator.Current.GetInstance<IQuerySuggestionService>();

  result.TotalHits = findResult.TotalMatching;
  result.NextPage = parameters.SearchBatch.NextPage(findResult.TotalMatching);
  result.Page = parameters.SearchBatch.Page;
  result.BatchSize = parameters.SearchBatch.Take;
  result.SortOrder = parameters.FilterSortOrder.ToString();
  result.SuggestionText = _querySuggestionService.SuggestionTextFor(parameters.Query, result.TotalHits, parameters.Themes, parameters.Categories);

  return result;
}

As in the previous sections, the search specific alterations are applied, and the search hits are mapped according to specifications.

public static SearchResult AddSpecificMetaFrom(this SearchResult result, UnifiedSearchResults findResult, SearchParameters parameters, SpecificSearchBase specific)
{
  return specific.AddResultMetaSpecifics(result, findResult, parameters);
}

public static SearchResult AddSearchHits(this SearchResult result, UnifiedSearchResults findResult, SpecificSearchBase specific)
{
  result.SearchHits = findResult.Select(hit => specific.MapSearchHit.Invoke(hit));
  return result;
}

Adding search specific tweaks to the UnifiedSearch

The alterations to the search that are supplied via the search specific object are based on a base class seen below. If you do not add any of your own tweaks by overriding the virtual methods, you can use the base class directly.

Most of these base methods just pass the object back without applying any alterations.

public class SpecificSearchBase
{        
  public virtual SearchParameters AddSearchParameters(SearchParameters parameters)
  {
    return parameters;
  }

  public virtual IQueriedSearch<ISearchContent, QueryStringQuery> AddQuerySpecifics(IQueriedSearch<ISearchContent, QueryStringQuery> search, SearchParameters parameters)
  {
    return search;
  }

  public virtual SearchResult AddResultMetaSpecifics(SearchResult returnResult, UnifiedSearchResults findResult, SearchParameters parameters)
  {
    return returnResult;
  }

  public virtual HitSpecification HitSpecification()
  {
    return new HitSpecification
      {
        HighlightExcerpt = true,
        ExcerptHighlightSpecAction = spec => new HighlightSpec { FragmentSize = 150, NumberOfFragments = 1, }
      };
  }

  public virtual Func<UnifiedSearchHit, SearchHitItem> MapSearchHit
  {
    get
    {
      return hit => new SearchHitItem
        {
          Heading = hit.Title,
          Text = hit.Excerpt,
          Url = hit.Url,
        };
    }
  }
}

Some of them, like HitSpecification and the search hit mapping method just supply default implementations.

Using the pluggable EPiServer Find implementation

Creating a default search base on this may be done like below.

HttpRequest request = HttpContext.Current.Request;
HttpRequestBase requestBase = new HttpRequestWrapper(request);
SpecificSearchBase specifics = new SpecificSearchBase();
SearchResult result = _searchService.SearchFor(requestBase, specifics);