Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

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:

Terminal window
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)
OptionMaps toNotes
$filter.Where(...)eq ne gt ge lt le, and or not, contains/startswith/endswith, null checks, nested paths
$orderbyOrderBy / OrderByDescending (string)asc/desc, dotted paths
$top.Paginate(skip, top) → take
$skip.Paginate(skip, top) → offsetcombined 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.

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

The 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 keyPropertyedm.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;
});
});
PolicyEffectOn violation
DefaultPageSizePage size used when $top is omitted— (applied)
MaxTopLargest $top; also clamps the effective page size400
MaxSkipLargest $skip400
MaxFilterNodeCount / MaxOrderByNodeCountFilter-tree / order-by complexity400
AllowFilter / AllowOrderBy / AllowSelect / AllowCountDisable a whole system option400
AllowArithmeticPermit add sub mul div mod in $filter400
AllowedFunctionsAllowlist of $filter functions (empty = all)400
FilterableProperties / SortableProperties / SelectablePropertiesPer-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.

  • Shiny.DocumentDb.OData — a reusable translator engine. It takes parsed OData query options + a target type, drives an IDocumentQuery<T>, and materializes. It carries no Microsoft.OData dependency and is AOT/trim-clean — usable on its own for filter translation.
  • Shiny.DocumentDb.AspNetCore.OData — the host. It uses Microsoft.AspNetCore.OData for 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.

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

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.