Most applications that are developed nowadays usually involve interacting with non-trivial amounts of data, so we need to provide functionality for sorting and filtering in our applications.
Moreover, speed and load times are increasingly important, so we don’t want to return all items in a single HTTP call, as this will end up being a large amount of data. Instead, we need to provide paging to allow only a subset of items to be shown at any one time. All of this is non-trivial to implement. Fortunately, in ASP.NET Core, we have a convenient way to handle this, with the Sieve package.
Let’s dive into the capabilities of this package.
Features of Sieve
Out of the box, Sieve gives us an easy way to add sorting and filtering to our application models, as well as provides functionality for pagination, so we can specify how many items are returned in a single request.
We add sorting and filtering functionality with the use of attributes:
public class SortFilterModel { [Sieve(CanSort = true)] public string Name { get; set; } [Sieve(CanSort = true, CanFilter = true)] public string Category {get; set; } [Sieve(CanFilter = true)] public decimcal Price { get; set; } }
Here, we use the Sieve
attribute, which allows us to define if our field as sortable, filterable, or both. This is all we need to do to our model to enable the functionality provided by Sieve.
Now, in an HTTP GET method, we pass as a parameter the SieveModel
class:
[HttpGet] public IActionResult GetSortedFilteredModel(SieveModel sieveModel) { var models = _dataRetrievalService.Models; models = _sieveProcessor.Apply(sieveModel, models); return Ok(models); }
Here, we have a simple method that takes a SieveModel
object. This object defines the properties that we use to filter, sort, etc, stored as a comma-separated list of field names with an operator.
Next, with our list of application models, we use the Apply()
method from the SieveProcessor
class provided by the package to return the list of sorted, filtered, and paginated models, which we return to the caller.
Now that we understand the basics of using the Sieve package to add sorting, filtering, and pagination to our applications, let’s create an application to see it in action.
Create an API
Our fictional API will work with running shoes, so let’s start by using the Visual Studio Project Wizard or dotnet new webapi
command to create our application.
Next, we’ll add the Sieve
NuGet package and create our Shoe
model:
public class Shoe { public required string Name { get; set; } public required string Category { get; set; } public required string Brand { get; set; } public decimal Price { get; set; } public decimal Rating { get; set; } }
Here, we define a set of required members that we will shortly use for sorting and filtering.
With our model created, we can create a simple service to retrieve our shoes:
public class ShoeRetrievalService : IShoeRetrievalService { public IQueryable<Shoe> GetShoes() { return new List<Shoe> { new() { Name = "Pegasus 39", Brand = "Nike", Price = 119.99M, Category = "Running", Rating = 4.5M }, new() { Name = "Pegasus Trail 3", Brand = "Nike", Price = 129.99M, Category = "Trail", Rating = 3.8M }, new() { Name = "Ride 15", Brand = "Saucony", Price = 119.99M, Category = "Neutral", Rating = 4.9M } }.AsQueryable(); } }
We define an IShoeRetrievalService
interface with a single method, GetShoes()
which returns an IQueryable<Shoe>
. Our ShoeRetrievalService
implements this interface and creates a couple of shoes that we’ll use to test out the sorting, filtering, and pagination.
Finally, let’s add the API controller that we’ll use to retrieve our shoes from:
[Route("api/[controller]")] [ApiController] public class ShoesController : ControllerBase { private readonly ISieveProcessor _sieveProcessor; private readonly IShoeRetrievalService _shoeRetrievalService; public ShoesController(ISieveProcessor sieveProcessor, IShoeRetrievalService shoeRetrievalService) { _sieveProcessor = sieveProcessor; _shoeRetrievalService = shoeRetrievalService; } [HttpGet] public IActionResult GetShoes([FromQuery]SieveModel sieveModel) { var result = _sieveProcessor.Apply(sieveModel, _shoeRetrievalService.GetShoes()); return Ok(result); } }
To start, we inject an instance of ISieveProcessor
and IShoeRetrievalService
in our constructor, assigning them to private fields.
We have a single HTTP GET method, GetShoes()
which takes as a parameter the SieveModel
class. We decorate this parameter with the FromQuery
attribute, so the framework knows we’ll pass this model and its properties as query parameters.
Next, we call the Apply()
method, passing in our sieveModel
which contains the sorting, filtering, and pagination settings, along with our list of shoes.
Finally, we return this sorted, filtered, and paginated list to the caller.
We must remember to register our services with the service collection so we can take advantage of dependency injection. In our Program
class, we’ll register our services:
var builder = WebApplication.CreateBuilder(args); // code removed for brevity builder.Services.AddScoped<ISieveProcessor, SieveProcessor>(); builder.Services.AddScoped<IShoeRetrievalService, ShoeRetrievalService(); var app = builder.Build(); // code removed for brevity app.Run();
Here, we add both the SieveProcessor
and ShoeRetrievalService
classes as scoped objects.
Before we look at the sorting and filtering functionality, let’s run our application and make a GET request to /api/shoes
, where our list of shoes will be returned, confirming our API works.
From here, we no longer have to touch our API method. Instead, we use the attributes provided by Sieve to add sorting and filtering functionality to our model. Let’s look at that next.
Sorting With Sieve
Back in our Shoe
model, we can enable sorting very easily with the Sieve
attribute:
public class Shoe { public required string Name { get; set; } public required string Category { get; set; } public required string Brand { get; set; } [Sieve(CanSort = true)] public decimal Price { get; set; } [Sieve(CanSort = true)] public decimal Rating { get; set; } }
Here, we use the Sieve
attribute, setting the CanSort
property to true
for our Price
and Rating
properties. This is how simple it is to add sorting to our application.
Let’s run our application again and attempt to sort by our properties. To sort our results, we use the sorts
query parameter, providing a comma-separated list of properties we want to sort by. So let’s start by sorting by our Price
property, making a request to /api/shoes?sorts=Price
:
[ { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 }, { "name": "Ride 15", "category": "Neutral", "brand": "Saucony", "price": 119.99, "rating": 4.9 }, { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 } ]
Our response shows our shoes that cost 119.99
first. This confirms that Sieve sorts ascendingly by default. To sort descending, we simply add a -
before the property we want to sort in descending order.
Also, we can easily sort by multiple properties, so let’s try that by sending a request to /api/shoes?sorts=Price,-Rating
:
[ { "name": "Ride 15", "category": "Neutral", "brand": "Saucony", "price": 119.99, "rating": 4.9 }, { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 }, { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 } ]
This time, we notice the Saucony Ride 15
shoes come first. This is because not only did we sort by the Rating
property, but we added the -
symbol, meaning it will sort in descending order.
Sieve Filtering
Now that we understand how to sort properties with the Sieve package, let’s explore the filtering functionality.
Just like sorting, we can enable the filtering of properties with the Sieve
attribute, but we now use the CanFilter
property:
public class Shoe { public required string Name { get; set; } [Sieve(CanFilter = true)] public required string Category { get; set; } [Sieve(CanFilter = true)] public required string Brand { get; set; } [Sieve(CanSort = true)] public decimal Price { get; set; } [Sieve(CanSort = true, CanFilter = true)] public decimal Rating { get; set; } }
Here, we add filtering capabilities to the Category
, Brand
and Rating
properties. Notice from the Rating
property that we can enable both sorting and filtering on the same property with Sieve.
With these changes made to our model, we can run our application again and send a request to /api/shoes?filters=Brand@=Nike
:
[ { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 }, { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 } ]
This time, our response only contains Nike-branded shoes, because we filtered on the Brand
property and used the @=
operator, which looks for values containing the value we provided.
Also, we can combine this with sorting by making a new request to /api/shoes?filters=Brand@=Nike&sorts=Rating
:
[ { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 }, { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 } ]
This time, we still get only Nike-branded shoes in our response, but the Pegasus Trail 3
shoes now come first, as they have a lower rating, which is the property we sorted by.
Add Paging Capabilities
The final piece of functionality that the Sieve package provides is paging.
The great thing about this functionality is that we don’t need to alter any of our existing code to add pagination. So, we can simply make a request to /api/shoes&pageSize=1
:
[ { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 } ]
As we define a pageSize
of 1, we only get one result. We can combine this with the page
parameter to get our other shoes. So, let’s append the page
parameter to our query and send a request to /api/shoes?pageSize=1&page=2
:
[ { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 } ]
This time, we get the second item from our shoe collection.
Furthermore, we can combine pagination with sorting and filtering capabilities to adjust what items we want to be returned from our API. just like we looked at when combining sorting and filtering.
Customize Sieve Functionality
Now that we’ve explored the basic sorting, filtering, and pagination functionality the Sieve package offers us, let’s take a look at some of the more advanced features that we can customize.
Sieve Configuration
So far, we have been using the default configuration values for our API. Any request that uses a property name for sorting or filtering is case-sensitive by default. Fortunately, we can override this default value with the built-in configuration Sieve provides in appsettings.json
:
{ "Sieve": { "CaseSensitive": false } }
Here, we add a new configuration section for Sieve
, disabling the CaseSensitive
property.
Next, we need to tell the Sieve package to use our custom configuration in the Program
class:
var builder = WebApplication.CreateBuilder(args); // code removed for brevity builder.Services.Configure<SieveOptions>(builder.Configuration.GetSection("Sieve")); var app = builder.Build(); // code removed for brevity app.Run();
Here, we use the options pattern to tell Sieve where our custom configuration exists.
Now, when we run our application, we can make a request to /api/shoes?sorts=price
, which will correctly sort our shoes by the Price
property.
We have other customizations we can use, such as DefaultPageSize
:
{ "Sieve": { "CaseSensitive": false, "DefaultPageSize": 2 } }
This time, we don’t need to add the pageSize
parameter to any of our requests. By default, our API will now return 2 results.
Fluent API
Instead of using attributes, Sieve provides us the ability to use the Fluent API to mark the properties we want to configure for sorting and filtering.
To begin, we create a new version of SieveProcessor
:
public class CustomSieveProcessor : SieveProcessor { public CustomSieveProcessor( IOptions<SieveOptions> options) : base(options) { } protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) { mapper.Property<Shoe>(p => p.Category) .CanFilter(); mapper.Property<Shoe>(p => p.Brand) .CanFilter(); mapper.Property<Shoe>(p => p.Price) .CanSort(); mapper.Property<Shoe>(p => p.Rating) .CanSort() .CanFilter(); return mapper; } }
First, we inherit from the SieveProcessor
class, providing a default constructor.
Next, we override the SievePropertyMapper()
method, using the fluent builder to configure the sorting and filtering for our Shoe
class.
With this, we can remove all of the attributes from our Shoe
class.
The final thing we must do is reconfigure our dependency injection setup in the Program
class:
var builder = WebApplication.CreateBuilder(args); // code removed for brevity builder.Services.AddScoped<ISieveProcessor, CustomSieveProcessor>(); var app = builder.Build(); // code removed for brevity app.Run();
This time, instead of registering the ISieveProcessor
interface to resolve to the SieveProcessor
class, we resolve to our CustomSieveProcessor
implementation.
Now when we run our application, we can replicate one of our requests from earlier to /api/shoes?filters=Brand@=Nike&sorts=Rating
, which returns the same result:
[ { "name": "Pegasus Trail 3", "category": "Trail", "brand": "Nike", "price": 129.99, "rating": 3.8 }, { "name": "Pegasus 39", "category": "Running", "brand": "Nike", "price": 119.99, "rating": 4.5 } ]
This confirms our custom processor using the Fluent API works as expected.
Conclusion
Providing sorting, filtering, and pagination functionality to our APIs provides a faster, more performant, and better user experience for consumers of our APIs.
In this article, we explored the powerful Sieve package that makes adding this functionality very simple. Sieve provides us with a lot of customization which means we can fine-tune the existing functionality to meet our applications requirements.