Custom Named Routes

.WithCustomRoute(slug, ...) registers a named, pre-configured GET endpoint on an entity. The route is exposed at {entityRoute}/{slug} and returns a pre-filtered, pre-sorted subset of data — without writing any controller code.

Method signature

Signature
.WithCustomRoute(
    string slug,                                 // URL segment, e.g. "featured", "latest"
    Expression<Func<T, bool>>? filter = null,  // optional row predicate
    string? sort   = null,                       // optional Dynamic LINQ sort expression
    int     take   = 0,                           // max rows (0 = no built-in limit)
    bool    single = false)                      // true = return T|404, false = return T[]

All parameters except slug are optional and can be combined freely.

Examples

Featured products and latest order
// GET /api/products/featured — top-5 in-stock products by price descending
options.Entity<Product>("products")
    .WithCustomRoute("featured",
        filter: p => p.Stock > 0,
        sort:   "Price desc",
        take:   5);

// GET /api/products/cheap — up to 10 items under 50 units
options.Entity<Product>("products")
    .WithCustomRoute("cheap",
        filter: p => p.Price < 50m,
        sort:   "Price asc",
        take:   10);

// GET /api/orders/latest — single most recent non-cancelled order (or 404)
options.Entity<Order>("orders")
    .WithQueryFilter(o => o.Status != OrderStatus.Cancelled)
    .WithCustomRoute("latest",
        sort:   "CreatedAt desc",
        take:   1,
        single: true);

Request / Response

Custom routes accept the same query parameters as a regular GET collection request. Client-supplied parameters stack on top:

HTTP
GET /api/products/featured
GET /api/products/featured?filter[categoryId]=1   # client filter stacks (AND)
GET /api/products/featured?sort[name]=asc          # client sort overrides route sort
single valueResponse on matchResponse when empty
false (default)200 OK — JSON array200 OK — empty array []
true200 OK — JSON object404 Not Found

Pipeline (order of operations)

StepWhat happens
1Entity-level QueryFilters applied (access control / soft-delete)
2Custom route's own filter predicate applied
3?include= navigation properties eagerly loaded
4Client-supplied ?filter[...] applied (AND semantics)
5Sort: ?sort[...] → route sort → default Id desc
6single: true → return first item or 404
7take limit applied (client ?pagesize= overrides route take)

Multiple routes on the same entity

Multiple custom routes
options.Entity<Product>("products")
    .WithQueryFilter(p => p.IsActive)    // entity-level: hides inactive items globally
    .WithCustomRoute("featured",
        filter: p => p.Stock > 0,
        sort:   "Price desc",
        take:   5)
    .WithCustomRoute("recent",
        sort: "CreatedAt desc",
        take: 10);

// Live endpoints:
// GET /api/products/featured
// GET /api/products/recent

Constraints

ConstraintDetail
Unique slugs per entity Registering two routes with the same slug on the same entity throws InvalidOperationException at startup.
Reserved slugs forbidden The slugs count and pagedresult conflict with built-in OtterApi paths — using them throws at startup.
EF-translatable predicates only The predicate must be expressible in SQL, same as .WithQueryFilter().
Static at startup Predicates are compiled once and cannot reference request-scoped data.
GET only Custom routes are read-only. POST, PUT, PATCH, and DELETE fall through to the next middleware.