.NET 6 library to make sorting, filtering and paging easier
Explore the docs »
Table of Contents
Since many REST APIs need the functionality to sort, filter and page data, there should be a simple way to do such a trivial task without lots of hardcoded filters and conditions. QuickSearch provides exactly this - simple way to fetch data from your API with a simple GET request.
TBD when it'll be available on NuGet
To start supporting sorted/filtered/paginated queries, all you need to do is create an endpoint that will receive SortOptions<TEntity>
, FilterOptions<TEntity>
or PageOptions
as a query parameter. These options can be then given directly to LINQ extensions without any additional mapping whatsoever:
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _context;
public UsersController(AppDbContext context)
=> _context = context;
[HttpGet("sorted")]
public Task<List<User>> GetSorted([FromQuery(Name = "s")] SortOptions<User> request)
=> _context.Users.Sort(request).ToListAsync();
[HttpGet("filtered")]
public Task<List<User>> GetFiltered([FromQuery(Name = "f")] FilterOptions<User> request)
=> _context.Users.Filter(request).ToListAsync();
[HttpGet("paged")]
public Task<Page<User>> GetPaged([FromQuery(Name = "p")] PageOptions request)
=> _context.Users.ToPageAsync(request);
}
To have all options in one request, you can create a request class that will contain all of those and set it as endpoint parameter:
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpGet]
public Task<Page<User>> GetUsers([FromQuery] SearchRequest request)
=> _context.Users
.Filter(request.Filter)
.Sort(request.Sort)
.ToPageAsync(request.Page);
}
public class SearchRequest
{
[FromQuery(Name = "p")]
public PageOptions? Page { get; set; }
[FromQuery(Name = "s")]
public SortOptions<User>? Sort { get; set; }
[FromQuery(Name = "f")]
public FilterOptions<User>? Filter { get; set; }
}
Example request for such endpoint will look like following:
curl --location "https://localhost:7032/users/paged\
?p.number=1\
&p.size=15\
&s.firstName=desc\
&s.lastName=asc\
&f.email.like=Adrian"
Paged response will come in a following format:
{
"items": [
...
],
"currentPage": <int>,
"pageSize": <int>,
"totalItems": <int>,
"totalPages": <int>
}
In case if you don't necessarily want to expose your data model in the API, you can create a map between types to separate search model from data model. Simply create a class extending AbstractPropertyMap<TIn, TOut>
, and declare mappings in the constructor.
public class Account
{
public int Balance { get; set; }
}
public class User
{
public string FirstName { get; set; }
public Account Account { get; set; }
}
public class UserSearch
{
public string Name { get; set; }
public int AccountBalance { get; set; }
}
public class UserPaginationMap : AbstractPropertyMap<UserSearch, User>
{
public UserPaginationMap()
{
Map(dto => dto.Name, u => u.FirstName);
Map(dto => dto.AccountBalance, u => u.Account!.Balance);
}
}
For result mapping, Page
class currently supports simple MapTo
method with a delegate parameter, which will return a page of the desired type.
Example of options and page mapping:
[HttpGet("users")]
public async Page<UserDTO> GetUsers(
[FromQuery] SortOptions<UserSearch> sortOptions,
[FromQuery] FilterOptions<UserSearch> filterOptions
[FromQuery] PageOptions pageOptions
)
{
var userSortOptions = sortOptions.MapTo<User>();
var userFilterOptions = filterOptions.MapTo<User>();
var result = await _context.Users.Include(u => u.Account)
.Filter(userFilterOptions)
.Sort(userSortOptions)
.ToPageAsync(pageOptions);
return result.MapTo(u => UserDTO.FromUser(u));
}
QuickSearch requests are validated out of the box on model binding. SortOptions<TEntity>
and FilterOptions<TEntity>
are validated also against the entity they are bound to, to check if all the properties on filters and sorters passed to the request are present on the model you want to filter/sort.
URL:
https://localhost:7032/users
?p.number=-1
&p.size=-1
&s.notexistingproperty=asc
&s.firstName=notexistingsorter
&f.notexistingproperty.eq=Test
&f.firstName.notexistingfilter=Test
Response:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-c9bf80bdbbcb14a4001e3084f299a882-205455575e3d01ba-00",
"errors": {
"p.Size": [
"The field Size must be between 1 and 2147483647."
],
"p.Number": [
"The field Number must be between 1 and 2147483647."
],
"s.firstName": [
"Unrecognized sort direction value"
],
"s.notexistingproperty": [
"Property does not exist on entity 'User'"
],
"f.firstName.notexistingfilter": [
"Invalid filter type"
],
"f.notexistingproperty.eq": [
"Property does not exist on entity 'User'"
]
}
}
If ever needed to pass options to another service request via query parameters, it's possible via separate options or whole using a builder.
var pageOptions = new PageOptions
{
Number = 10,
Size = 40,
};
var queryString = pageOptions.ToQueryString("p");
queryString
will be equal to p.Number=10&p.Size=40
var sortOptions = new FilterOptions<User>()
.AddFilter(u => u.FirstName, FilterType.Eq, "John")
.AddFilter(u => u.LastName, FilterType.Like, "DoePerhaps");
var queryString = filterOptions.ToQueryString("f");
queryString
will be equal to f.FirstName.Eq=John&f.LastName.Like=DoePerhaps
var options = new SortOptions()
.AddSort(u => u.Id, SortDirection.Desc)
.AddSort(u => u.Name, SortDirection.Asc);
var queryString = options.ToQueryString("s");
queryString
will be equal to s.Id=Desc&s.Name=Asc
var pageOptions = new PageOptions
{
Number = 10,
Size = 40
};
var filterOptions = new FilterOptions<User>()
.AddFilter(u => u.Id, FilterType.Eq, "someid")
.AddFilter(u => u.Name, FilterType.Like, "somename");
var sortOptions = new SortOptions<User>()
.AddSort(u => u.Id, SortDirection.Desc)
.AddSort(u => u.Name, SortDirection.Asc);
var builder = new PaginationQueryBuilder<User>()
.Page("p", pageOptions)
.Filter("f", filterOptions)
.Sort("s", sortOptions);
var queryString = builder.ToQueryString();
queryString
will be equal to p.Number=10&p.Size=40&f.Id.Eq=someid&f.Name.Like=somename&s.Id=Desc&s.Name=Asc"
- Add option to map DTOs to actual models to not force API to expose data model
- Add tests
- Unit tests
- Integration tests
- Add option to easily overwrite validation logic
- Make it work properly with minimal APIs
- Add better swagger support
- Better controls display
- Show all accepted fields for given entity
See the open issues for a full list of proposed features (and known issues).
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature
) - Commit your Changes (
git commit -m 'Add some AmazingFeature'
) - Push to the Branch (
git push origin feature/AmazingFeature
) - Open a Pull Request
Distributed under the Apache-2.0 License. See LICENSE.txt
for more information.