REST-Parser converts REST-style query strings into LINQ expressions and can run them against an IQueryable<T>. It is designed for APIs that need dynamic filtering, sorting, and pagination without hand-writing a query endpoint for every entity.
- Dynamic filtering with equality, comparison,
in, and stringcontainsoperators - OR conditions with
|, alongside AND with& - Filtering on strings, all numeric types,
bool,Guid, enums,DateTime,DateTimeOffset,DateOnly,TimeOnlyandTimeSpan - Multi-column sorting with ascending and descending directions
- Optional page-based pagination with result metadata
- Strongly typed LINQ expression generation
- Dependency injection support for ASP.NET Core
- Nullable support for supported scalar types
- Guardrails for query length, condition count, and page size
- Installation
- Quick Start
- Query Syntax
- Filtering
- Sorting
- Pagination
- API Usage
- Exception Handling
- Limits and Behavior
- Best Practices
- Troubleshooting
- Version History
- Project Layout
Install the NuGet package:
dotnet add package REST-ParserPackage Manager:
Install-Package REST-ParserPackage reference:
<PackageReference Include="REST-Parser" Version="2.0.0" />Define an entity:
public class Product
{
public int Id { get; set; }
public int TenantId { get; set; }
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime ReleaseDate { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsActive { get; set; }
public ProductStatus Status { get; set; }
public string? Description { get; set; }
public double? Rating { get; set; }
}
public enum ProductStatus
{
Draft,
Active,
Discontinued
}Register the parser for each entity type you want to query:
using REST_Parser.DependencyResolution;
builder.Services.RegisterRestParser<Product>();Inject and run the parser:
using REST_Parser;
using REST_Parser.Models;
public class ProductService
{
private readonly IRestToLinqParser<Product> parser;
private readonly AppDbContext context;
public ProductService(IRestToLinqParser<Product> parser, AppDbContext context)
{
this.parser = parser;
this.context = context;
}
public RestResult<Product> GetProducts(string query)
{
return parser.Run(context.Products, query);
}
}Example query:
GET /api/products?category=Electronics&price[lt]=1000&status[in]=Active,Draft&$sort_by[asc]=price&$page=1&$pagesize=20The general filter format is:
field[operator]=value
If you omit the operator, eq is used:
name=iPhone
name[eq]=iPhone
Combine query parts with &:
category=Electronics&price[lt]=1000&isActive=true
Use | within a part to match any of several conditions (OR). & binds more loosely than |, so each & part is one group of alternatives:
status=Active|status=Pending&price[lt]=100
This means (status == Active || status == Pending) && price < 100. The alternatives can be on different fields (name[contains]=phone|category=Mobile). Parentheses and nested grouping are not supported. Sort and paging parameters cannot be part of an OR group.
For several values of the same field, [in] is shorter:
status[in]=Active,Pending
This is the same as status=Active|status=Pending.
Special parameters:
| Parameter | Purpose | Example |
|---|---|---|
$sort_by |
Sort by a field | $sort_by[desc]=price |
$page |
One-based page number | $page=2 |
$pagesize |
Items per page | $pagesize=25 |
Case sensitivity:
- Field names are matched case-insensitively against public properties of
T(price,PriceandPRICEall work). $sort_by,$page, and$pagesizeare matched case-insensitively, and so is theasc/descdirection.- Filter operators are case-sensitive and must be lowercase:
price[gt]=10works,price[GT]=10throwsREST_InvalidOperatorException.
The parser does not URL-decode its input. Some HTTP clients encode brackets, dollar signs, and spaces (for example $sort_by%5Bdesc%5D=price), so decode the query string before passing it to the parser. See ASP.NET Core Controller.
Supported operators:
| Operator | Description | Supported types |
|---|---|---|
eq |
Equal to | All supported types |
ne |
Not equal to | All supported types |
gt |
Greater than | All supported types except string, bool and Guid |
ge |
Greater than or equal to | All supported types except string, bool and Guid |
lt |
Less than | All supported types except string, bool and Guid |
le |
Less than or equal to | All supported types except string, bool and Guid |
contains |
String contains | string |
in |
Equal to any value in a comma-separated list | All supported types |
Supported CLR types:
stringintandint?doubleanddouble?decimalanddecimal?DateTimeandDateTime?DateTimeOffsetandDateTimeOffset?boolandbool?GuidandGuid?long,float,short,byte,sbyte,ushort,uint,ulong(and nullable versions)- Enums (and nullable enums)
DateOnlyandDateOnly?TimeOnlyandTimeOnly?TimeSpanandTimeSpan?
Filtering on a property of any other type (for example char or a navigation property) throws REST_InvalidFieldnameException. Sorting works on any property type.
String examples:
name=iPhone
name[ne]=Samsung
description[contains]=wireless
category=Electronics&brand=Apple
Numeric examples:
stock[gt]=10
stock[le]=100
price[lt]=999.99
rating[ge]=4.5
Date examples:
releaseDate[gt]=2023-01-01
releaseDate[ge]=2023-01-01&releaseDate[le]=2023-12-31
DateTimeOffset examples:
updatedAt[ge]=2024-06-01T09:00:00Z
updatedAt[lt]=2024-06-01T09:00:00-05:00
updatedAt[gt]=2024-06-01
DateTimeOffset values are compared as points in time, so 10:00+02:00 equals 08:00Z. A value without an offset (such as 2024-06-01) is treated as UTC. In a URL, + decodes to a space, so send positive offsets as %2B (2024-06-01T09:00:00%2B02:00) or use UTC with Z.
Boolean and GUID examples:
isActive=true
isDiscontinued[ne]=true
productId[eq]=123e4567-e89b-12d3-a456-426614174000
Enum examples:
status=Active
status=active
status=2
status[gt]=Draft
Enum values can be member names (case-insensitive) or underlying numbers. eq and ne compare the enum directly. gt, ge, lt and le compare the underlying numeric values, so the result follows the order the members are declared in, not alphabetical order.
DateOnly, TimeOnly and TimeSpan examples:
startDate[ge]=2024-01-01
opensAt[lt]=09:30
duration[gt]=01:30:00
duration[le]=1.00:00:00
Sort with $sort_by. Put the optional direction on the $sort_by parameter and the field name on the right side:
$sort_by=name
$sort_by[asc]=name
$sort_by[desc]=price
Multiple sort clauses are applied in order:
category=Electronics&$sort_by[asc]=brand&$sort_by[desc]=price
If no sort is supplied and the entity has an Id property (matched case-insensitively, so ID or id also count), REST-Parser sorts by it ascending. If the entity has no such property, no default sort is added. The default sort is included in SortOrder returned by Parse(), too.
Pagination is optional. Add $page and/or $pagesize to enable it:
$page=2&$pagesize=25
Behavior:
$pageis one-based.- If
$pageis supplied without$pagesize, page size defaults to25. - If
$pagesizeis supplied without$page, page defaults to1. - If neither parameter is supplied, results are not paginated.
- Page size is capped at
1000. - If the requested page is past the end, the parser moves it to the last available page.
RestResult<T> includes:
| Property | Description |
|---|---|
Data |
Filtered, sorted, and optionally paginated IQueryable<T> from Run() |
Page |
Current page number |
PageSize |
Items per page |
TotalCount |
Total matching items before pagination |
PageCount |
Total number of pages |
Expressions |
Filter expressions parsed from the query |
SortOrder |
Sort expressions parsed from the query |
To support direct query strings such as /api/products?category=Electronics&price[lt]=1000, pass the request query string to the parser. Request.QueryString.Value is still URL-encoded, so decode it first:
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using REST_Parser;
using REST_Parser.Models;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IRestToLinqParser<Product> parser;
private readonly AppDbContext context;
public ProductsController(IRestToLinqParser<Product> parser, AppDbContext context)
{
this.parser = parser;
this.context = context;
}
[HttpGet]
public IActionResult Get()
{
string query = WebUtility.UrlDecode(Request.QueryString.Value?.TrimStart('?') ?? "");
RestResult<Product> result = parser.Run(context.Products.AsNoTracking(), query);
return Ok(new
{
data = result.Data.Select(p => new
{
p.Id,
p.Name,
p.Category,
p.Price
}).ToList(),
pagination = new
{
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
pageCount = result.PageCount
}
});
}
}Sample requests:
GET /api/products
GET /api/products?category=Electronics
GET /api/products?price[gt]=100&price[lt]=1000
GET /api/products?description[contains]=wireless&isActive=true
GET /api/products?category=Electronics&$sort_by[asc]=price&$page=1&$pagesize=10Use Parse() when you want to inspect or apply the generated pieces yourself:
RestResult<Product> parsed = parser.Parse("category=Electronics&price[lt]=1000");
IQueryable<Product> query = context.Products
.Where(p => p.IsActive)
.Where(p => p.TenantId == tenantId);
foreach (var expression in parsed.Expressions.Where(e => e != null))
{
query = query.Where(expression);
}Expressions can contain null entries when the query has empty parts (for example a trailing &), so skip them as shown. Run() does this for you.
Use Run() when you want REST-Parser to apply filters, sorting, and pagination in one call:
RestResult<Product> result = parser.Run(
context.Products.AsNoTracking(),
"category=Electronics&price[lt]=1000&$sort_by[asc]=price&$page=1&$pagesize=20");
List<Product> products = result.Data.ToList();The RegisterRestParser<T>() extension is the preferred setup. If needed, you can register the services manually:
services.AddSingleton<IStringExpressionGenerator<Product>, StringExpressionGenerator<Product>>();
services.AddSingleton<IIntExpressionGenerator<Product>, IntExpressionGenerator<Product>>();
services.AddSingleton<IDateExpressionGenerator<Product>, DateExpressionGenerator<Product>>();
services.AddSingleton<IDateTimeOffsetExpressionGenerator<Product>, DateTimeOffsetExpressionGenerator<Product>>();
services.AddSingleton<IDoubleExpressionGenerator<Product>, DoubleExpressionGenerator<Product>>();
services.AddSingleton<IDecimalExpressionGenerator<Product>, DecimalExpressionGenerator<Product>>();
services.AddSingleton<IBooleanExpressionGenerator<Product>, BooleanExpressionGenerator<Product>>();
services.AddSingleton<IGuidExpressionGenerator<Product>, GuidExpressionGenerator<Product>>();
services.AddSingleton<INumericExpressionGenerator<Product>, NumericExpressionGenerator<Product>>();
services.AddSingleton<IEnumExpressionGenerator<Product>, EnumExpressionGenerator<Product>>();
services.AddSingleton<IDateOnlyExpressionGenerator<Product>, DateOnlyExpressionGenerator<Product>>();
services.AddSingleton<ITimeOnlyExpressionGenerator<Product>, TimeOnlyExpressionGenerator<Product>>();
services.AddSingleton<ITimeSpanExpressionGenerator<Product>, TimeSpanExpressionGenerator<Product>>();
services.AddSingleton<IRestToLinqParser<Product>, RestToLinqParser<Product>>();REST-Parser throws specific exceptions for invalid query parts:
using REST_Parser.Exceptions;
try
{
RestResult<Product> result = parser.Run(context.Products, query);
return Ok(result.Data.ToList());
}
catch (REST_InvalidFieldnameException ex)
{
return BadRequest(new { error = "Invalid field", details = ex.Message });
}
catch (REST_InvalidOperatorException ex)
{
return BadRequest(new { error = "Invalid operator", details = ex.Message });
}
catch (REST_InvalidValueException ex)
{
return BadRequest(new { error = "Invalid value", details = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { error = "Invalid query", details = ex.Message });
}Common causes:
| Exception | Typical cause | Example |
|---|---|---|
REST_InvalidFieldnameException |
Field does not exist on the entity, or its type can't be filtered | unknownField=value |
REST_InvalidOperatorException |
Operator is unknown, not lowercase, or not supported for that field type | name[gt]=test |
REST_InvalidValueException |
Value cannot be converted to the field type, or $page/$pagesize is not a positive integer |
price=abc, $page=0 |
ArgumentException |
Query too long, too many conditions, a part with no =, or an empty or sort/paging | alternative |
name, name=a| |
Built-in limits:
| Limit | Value |
|---|---|
| Maximum query length | 2000 characters |
| Maximum conditions | 50, counting each & part, each | alternative and each [in] value |
| Maximum page size | 1000 |
Other behavior to be aware of:
- String equality and
containsuse the underlying .NET string comparison behavior and are case-sensitive for in-memory queries. containsis only available forstringfields.&parts are combined with AND, and|alternatives within a part with OR. Parentheses and nested grouping are not supported.- Values may contain
=(only the first=separates field and value) but cannot contain&or|, since those separate conditions.[in]values cannot contain,. - Clients should URL-encode
|as%7C. As with brackets, decode the query string before passing it to the parser. - Any part containing
$sort_byor$pageanywhere (including in its value) is treated as a sort or paging parameter, not a filter. Unrecognised$page...parameters are ignored. - Numeric and date parsing uses invariant culture.
Run()keeps the result asIQueryable<T>, so database-backed providers such as EF Core can translate the generated query where supported.
- Use
AsNoTracking()for read-only EF Core query endpoints. - Apply tenant, user, or soft-delete filters before user-provided filters when you need isolation.
- Project to DTOs before returning API responses.
- Catch parser exceptions and return clear
400 Bad Requestresponses. - Keep API-specific page size limits stricter than
1000if your endpoint needs a smaller cap.
Example with tenant isolation:
RestResult<Product> parsed = parser.Parse(query);
IQueryable<Product> products = context.Products
.Where(p => p.TenantId == tenantId)
.Where(p => p.IsActive);
foreach (var expression in parsed.Expressions.Where(e => e != null))
{
products = products.Where(expression);
}Check that the field name maps to a public property on T:
name=iPhone
price[lt]=1000
Use an operator supported by the field type. For example, contains is valid for strings, but not for numbers or dates.
Make sure the value can be parsed as the field type:
price=999.99
releaseDate=2023-01-01
isActive=true
| always separates alternatives and , always separates [in] values, so a value containing either character is split. For example, name=Black|Decker is read as two alternatives, name=Black and Decker, and throws ArgumentException because Decker has no =. Filter on a different field, or use contains with part of the value.
Remember that & binds more loosely than |: a=1|b=2&c=3 means (a == 1 || b == 2) && c == 3.
Page, PageSize, TotalCount, and PageCount are populated by Run() when pagination is requested. Add $page or $pagesize if the endpoint needs those values.
Inspect the parsed expressions and test the base query before filters:
RestResult<Product> parsed = parser.Parse(query);
Console.WriteLine($"Filters: {parsed.Expressions.Count}");
Console.WriteLine($"Sorts: {parsed.SortOrder.Count}");
Console.WriteLine($"Base count: {context.Products.Count()}");Recent updates:
2.0.0- Filtering onDateTimeOffset,long,float,short,byte,sbyte,ushort,uint,ulong, enums,DateOnly,TimeOnlyandTimeSpan. OR conditions with|and the[in]operator. A filter part with no=now throwsArgumentException(as documented) instead ofREST_InvalidFieldnameException, and values can no longer contain|.1.4.0- Bug fixes for value splitting on=, nullable handling, culture-invariant parsing, exception accuracy, null-safe collection initialization, and unsupported type detection.1.3.0- Upgrade to .NET 10 with code improvements and optimizations.1.2.4- Package updates.1.2.3- Nullable boolean fix.1.2.2- DefaultIdsort when no sort is supplied.1.2.0- Upgrade to .NET 7.
See GitHub releases for more detail.
| Path | Contents |
|---|---|
REST-Parser/REST-Parser |
The library: RestToLinqParser<T>, per-type expression generators, exceptions, DI extension |
REST-Parser/RestParserTests |
MSTest unit tests |
REST-Parser/RestParserHost |
Placeholder console app |
MIT. Copyright (c) 2026 John McArthur. See LICENSE.