X

Adding search to Blazor applications

As my Blazor demo application supports now Azure AD I took the next step and implemented search using Azure Search service. This blog post shows how to add search capabilities to Blazor application using Azure AD protected Azure Functions back-end and Azure Search service.

Source code available! Source code for my Blazor demo solution is available at my GitHub repository peipman/BlazorDemo. Feel free to go through the code or make it work on your own Azure environment. I expect you to keep the code open while going through this blog post. Where adequate I also have links to specific files in solution provided.

Azure Search is powerful search solution for small and big corporate sites. As all other parts of my solution are implemented using Azure services I decided to keep the same pattern also with search. Here’s the high-level view to mu solution with search added.

Although I first wanted to go with pure Blazor implementation of Azure Search client it turned out to be impossible. Blazor makes all requests to external system exactly like browser does. It means that first there’s OPTIONS request to check CORS stuff. For most of requests answer is negative. It’s possible to use query key to send queries to search index but in big part this is all we can do. Other actions expect web application to know search service administration key and this is considered as a high risk. Microsoft suggests to keep Azure Search behind some server-side service for security reasons.

Setting up Azure Search

Before we start with code we need to create Azure Search service and index for books. I have free pricing tier. The following image shows my search index. I called it “books”. For CORS you can enable access for all locations for testing. For live sites it is recommended to configure it if browser access is needed. The following screenshot shows my books index.

To communicate with Azure Search we need Microsoft.Azure.Search NuGet package in BlazorDemo.AzureFunctionsBackend project. For demo purposes I added simple static class AzureSearchClient.

public static class AzureSearchClient
{
    private static SearchIndexClient GetClient()
    {
        return new SearchIndexClient("<service name>", "books", new SearchCredentials("<service key>"));
    }

    public static async Task<PagedResult<Book>> Search(string term, int page)
    {
        var searchParams = new SearchParameters();
        searchParams.IncludeTotalResultCount = true;
        searchParams.Skip = (page - 1) * 10;
        searchParams.Top = 10;

        using (var client = GetClient())
        {
            var results = await client.Documents.SearchAsync<Book>(term, searchParams);
            var paged = new PagedResult<Book>();
            paged.CurrentPage = page;
            paged.PageSize = 10;
            paged.RowCount = (int)results.Count;
            paged.PageCount = (int)Math.Ceiling((decimal)paged.RowCount / 10);

            foreach (var result in results.Results)
            {
                paged.Results.Add(result.Document);
            }

            return paged;
        }
    }

    public static async Task IndexBook(Book book, TraceWriter log)
    {
        using (var client = GetClient())
        {
            var azureBook = new { id = book.Id.ToString(), Title = book.Title, ISBN = book.ISBN };
            var batch = IndexBatch.MergeOrUpload(new [] { azureBook });

            await client.Documents.IndexAsync(batch);
        }
    }

    public static async Task RemoveBook(int id)
    {
        using (var client = GetClient())
        {
            var batch = IndexBatch.Delete("id", new[] { id.ToString() });

            await client.Documents.IndexAsync(batch);
        }
    }
}

This class has four methods:

  1. GetClient() – creates new search client with information needed to communicate with books index.
  2. Search() – queries search index for given term and returns paged result with books. To get better idea about how PagedResult<T> works I have blog post Paging with Entity Framework Core. For full paging sample I have sample in GitHub repository gpeipman/DotNetPaging.
  3. IndexBoox() – adds or updates given book in search index.
  4. RemoveBook() – removes book from search index.

For real-life scenario I suggest to go with better architecture and build real client that can be injected or retrieved by some simple service locator you build for functions.

Adding search to functions back-end

As Blazor application uses search through functions we have to add new function to query search index. Other methods like Save() and Delete must also update search index (Functions.cs file in BlazorDemo.AzureFunctionsBackend project).

public static class Functions
{
    [FunctionName("Index")]
    public static IActionResult Index([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "Books/Index/page/{page}")]HttpRequest req, TraceWriter log, [FromRoute] int page)
    {
        if (page <= 0) page = 1;

        using (var context = (new BooksDbContextFactory()).CreateDbContext())
        {
            var books = context.Books
                                .OrderBy(b => b.Title)
                                .GetPaged(page, 10);

            return new JsonResult(books);
        }
    }

    [FunctionName("Get")]
    public static IActionResult Get([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "Books/Get/{id}")]HttpRequest req, TraceWriter log, [FromRoute]int id)
    {
        using (var context = (new BooksDbContextFactory()).CreateDbContext())
        {
            var book = context.Books.FirstOrDefault(b => b.Id == id);

            return new JsonResult(book);
        }
    }

    [FunctionName("Save")]
    public static async Task Save([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "Books/Save")]HttpRequest req, TraceWriter log)
    {
        using (var reader = new StreamReader(req.Body, Encoding.UTF8))
        using (var context = (new BooksDbContextFactory()).CreateDbContext())
        {
            var body = reader.ReadToEnd();
            var book = JsonConvert.DeserializeObject<Book>(body);

            context.Update(book);
            context.SaveChanges();

            await AzureSearchClient.IndexBook(book, log);
        }
    }

    [FunctionName("Delete")]
    public static async Task Delete([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "Books/Delete/{id}")]HttpRequest req, TraceWriter log, [FromRoute]int id)
    {
        using (var context = (new BooksDbContextFactory()).CreateDbContext())
        {
            var book = context.Books.FirstOrDefault(b => b.Id == id);
            if (book == null)
            {
                return;
            }

            context.Books.Remove(book);
            await context.SaveChangesAsync();

            await AzureSearchClient.RemoveBook(book.Id);
        }
    }

    [FunctionName("Search")]
    public static async Task<IActionResult> Search([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "Books/Search/{page}/{term}")]HttpRequest req, TraceWriter log, [FromRoute]string term, [FromRoute]int page)
    {
        var results = await AzureSearchClient.Search(term, page);

        return new JsonResult(results);
    }
}

Search function is just a wrapper between Blazor application and Azure Search so we don’t have to connect Blazor application directly to Azure Search. This way we Blazor application has no way to know about search details and if needed we can change search logic without any need to deploy Blazor application again. Also there’s no danger that search index gets accessible to unknown users (remember, Azure Search is not protected by Azure AD).

Adding search box to user interface

The final part of work is to update UI of application and add search box with some buttons there. What we want to achieve is something like shown on following image.

Let’s start with Index page (Pages/Index.cshtml file in BlazorDemo.AdalClient project). We add search box with Search and Clear buttons right next to Add new button like shown in the code fragment below.

<div class="row margin-bottom-20">
    <div class="col-md-3">
        <button class="btn btn-primary" type="button" onclick=@AddNew>Add new</button>
    </div>
    <div class="col-md-9">
        <input type="text" bind="@this.SearchTerm" onkeyup="@SearchBoxKeyPress" />
        <button class="btn" type="button" onclick="@SearchClick">Search</button>
        <button class="btn" type="button" onclick="@ClearClick">Clear</button>
    </div>
</div>

We bind search box field to SearchTerm property of Index page model. It is two-way binding meaning that property gets the value automatically when text in search box is changed. We also have to capture onkeyup event of search box. If user clicked Enter button there then we have to search books or load default list from database.

New events are here (Pages/Index.cshtml.cs file in BlazorDemo.AdalClient project).

protected void SearchClick()
{
    if(string.IsNullOrEmpty(SearchTerm))
    {
        LoadBooks(int.Parse(Page));
        return;
    }

    Search(SearchTerm, int.Parse(Page));
}

private void Search(string term, int page)
{
    Action<string> action = async (token) =>
    {
        BooksClient.Token = token;
        Books = await BooksClient.SearchBooks(term, page);

        StateHasChanged();
    };

    RegisteredFunction.InvokeUnmarshalled<bool>("executeWithToken", action);
}

protected void SearchBoxKeyPress(UIKeyboardEventArgs ev)
{
    if(ev.Key == "Enter")
    {
        SearchClick();
    }
}

protected void ClearClick()
{
    SearchTerm = "";
    LoadBooks(1);
}

If you go through Index page you will see how current page and search term are always available through routes. Paging works also with search results.

This is how Blazor books database looks after adding search.

Wrapping up

Azure Search is powerful search service for web sites and different types of applications. For Blazor applications it works best when used behing API layer – be it ASP.NET Core Web API or Azure Functions. This blog post showed how to build search to Blazor applications using Azure Functions and Azure Search services. Together with Azure AD it takes some effort to configure services on Azure but we have less hassle on code level later.

Liked this post? Empower your friends by sharing it!

View Comments (0)

Related Post