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.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive into the capabilities of this package.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

If you want to learn about implementing pagination from scratch, check out our great article Paging in ASP.NET Core Web Api.

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.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!