Skip to content

About

A C# Parser for REST requests generating Linq expressions to be used in an API

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

144 Commits

Folders and files

Repository files navigation

REST-Parser

NuGet .NET

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.

Features

  • Dynamic filtering with equality, comparison, in, and string contains operators
  • OR conditions with |, alongside AND with &
  • Filtering on strings, all numeric types, bool, Guid, enums, DateTime, DateTimeOffset, DateOnly, TimeOnly and TimeSpan
  • 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

Contents

Installation

Install the NuGet package:

dotnet add package REST-Parser

Package Manager:

Install-Package REST-Parser

Package reference:

<PackageReference Include="REST-Parser" Version="2.0.0" />

Quick Start

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=20

Query Syntax

The 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, Price and PRICE all work).
  • $sort_by, $page, and $pagesize are matched case-insensitively, and so is the asc/desc direction.
  • Filter operators are case-sensitive and must be lowercase: price[gt]=10 works, price[GT]=10 throws REST_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.

Filtering

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:

  • string
  • int and int?
  • double and double?
  • decimal and decimal?
  • DateTime and DateTime?
  • DateTimeOffset and DateTimeOffset?
  • bool and bool?
  • Guid and Guid?
  • long, float, short, byte, sbyte, ushort, uint, ulong (and nullable versions)
  • Enums (and nullable enums)
  • DateOnly and DateOnly?
  • TimeOnly and TimeOnly?
  • TimeSpan and TimeSpan?

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

Sorting

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

Pagination is optional. Add $page and/or $pagesize to enable it:

$page=2&$pagesize=25

Behavior:

  • $page is one-based.
  • If $page is supplied without $pagesize, page size defaults to 25.
  • If $pagesize is supplied without $page, page defaults to 1.
  • 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

API Usage

ASP.NET Core Controller

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=10

Parse vs Run

Use 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();

Manual Registration

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>>();

Exception Handling

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|

Limits and Behavior

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 contains use the underlying .NET string comparison behavior and are case-sensitive for in-memory queries.
  • contains is only available for string fields.
  • & 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_by or $page anywhere (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 as IQueryable<T>, so database-backed providers such as EF Core can translate the generated query where supported.

Best Practices

  • 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 Request responses.
  • Keep API-specific page size limits stricter than 1000 if 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);
}

Troubleshooting

Invalid field name

Check that the field name maps to a public property on T:

name=iPhone
price[lt]=1000

Invalid operator

Use an operator supported by the field type. For example, contains is valid for strings, but not for numbers or dates.

Invalid value

Make sure the value can be parsed as the field type:

price=999.99
releaseDate=2023-01-01
isActive=true

OR or [in] returns unexpected results

| 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.

No pagination metadata

Page, PageSize, TotalCount, and PageCount are populated by Run() when pagination is requested. Add $page or $pagesize if the endpoint needs those values.

No results

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()}");

Version History

Recent updates:

  • 2.0.0 - Filtering on DateTimeOffset, long, float, short, byte, sbyte, ushort, uint, ulong, enums, DateOnly, TimeOnly and TimeSpan. OR conditions with | and the [in] operator. A filter part with no = now throws ArgumentException (as documented) instead of REST_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 - Default Id sort when no sort is supplied.
  • 1.2.0 - Upgrade to .NET 7.

See GitHub releases for more detail.

Links

Project Layout

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

License

MIT. Copyright (c) 2026 John McArthur. See LICENSE.

About

A C# Parser for REST requests generating Linq expressions to be used in an API

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages