OData Endpoints
Expose an IDocumentStore document type as an OData v4 entity set over HTTP. A client issues
GET /odata/customers?$filter=Country eq 'CA'&$orderby=Created desc&$top=20&$skip=40&$count=true&$select=Name,Country
and the library translates those system query options onto the fluent IDocumentQuery<T>, runs them
against whatever provider is configured, and returns an OData-shaped JSON payload.
This is a read/query integration. Two packages:
dotnet add package Shiny.DocumentDb.OData # translator engine (dependency-free, AOT-clean)dotnet add package Shiny.DocumentDb.AspNetCore.OData # ASP.NET Core host (EDM + endpoints)Supported query options
Section titled “Supported query options”| Option | Maps to | Notes |
|---|---|---|
$filter | .Where(...) | eq ne gt ge lt le, and or not, contains/startswith/endswith, null checks, nested paths |
$orderby | OrderBy / OrderByDescending (string) | asc/desc, dotted paths |
$top | .Paginate(skip, top) → take | |
$skip | .Paginate(skip, top) → offset | combined into one call |
$count=true | .Count() | returned as @odata.count, computed pre-paging |
$select | .Project(fields) | server-side sparse fieldsets |
$expand returns 501 Not Implemented — documents are self-contained JSON with no relationships, so
there is no analog.
Hosting
Section titled “Hosting”builder.Services .AddDocumentStore(...) .AddDocumentODataEndpoints(edm => { edm.EntitySet<Customer>("customers"); edm.EntitySet<Order>("orders"); });
app.MapDocumentODataEntitySet<Customer>("odata/customers");// GET odata/customers?$filter=Country eq 'CA'&$orderby=Created desc&$top=20&$count=trueThe endpoint pulls the parsed query options from the request, converts them into the engine’s
provider-neutral ODataQueryModel, executes against the store, and serializes the OData envelope
(@odata.context, @odata.count, value). The EDM key defaults to Id; for a custom key pass
keyProperty — edm.EntitySet<Customer>("customers", keyProperty: "CustomerId") — so @odata.id links
are correct.
Governance — caps, allowlists & complexity limits
Section titled “Governance — caps, allowlists & complexity limits”A public OData endpoint should never be wide open. Each entity set carries an ODataQueryPolicy:
configure API-wide defaults with ConfigureDefaultPolicy(...) and override per set via the
EntitySet<T>(name, policy => …) overload (the override starts from a clone of the defaults, so it only
states the differences). A request that violates the policy is rejected with 400 Bad Request and a
message naming the offending option/property.
builder.Services.AddDocumentODataEndpoints(edm =>{ edm.ConfigureDefaultPolicy(p => { p.DefaultPageSize = 25; // applied when the client omits $top — bounds unbounded reads p.MaxTop = 100; // a larger $top is rejected (400); the page size is clamped to it p.MaxSkip = 10_000; p.MaxFilterNodeCount = 50; // reject pathologically complex $filter trees (DoS guard) p.MaxOrderByNodeCount = 3; });
edm.EntitySet<Customer>("customers", p => { // Only these properties may be filtered / sorted / selected (root segment for nested paths). p.FilterableProperties.UnionWith(["Name", "Country", "Age", "IsActive"]); p.SortableProperties.UnionWith(["Name", "Age"]); p.SelectableProperties.UnionWith(["Id", "Name", "Country", "Age"]); // p.AllowCount = false; p.AllowSelect = false; // turn off whole options // p.AllowedFunctions.UnionWith(["startswith"]); // empty = all functions allowed // p.AllowArithmetic = false; });});| Policy | Effect | On violation |
|---|---|---|
DefaultPageSize | Page size used when $top is omitted | — (applied) |
MaxTop | Largest $top; also clamps the effective page size | 400 |
MaxSkip | Largest $skip | 400 |
MaxFilterNodeCount / MaxOrderByNodeCount | Filter-tree / order-by complexity | 400 |
AllowFilter / AllowOrderBy / AllowSelect / AllowCount | Disable a whole system option | 400 |
AllowArithmetic | Permit add sub mul div mod in $filter | 400 |
AllowedFunctions | Allowlist of $filter functions (empty = all) | 400 |
FilterableProperties / SortableProperties / SelectableProperties | Per-property allowlists (empty = all) | 400 |
Defaults are fully permissive — an unconfigured policy changes nothing, so existing endpoints keep
working until you opt in. ODataQueryPolicy lives in the dependency-free engine package, so the same
limits apply to any non-HTTP caller of the engine too.
Architecture & AOT
Section titled “Architecture & AOT”Shiny.DocumentDb.OData— a reusable translator engine. It takes parsed OData query options + a target type, drives anIDocumentQuery<T>, and materializes. It carries noMicrosoft.ODatadependency and is AOT/trim-clean — usable on its own for filter translation.Shiny.DocumentDb.AspNetCore.OData— the host. It usesMicrosoft.AspNetCore.ODatafor full$metadata/EDM/parse compliance and feeds the parsed filter tree into the same engine. Because the EDM stack is reflection-heavy, the host package is JIT-only (server-side) — it does not carry the AOT guarantee the engine does.
Capability tiers
Section titled “Capability tiers”$filter/$orderby/$top/$skip/$count/$select work on every provider (they ride
.Where/.Paginate/.Project/.Count). Spatial OData geo functions (where supported) require a
spatial-capable provider; a spatial filter on a non-spatial provider returns 501 rather than silently
dropping the predicate.
Runnable sample
Section titled “Runnable sample”samples/Sample.ODataApi in the repo is a minimal ASP.NET Core app that wires this up against a
SQLite store: it registers a seeder (AddDocumentSeeder) that fills the store once at startup with
~250 customers and ~1,000 orders generated by Bogus
(using a fixed random seed, so the data is identical on every run) plus a few stable anchor records,
then exposes both as entity sets. The app root serves a static query explorer
(wwwroot/index.html) — pick a sample query or write your own, run it, and inspect the OData envelope
in a table or a syntax-highlighted JSON viewer; you can also save your own queries (kept in the browser
via localStorage). The sample list is a plain SAMPLES object at the top of the page script, so
adding your own is a one-line edit. Run it with dotnet run --project samples/Sample.ODataApi and
browse http://localhost:5099/.
The store itself runs reflection-free — it’s configured with a source-generated JsonSerializerContext
(UseReflectionFallback = false), which is the fast, AOT/trim-safe serialization path (only the OData EDM
layer remains reflection-based; that part is JIT-only by design). The seeder logs how long the bulk write
took on startup, so you can see it. The entity set is also locked down with an ODataQueryPolicy (page-size
caps + per-property allowlists) — see Governance.