Searching Content in Kentico Kontent with Algolia

Searching Content in Kentico Kontent with Algolia

By Tomas Nosek, Martin DobsicekFeb 26, 2020

Search is an important part of websites as it helps your visitors find their desired content quickly. With Kentico Kontent, you can easily integrate external search engines so you don’t need to implement a complicated search algorithm yourself.

In this blog post, we’re going to walk you through a sample integration of the Kentico Kontent with Algolia.

We’re going to develop the functionality on the .NET Core platform using the MVC approach, and using the C# programming language for the back end and JavaScript for the front end. It will search through the content stored in Kentico Kontent. In this case, we’ll use blog posts as the content type to be searched in code examples, but the code can be easily modified for any content type of your choice.

Getting Started with Algolia

The first step in the process is to create an account with Algolia. Once registered, you can log in and find the API keys in your profile. There are three basic pieces of information that you need to use when calling Algolia’s API:

  • Application ID – to identify the application we want to work with
  • Search-Only API Key – the API key usable for search queries (can be visible publicly so it can be used in the front end)
  • Admin API Key – for managing indexes (needs to be kept private so it’s available in the back end only)

We would also recommend going through Algolia’s documentation to get familiar with Algolia’s concepts, API, and prepared UI widgets.

Now there are three basic steps to implementing the code:

  • Index current data
  • Ensure indexing when data changes (using a webhook)
  • Search through the index and display results (when web visitors submit a query)

Index Current Data

Data should be indexed from the back end since indexing uses the private Admin API Key. We’ll use the Algolia.Search NuGet package, to ensure smooth working with Algolia’s API, and the Kentico Kontent Delivery .NET SDK, to acquire the blog post data from the Kentico Kontent app.

Firstly, we prepare a model called BlogPostSearchModel in which we store the data of the blog posts. The model needn’t only contain texts to be indexed—we can add various other data to the Algolia index for use later on in displaying results. By doing this, you can save time and don’t have to first obtain the data from the Kentico Kontent application. For our example, we’ll add the URLSlug field to be able to create links to blog posts.

public class BlogPostSearchModel 
{
	public string ID { get; set; }
	public string UrlSlug { get; set; }
	public string Title { get; set; }
	public string Body { get; set; }
	public string Topic { get; set; }
	public string Author { get; set; }
}

Then, we prepare the method for creating BlogPostSearchModel objects from data returned by the Delivery API as an object of the ContentItem type.

private static BlogPostSearchModel GetBlogPost(ContentItem item)
{
	if (item == null)
	{
		return new BlogPostSearchModel();
	}
	return new BlogPostSearchModel
	{
		ID = item.System.Id,
		UrlSlug = item.GetString("url_slug"),
		Title = item.GetString("title"),
		Body = item.GetString("body"),
		Topic = item.GetOptions("topic").FirstOrDefault()?.Name,
		Author = item.GetModularContent("author").FirstOrDefault()?.GetString("name")
	};
}

After that, we create a method that acquires all blog posts using the Delivery API and prepares a list of the BlogPostSearchModel instances leveraging the method above. We use ProjectID to identify the project from which to get blog posts.

private static async Task<IEnumerable<BlogPostSearchModel>> GetBlogPosts()
{
	var parameters = new List<IQueryParameter>
	{
		new EqualsFilter("system.type", "blog_post"),
		new OrderParameter("elements.date", SortOrder.Descending)
	};

	DeliveryClient client = new DeliveryClient("<yourDeliveryApiProjectID>");
	var response = await client.GetItemsAsync(parameters);

	return response.Items.Select(GetBlogPost);
}

This is a basic approach to acquiring data from Kentico Kontent, which we’ve chosen for demonstration purposes. For production code, we would suggest using a strongly-typed-models approach instead, as it is the recommended way of receiving content via the Delivery API.

We’ll also need a method that will sanitize the content of rich text fields, i.e., remove HTML tags and escape any special characters that might cause an error during indexing.

private static string SanitizeRichText(string inputString)
{
	// Remove HTML tags
	inputString = Regex.Replace(inputString, "<.*?>", string.Empty);

	// Decode/Escape special characters
	return Regex.Replace(inputString, "&nbsp;", " ").Replace("\"", " ").Replace("\\", "\\\\");
}

Now we can implement a method for indexing the given blog posts in Algolia’s indexto be used both for initial indexing and future data indexing.

public static void IndexBlogPostsInAlgolia(IEnumerable<BlogPostSearchModel> blogPostsToIndex, bool isInitialIndexing)
{
	AlgoliaClient algoliaClient = new AlgoliaClient("<yourAlgoliaApplicationID>", "<yourAlgoliaAdminApiKey>");

	var objs = new List<JObject>();
	foreach (var blogPost in blogPostsToIndex)
	{
		objs.Add(JObject.Parse(
				$@"{{
			""objectID"":""{blogPost.ID}"",
			""urlSlug"":""{blogPost.UrlSlug}"",
			""title"":""{blogPost.Title}"",
			""body"":""{SanitizeRichText(blogPost.Body)}"",
			""topic"":""{blogPost.Topic}"",
			""author"":""{blogPost.Author}""
			}}"));
	}
	
	var algoliaIndex = algoliaClient.InitIndex("<yourIndexName>");

	if (isInitialIndexing)
	{
		algoliaIndex.SetSettings(JObject.Parse(@"{""searchableAttributes"":[""title"", ""body"", ""topic"", ""author""]}"));
	}

	var res = algoliaIndex.AddObjects(objs);
}

Finally, we can leverage these methods to acquire our current blog posts and index them in Algolia.

var blogPosts = await GetBlogPosts();
IndexBlogPostsInAlgolia(blogPosts, true);

Ensure Indexing When Data Changes

Kentico Kontent supports webhooks, which can notify your system whenever you publish new content or edit published content. To enable this, you need to set up a webhook as described in the documentation and implement an MVC action method that will handle the message from the webhook to the system.

This method will handle only HttpPost requests on the particular route specified in the “URL address” field in our webhook setting. (For example, if we use the “webhook” route, the URL should be “https://ourdomain/webhook”.) We should also check that the message wasn’t modified on its way. The signature, sent along with the message, contains a base64 encoded hash of the message. So, first, you need to prepare a method that will compute the expected signature.

protected string ComputeExpectedSignature(string message)
{
	const string secret = "<hereYouShouldPlaceSecretFromWebhook'sSetting>";
	var messageBytes = Encoding.UTF8.GetBytes(message);
	var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
	return Convert.ToBase64String(hmac.ComputeHash(messageBytes));
}

Then, we can implement the action method that will handle the webhook messages:

[HttpPost]
[Route("webhook")]
public async Task<IActionResult> ProcessWebhookMessage([FromHeader(Name = "X-KC-Signature")]string signature)
{
	const string typeToProcess = "blog_post";

	if (Request.Body.CanSeek)
	{
		Request.Body.Position = 0;
	}
	var contentString = new StreamReader(Request.Body).ReadToEnd();
	if (signature.Equals(ComputeExpectedSignature(contentString)))
	{
		var message = JsonConvert.DeserializeObject<WebhookMessage>(contentString); 
		var codeNames = message.Data.Items
							.Where(item => typeToProcess.Equals(item.Type))
							.Select(item => item.Codename);
		if (codeNames.Any())
		{
			try
			{
				var parameters = new List<IQueryParameter>
				{
					new InFilter("system.codename", codeNames.ToArray()),
					new EqualsFilter("system.type", type)
				};
				var response = await mService.WebhooksClient.GetItemsAsync(parameters);
				var blogPosts = response.Items.Select(GetBlogPost);
				IndexBlogPostsInAlgolia(blogPosts, false);
			}
			catch (AggregateException e)
			{
				// Log exception
			}
		}
	}
	return Ok();
}

Search and Display Results

We’ll be performing a search on the front-end side so that we can then call Algolia’s servers and receive a response directly without calling back to our servers. This approach should ensure much better performance. We can also leverage existing Algolia widgets for easier implementation of the UI. So, how will we build our UI?

First of all, we need to prepare divs in the index.html that will act as containers for an input field into which users can enter their search queries and other UI parts like paging, statistics, and search results.

<div class="blog-search">
    <div id="blog-search-input"></div>
    <div id="blog-search-stats"></div>
    <div id="blog-search-hits"></div>
    <div id="blog-search-pagination"></div>
</div>

Now we can add JavaScript that will show the results and the additional UI elements in the appropriate containers.

<script src="https://cdn.jsdelivr.net/npm/algoliasearch@4.0.0/dist/algoliasearch-lite.umd.js" integrity="sha256-MfeKq2Aw9VAkaE9Caes2NOxQf6vUa8Av0JqcUXUGkd0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.0.0/dist/instantsearch.production.min.js" integrity="sha256-6S7q0JJs/Kx4kb/fv0oMjS855QTz5Rc2hh9AkIUjUsk=" crossorigin="anonymous"></script>

<script>
    // Name of search index on Algolia
    var blogIndexName = '<yourIndexName>';

    // Initialize "instantsearch" object for your index. It will act as context for particular widgets
    var search = instantsearch({
        indexName: blogIndexName,
        searchClient: algoliasearch(
            appId: '@(algoliaSettings.Value.AlgoliaAppId)',
            apiKey: '@(algoliaSettings.Value.AlgoliaSearchApiKey)', // search only API key, no ADMIN key //AppOptions.AlgoliaSearchApiKey
        ),
    });

    // Bind "search box" widget to appropriate input
    search.addWidget(
        instantsearch.widgets.searchBox({
            container: '#blog-search-input',
            autofocus: true
        })
    );

    // Bind "Statistics" widget to appropriate container
    search.addWidget(
        instantsearch.widgets.stats({
            container: '#blog-search-stats',
            templates: {
                body: '<p><strong>{{nbHits}}</strong> results found for "<strong>{{query}}</strong>"</p>'
            }
        })
    );

    // Bind "Hits" widget to appropriate container. It displays results using specified template
    search.addWidget(
        instantsearch.widgets.hits({
            container: '#blog-search-hits',
            hitsPerPage: 9,
            templates: {
                item: document.getElementById('hit-template').innerHTML,
                empty: "We didn't find any results for the search <em>\"{{query}}\"</em>"
            }
        })
    );

    // Bind "Pagination" widget to appropriate container.
    // We set "previous" and "next" captions to empty string as we will use arrow icons defined by CSS styles instead
    search.addWidget(
        instantsearch.widgets.pagination({
            container: '#blog-search-pagination',
            showFirstLast: false,
            labels: { previous: '', next: '' }
        })
    );

    search.start();
</script>

The layout of the particular item in results is defined by a template. In this example, the template consists of HTML code placed within the <script> tag (so it’s not rendered to a page) with an ID “hit-template”. There are two types of variables that can be used in the template which Algolia’s script automatically resolves:

  • Variable within two pairs of curly brackets—these are properties from the index.
  • Variable within three pairs of curly brackets—where we reference the _highlightResult object which contains properties from the index, with HTML highlights on the matched words.
<script type="text/html" id="hit-template">
            <a href="blog/{{urlSlug}}" class="blog-search-results__link">
                <div class="blog-search-results__image js-fix-image" style="background-image: url('{{image}}');">
                </div>
                <div class="blog-search-results__text">
                    <h3 class="blog-search-results__title">{{{_highlightResult.title.value}}}</h3>

                    <div class="blog-search-results__desc">
                        <p>{{{_highlightResult.perex.value}}}</p>
                    </div>
                    <div class="blog-search-results__info">
                        <div class="blog-search-results__author">
                            {{author}} <span class="blog-search-results__bullet"></span> {{published}}
                        </div>
                        <div class="blog-search-results__topic">
                            {{topic}}
                        </div>
                    </div>
                </div>
            </a>
</script>

Wrapping Up

In this walkthrough, we’ve shown you how easily you can integrate Kentico Kontent with Algolia to enable searching of your website content. This functionality will help improve user experience.

Are you going to give Algolia search a try?

Written by
Tomas Nosek

I've been leading the Customer Education and Consulting teams at Kentico Kontent. My favorite topics are merging technical and marketing communication, UX writing, content modeling, team management, and virtual reality. You can find me at @tomnosek on Twitter or get links to my blog posts at nosek.net.

More articles from Tomas
Written by
Martin Dobsicek


Subscribe to Kentico Kontent Newsletter

Stay in the loop. Get the hottest updates while they’re fresh!