Now that we’ve indexed some data, we should have a way of actually searching it.
For this purpose, we’re going to implement the SearchService
class, which will be responsible for building and sending our queries to Elastic through the ElasticClient
we’re going to provide.
These are the steps we’re going to follow:
- Create the
SearchService
class. - Create a
SearchResult
object that will serve as a template for the Elastic response. - Create a
Search
method that accepts a query string and pagination info. - Build a
fluent DSL
expression that’s going to send aQueryStringQuery
to Elastic and return a response. - Map the response to
SearchResult
and return it. - Expose the
Search
method through our API. - Register the
SearchService
as an injectable. - Test if we’re getting correct results by sending a request to our API
Step 1. Creating the SearchService
class
In your Elastic folder, add a new class called SearchService
with the following implementation:
public class SearchService
{
public SearchService(ElasticClientProvider clientProvider)
{
this.client = clientProvider.Client;
}
private readonly ElasticClient client;
}
As you can see, we’re providing an ElasticClient
through the previously implemented ElasticClientProvider
.
Step 2. Creating the SearchResult
class
In your Models folder, add a class called SearchResult
with the following implementation:
using System.Collections.Generic;
public class SearchResult<T>
{
public long Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public IEnumerable<T> Results { get; set; }
public long ElapsedMilliseconds { get; set; }
}
This is the object we’re going to return from our Search
method. Now let’s create it.
Step 3. Creating the Search
method
In your SearchService
class, add the following method.
public async Task<SearchResult<Recipe>> Search(string query, int page, int pageSize) {}
This is the method that’s going to be responsible for searching the data.
Step 4. Building our fluent DSL
expression
Before starting to write the actual fluent DSL
query, we need to think about what we want it to do.
I’ll remind that we want to process the user input in the following way:
- Exact phrases to look for are marked with quotes (“an example phrase“).
- Each word/phrase that isn’t marked in any way (example) will be present in the result set’s ingredients.
- Each word/phrase that is marked with a minus (-example) will not be present in the result set’s ingredients.
- Each word/phrase can be [boosted][qsboosting] to become more relevant (score higher) in the search results (example^2).
So, if we want to look for recipes containing garlic, tomatoes, chopped onions and not containing eggs and red-pepper flakes, we’ll have to input the query string:
garlic tomatoes -egg* “chopped onions” -“red-pepper flakes” // Order doesn’t matter
All of the above is natively supported by Elastic’s query_string query
, which looks like this:
{
"from": (page - 1) * pageSize,
"size": pageSize,
"query": {
"bool": {
"must": [
{
"query_string": {
"query": "our user inputted query"
}
}
]
}
}
}
In order to build a similar query with NEST
, we’ll use fluent DSL
.
This is how our fluent DSL
expression should look like.
var response = await this.client.SearchAsync<Recipe>(searchDescriptor => searchDescriptor
.Query(queryContainerDescriptor => queryContainerDescriptor
.Bool(queryDescriptor => queryDescriptor
.Must(queryStringQuery => queryStringQuery
.QueryString(queryString => queryString
.Query(query)))))
.From((page - 1) * pageSize)
.Size(pageSize));
Don’t worry, once we go through it line by line, it will seem a lot less scary!
var response = await this.client.SearchAsync<Recipe>(searchDescriptor => searchDescriptor
We define a variable called response
that will hold the response returned by Elastic. We then call the ElasticClient
method SearchAsync
and initialize a SearchDescriptor
object, which will be responsible for describing our query to Elastic.
.Query(queryContainerDescriptor => queryContainerDescriptor
We specifiy that we’ll be sending a query and we initialize a QueryContainerDescriptor
object.
.Bool(queryDescriptor => queryDescriptor
We tell the QueryContainerDescriptor
object that we’ll be sending a BooleanQuery
and we initialize a BooleanQueryDescriptor
object.
.Must(queryStringQuery => queryStringQuery
We tell the BooleanQueryDescriptor
object that it must match a query we are about to initialize.
.QueryString(queryString => queryString
We specify that the query the BooleanQueryDescriptor
must match is of type QueryStringQuery
and we initialize it.
.Query(query)))))
We pass our user inputted query to the QueryStringQuery
we initialized.
.From((page - 1) * pageSize)
.Size(pageSize));
Take just the documents the user requested according to the pagination info.
Step 5. Map the response to SearchResult
and return it
return new SearchResult<Recipe>
{
Total = response.Total,
ElapsedMilliseconds = response.Took,
Page = page,
PageSize = pageSize,
Results = response.Documents
};
This is the actual response that our API will return in the form of JSON.
The implementation of our Search
method is completed. Now we just have to expose it through our API, register SearchService
as an injectable and we’ll be ready to search some data!
6. Exposing the Search
method through our API
This is the simplest step. What we need to do is:
- Create a controller responsible for handling the Recipes.
- Create a
Search
action that will accept the user inputted query and pass it to our service.
1. Create the controller
In your project’s Controllers folder, create a new class called RecipeController
with the following implementation.
[Route("api/[controller]")]
public class RecipeController : ApiController
{
public RecipeController(SearchService searchService)
{
this.searchService = searchService;
}
private readonly SearchService searchService;
}
We’re simply injecting the SearchService
and storing it in a local variable.
2. Create the Search
action
Now, add a method called search with the following template:
[HttpGet("search")]
public async Task<JsonResult> Search([FromQuery]string query, int page = 1, int pageSize = 10)
{
var result = await this.searchService.Search(query, page, pageSize);
return Json(result);
}
Quite simple. The HttpGet
attribute specifies that this action will only fire for GET requests, and the FromQuery
attribute specifies that the query
parameter will be taken from the query string.
We’re just one step away from being able to Search
our data.
Step 7. Register the SearchService
as an injectable
In your Startup.cs
file, in the ConfigureServices
method, add the following line:
services.AddTransient(typeof(SearchService));
This will make our SearchService
available for injection.
Testing the Search functionality
Okay! We’ve implemented the Search
function and exposed an endpoint through our API. Let’s search some recipes!
Fire up Postman and make a request to:
http://yourappurl/api/recipe/search?query=whatever query you wish
Awesome, isn’t it? With just a few lines of code we managed to create a search engine that’s able to handle complicated user input.
Don’t miss part 4, when we’ll be implementing the Autocomplete and More like this functionality!
As always, if you have any questions, don’t hesitate to contact me!
Be First to Comment