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 value | Response on match | Response when empty |
|---|---|---|
false (default) | 200 OK — JSON array | 200 OK — empty array [] |
true | 200 OK — JSON object | 404 Not Found |
Pipeline (order of operations)
| Step | What happens |
|---|---|
| 1 | Entity-level QueryFilters applied (access control / soft-delete) |
| 2 | Custom route's own filter predicate applied |
| 3 | ?include= navigation properties eagerly loaded |
| 4 | Client-supplied ?filter[...] applied (AND semantics) |
| 5 | Sort: ?sort[...] → route sort → default Id desc |
| 6 | single: true → return first item or 404 |
| 7 | take 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
| Constraint | Detail |
|---|---|
| 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. |