Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

REST & Live-Query Endpoints

Shiny.DocumentDb.AspNetCore turns a document type into a complete HTTP resource in one line — list, by-id, count, create, replace, merge-patch, delete, and a live Server-Sent-Events tail. Plain JSON, no third-party dependency, AOT-clean.

app.MapDocuments<Order>("/orders", o =>
{
o.Operations = DocumentEndpoints.All;
o.MaxPageSize = 100;
o.AllowFilterOn(x => x.Status, x => x.CustomerId, x => x.Total);
o.TypeInfo = AppJsonContext.Default.Order; // AOT
o.Scope<ITenantContext>((tenant, _) => x => x.TenantId == tenant.TenantId);
})
.RequireAuthorization("orders");
GET /orders?filter=status eq 'open'&orderby=total desc&take=20&fields=id,total
GET /orders/{id}
GET /orders/count?filter=…
GET /orders/stream # text/event-stream, live
POST /orders
PUT /orders/{id} # full replace, If-Match honored
PATCH /orders/{id} # JSON Merge Patch (RFC 7396)
DELETE /orders/{id} # If-Match honored

The store already has every primitive this needs — the string-expression grammar, Project(...) for sparse fieldsets, cursor pagination, optimistic concurrency, and change monitoring for the stream. This package is the HTTP shell over them.

samples/Sample.RestApi is a runnable API over the same seeded data as the OData sample, so the two can be compared request for request — allowlists, cursor paging, sparse fieldsets, the SSE tail behind a rate limiter, and an OpenAPI document. Browse http://localhost:5098/.

Both packages exist and both stay. They must never be mapped on the same route prefix.

Shiny.DocumentDb.AspNetCore (this page) Shiny.DocumentDb.AspNetCore.OData
Query syntax The store’s own grammar (filter=total > 500) OData v4 ($filter=Total gt 500)
Client SPAs, MAUI, internal service APIs, curl An existing OData toolchain, $metadata-driven clients
Live updates Yes — SSE at /{resource}/stream No
Writes Yes — POST/PUT/PATCH/DELETE No — query only
Dependencies None (framework reference only) Microsoft.AspNetCore.OData
AOT Clean JIT only (EDM is reflection-heavy)

Reach for OData when the client already speaks it. Reach for this when you own both ends.

DocumentEndpoints is a flags enum, and the default is the safe half — Read | Count. An operation you don’t map is not a route.

Flag Routes
Read GET /, GET /{id}
Count GET /count
Write POST /, PUT /{id}, PATCH /{id}
Delete DELETE /{id}
Stream GET /stream
All all of the above
Parameter Meaning
filter A string-grammar clause — status == 'open' and total > 500
orderby total desc, or several keys: country, total desc
skip / take Offset paging. take is clamped to MaxPageSize, not refused
cursor Cursor paging. Pass cursor= (empty) for the first page; the response is { items, nextCursor }
fields Sparse fieldset — fields=id,total returns only those keys. Pages with skip/take; cannot be combined with cursor

AllowFilterOn(...) names the fields a client may filter, sort or project on. An unlisted field is a 400 naming the field — not a table scan:

o.AllowFilterOn(x => x.Status, x => x.CustomerId, x => x.Total);

Leaving it empty means “everything”, which is fine for an internal API and wrong for a public one. The check is lexical and runs before the grammar parses anything: string literals are skipped (so a value that looks like a field name is not mistaken for one), and identifiers followed by ( are function calls, so the whole scalar-function library keeps working without being enumerated here.

id is always available whatever the allowlist says — it is in the route, it is in every response body, and refusing fields=id would protect nothing while making a sparse fieldset useless. A type whose identifier is mapped to a differently-named property still needs that name listed.

Scope(...) is the security-critical member. It runs per request, inside the request’s DI scope, and its predicate is AND-ed into every read and enforced on every write. There is no way for a request to remove it — the same contract as the AI tools’ non-removable Where.

app.MapDocuments<Order>("/orders", o =>
{
// resolve-a-service form — TService comes from the REQUEST scope
o.Scope<ITenantContext>((tenant, _) => x => x.TenantId == tenant.TenantId);
// async form — the service has to go and look
o.Scope<IPermissionService>(async (perms, ctx) =>
{
var regions = await perms.GetVisibleRegionsAsync(ctx.User, ctx.CancellationToken);
return x => regions.Contains(x.Region);
});
// raw form — anything else, with the whole request in hand
o.Scope(ctx => x => x.CustomerId == ctx.User.FindFirstValue("customer_id"));
})
.RequireAuthorization();

Rules:

  • Additive and AND-ed. Several Scope calls compose. There is no “replace the scope”.
  • Out of scope is 404, never 403 — on GET, PUT, PATCH and DELETE. A 403 would confirm the record exists.
  • A write that would land outside the scope is 400, and nothing is stored.
  • Deny-all is explicit. Return DocumentScope.DenyAll<T>() for “no access”; the endpoints never read “no scope” as “everything”.
  • A missing service is a startup error. Scope<TService> records the type, and MapDocuments asserts it is registered when the route is mapped — not at 3am on the first request that needs it.
  • Don’t do I/O in a scope callback if you can avoid it. It is on every request’s critical path for that resource. Push the answer into a scoped service your auth middleware already populates and use the sync Scope<TService> form; treat the async overload as the exception.

When the document type has a mapped version property, reads carry an ETag and writes honour If-Match:

GET /orders/o1 → 200, ETag: "3"
PUT /orders/o1 If-Match: "3" → 204 (ETag: "4")
PUT /orders/o1 If-Match: "3" → 412 (someone else got there first)

If-Match is optional by default — a client that doesn’t send it gets last-writer-wins. Set RequireIfMatch = true and a write or delete without the header is 428 Precondition Required.

ETags need the relational store (the version mapping is read from its options); without a mapped version property no ETag is emitted and If-Match is ignored.

A PATCH body is a JSON Merge Patch: unspecified members are preserved, and an explicit null removes the member.

PATCH /orders/o1
{"status":"closed","notes":null}

The merge is applied by the endpoint against the document it already read for the scope check, then written as a full replace through the type-keyed JSON collection — so it still rides the whole write pipeline (tenancy, temporal history, versioning/CAS, sidecars, interceptors). The store’s own merge deliberately treats a null as “leave alone” (it has to — a serialized CLR document carries a null for every unset member); over HTTP, every member in the body was written by the caller, so a null means remove.

Everything is ProblemDetails:

Status When
400 Malformed filter, a field not on the allowlist, a body that would land outside the scope
404 No such document — including one hidden by the scope
409 Duplicate id on create
412 Stale If-Match
428 RequireIfMatch and no If-Match header
501 The operation is not supported by the configured provider

A malformed filter= is always a 400, never a 500.

GET /{resource}/stream is a Server-Sent-Events feed of insert / update / delete events. No SignalR, no hub, no client library — EventSource in a browser, a plain HttpClient in MAUI, curl -N in a terminal.

event: insert
data: {"id":"o9","document":{ … }}
  • ?filter= applies, evaluated in memory against each change.
  • The scope is evaluated once, when the connection opens. A stream can outlive any sane notion of “current permissions”, so close the connection yourself (auth expiry, your own timeout) when re-authorization matters.
  • A keep-alive comment is emitted every StreamHeartbeat (30s by default). Proxies kill idle connections; the heartbeat is not optional.
  • Delete and clear events carry no document, so neither the scope nor the filter can be checked against them — they are dropped rather than leaked past a scope that cannot be evaluated.

/stream needs a provider that implements IObservableDocumentStore. Mapping it on one that doesn’t is a startup error, not a per-request 501. And because one connection is one long-lived request per client, put it behind rate limiting:

app.MapDocuments<Order>("/orders", o => o.Operations = DocumentEndpoints.All)
.RequireRateLimiting("stream");

MapDocumentCollection exposes a JSON collection — no CLR type, no mappings, documents in and out as raw JSON. Relational providers only.

app.MapDocumentCollection("/intake", "intake_forms", o =>
{
o.Operations = DocumentEndpoints.Read | DocumentEndpoints.Count;
o.IdProperty = "id";
o.AllowFilterOn("score", "submittedAt");
o.Scope<ITenantContext>((tenant, _) => $"tenantId == '{tenant.TenantId}'");
});

The scope is a string-grammar clause instead of an expression, and it is enforced in SQL on every path — including by-id and delete, which resolve their target through the scoped query first. A scoped collection refuses writes (501): a raw JSON body has no evaluator, so the boundary could not be checked on the way in, and the combination is refused rather than silently weakened. Use MapDocuments<T>() when you need scoped writes.

Every route is mapped as a RequestDelegate rather than a typed minimal-API handler — the Delegate overloads of MapGet reflect over the handler’s parameters, which would cost the package its AOT-clean promise. Set TypeInfo to your source-generated JsonTypeInfo<T> and no reflection is left on the path.

The list endpoint streams stored document bodies straight to the socket through the raw-JSON terminals — never buffered, never re-serialized. It probes SupportsRawJson rather than assuming: a type with encrypted properties can only be read through the typed path (which is what decrypts), so the endpoint falls back rather than turning into a 501, and the typed fallback serializes through the plaintext view so a client never receives an enc:1:… envelope.

By-id deliberately materializes: the scope is evaluated in memory there, so there is nothing to save — and the version for the ETag comes off the same instance.

This package is one host. If you need the same shape somewhere ASP.NET Core cannot go — an embedded server in a .NET MAUI app, a custom transport, a message pump — the pieces a host actually needs are public, in the Shiny.DocumentDb.Hosting namespace. Nothing here is specific to HTTP, and a host package can live in any repo without a change to DocumentDb first.

using Shiny.DocumentDb.Hosting;
// 1. A scope has to be checked against an INCOMING document, before there is anything to query against.
Expression<Func<Order, bool>> scope = x => x.TenantId == tenantId;
var inScope = DocumentPredicate.Compile(scope); // never uses Reflection.Emit
query = query.Where(scope); // push down for reads
if (!inScope(incoming)) // enforce on POST/PUT
return BadRequest();
// 2. A live tail applies ?filter= to changes arriving in memory, not through a query.
var filter = DocumentPredicate.Compile(DocumentFilter.Parse(request.Filter, AppJsonContext.Default.Order));
await foreach (var change in ((IObservableDocumentStore)store).NotifyOnChange<Order>(ct))
{
if (change.Document != null && filter(change.Document))
await WriteEvent(change);
}
// 3. ETag / If-Match needs to know whether the type has a mapped version property.
var version = store.GetVersionMapping(typeof(Order)); // null → this type has no ETag to publish
var etag = version?.GetVersion(order);
Member What it is for
DocumentPredicate.Compile<T>(expr) A delegate equivalent to expr.Compile() without Reflection.Emit
DocumentFilter.Parse<T>(filter, typeInfo) The string-expression grammar → an expression
DocumentFilter.ParseJson(filter, typeInfo?) The same, over a schema-free collection body
DocumentStoreAccessor.GetMappings(store) The store’s DocumentMappingRegistry, on any provider
DocumentStoreAccessor.GetVersionMapping(store, type) The optimistic-concurrency mapping, or null

Two things are worth being explicit about.

Compile is not Expression.Compile(), and the difference is the point. The BCL method generates IL at runtime, which forfeits the trimmed/Native-AOT guarantee of the app doing the hosting — exactly the environment an embedded server exists for. DocumentPredicate.Compile walks the tree instead. That it never generates code is the contract; the walk is the implementation.

It is the same evaluator the query layer uses. A host that wrote its own would eventually have the pushed-down predicate and the in-memory check disagree about what a scope means, and that disagreement is a scope bypass rather than a cosmetic difference. One interpreter, one set of semantics.

Everything else a host needs — IDocumentStore, IDocumentQuery<T>, IJsonDocumentCollection, DocumentChange<T>, IdAccessor<T>, ConcurrencyException — was already public.