Shiny.DocumentDb
| GitHub | |
| Downloads |
dotnet add package Shiny.AspNet.DocumentDbTurns a Shiny.DocumentDb document type into a complete HTTP resource in one line, on the
ASP.NET Core app you already have. Every route is a plain RequestDelegate — nothing binds parameters by
reflection — and there is no reflection serializer fallback, so the resource is trim and Native AOT clean.
using Shiny.AspNet.DocumentDb;
builder.Services.AddDocumentStore(o => o.DatabaseProvider = new SqliteDatabaseProvider(path));
var app = builder.Build();
app.MapDocuments<Order>("/orders", o =>{ o.Operations = DocumentEndpoints.All; o.TypeInfo = AppJson.Default.Order; o.AllowFilterOn(x => x.Status, x => x.Total);}).RequireAuthorization("orders");That maps eight routes:
GET /orders |
List, with filtering, sorting, paging and sparse fieldsets |
GET /orders/{id} |
One document, with an ETag (and Accept-Patch when writes are mapped) |
GET /orders/count |
A count, honouring the same filter |
GET /orders/stream |
A live Server-Sent Events tail of inserts, updates and deletes |
POST /orders |
Create, answering 201 with a Location |
PUT /orders/{id} |
Replace, with optional If-Match |
PATCH /orders/{id} |
RFC 7396 JSON Merge Patch, or RFC 6902 JSON Patch |
DELETE /orders/{id} |
Delete |
Operations defaults to Read | Count — the safe half. A route that was never mapped is a routing
answer (404, or 405 when the path exists for another verb), not an authorization one.
Querying a list
Section titled “Querying a list”| Parameter | Meaning |
|---|---|
?filter= |
The document filter grammar — status == 'open' and total > 10 |
?orderby= |
total desc, name. Cursor paging needs a stable order; see DefaultOrderBy |
?fields= |
Sparse fieldset — id,total. Cannot be combined with cursor |
?skip= / ?take= |
Offset paging. take is clamped to MaxPageSize rather than refused |
?cursor= |
Keyset paging. The response is { items, nextCursor } rather than a bare array |
A list that is neither projected nor cursor-paged streams the stored JSON straight to the socket without ever materializing a CLR object — unless the type is encrypted, where only the typed path can decrypt, which the endpoint probes rather than assumes.
The field allow-list
Section titled “The field allow-list”AllowFilterOn decides what a client may filter, sort or project on. Empty means everything, which is
fine for an internal API and wrong for a public one, where an unlisted field should be a 400 rather
than a table scan.
o.AllowFilterOn(x => x.Status, x => x.Total);It is enforced lexically, before the filter reaches the grammar parser — the point is to refuse an
unlisted field early. String literals are skipped, so a value that looks like a field name is never
mistaken for one, and identifiers followed by ( are function calls, so the scalar-function library
keeps working. id is always allowed: it is in the route, in every response, and how a client
addresses a document at all.
Scopes
Section titled “Scopes”A scope is a server-side predicate AND-ed into every read and enforced on every write — typically the caller’s tenant or owner. There is no way for a request to remove one.
o.Scope<ITenantContext>((tenant, ctx) => x => x.TenantId == tenant.TenantId);A document outside the scope is 404 on read, write and delete — never 403, which would confirm the
record exists. Return DocumentScope.DenyAll<T>() for “no access”; the endpoints never treat “no
scope” as “everything”, and a callback returning null is an error rather than an open door.
A scope on a write is checked on both sides: the incoming document must fall inside it, and so must
the stored one. A PATCH that would move a document out of scope is refused after the patch is
applied, whichever patch format it used.
A scope callback is handed a DocumentScopeContext: ctx.Http, ctx.Services (the request scope),
ctx.User and ctx.CancellationToken. Scope<TService> resolves the service from the request scope and
hands you both:
o.Scope(ctx =>{ var tenant = ctx.Http.Request.Headers["X-Tenant"].FirstOrDefault() ?? ""; return x => x.TenantId == tenant;});Every Scope<TService> service is checked at map time (through IServiceProviderIsService, so a
scoped service is never resolved from the root) — a scope that cannot resolve its service would refuse
every request, so it is a startup error instead.
Concurrency
Section titled “Concurrency”When the document type has a version property mapped, reads carry an ETag and writes accept
If-Match:
o.ConfigureDocument<Order>(cfg => cfg.MapVersionProperty(x => x.Version)); // on the storeo.RequireIfMatch = true; // on the endpointA stale If-Match is 412. With RequireIfMatch, a write or delete that sends none is 428 Precondition Required. Without it, a client that does not speak ETags gets documented
last-writer-wins rather than a 412 it cannot act on.
PATCH: merge patch or JSON Patch
Section titled “PATCH: merge patch or JSON Patch”PATCH accepts two formats, chosen by the Content-Type header:
Content-Type |
Format |
|---|---|
application/json-patch+json |
RFC 6902 JSON Patch: a list of operations |
anything else (application/merge-patch+json, application/json, or none) |
RFC 7396 JSON Merge Patch, as before |
GET /{id} includes Accept-Patch: application/merge-patch+json, application/json-patch+json
when writes are mapped, so a client can see from a normal read that both formats are accepted.
Merge patch
Section titled “Merge patch”An explicit null in a merge-patch body removes the member:
PATCH /orders/a{"notes": null}The store’s own merge cannot work this way. A serialized document has a null for every unset member,
and treating those as deletions would erase fields nobody touched. A merge-patch body is different:
every member in it was written by the caller. The endpoints apply the merge themselves so that a
null means “remove” on every provider.
JSON Patch
Section titled “JSON Patch”JSON Patch can do things merge patch cannot: append to or remove from an array, move or copy a
value, set a member to null without deleting it, and test a value before changing anything:
PATCH /orders/aContent-Type: application/json-patch+jsonIf-Match: "3"
[ { "op": "test", "path": "/status", "value": "open" }, { "op": "replace", "path": "/status", "value": "closed" }, { "op": "remove", "path": "/notes" }]Paths use the names from the JSON response (/status, not /Status). The patch is applied to the
stored document all at once or not at all. Both formats go through the same write path, so the
same checks run whichever you send: the If-Match / RequireIfMatch check before the write, the
full-replace write, and the scope check after it.
| Malformed patch | 400 |
A test that fails, or a path that does not exist |
409, and nothing is written |
| A patch that turns the document into something other than an object | 422 |
Stale If-Match / missing when required |
412 / 428, the same as for merge patch |
| Document outside the scope, or moved out of it by the patch | 404 / 400, the same as for merge patch |
The schema-free MapDocumentCollection lane accepts JSON Patch too. A
merge patch there is sent to the store as a partial update. A JSON Patch is applied to the stored
document, and the result is written back as a full replace. This is because array positions, move
and test only make sense against the current document.
The live tail
Section titled “The live tail”DocumentEndpoints.Stream maps GET /stream as Server-Sent Events, one frame per change:
event: insertdata: {"id":"a","document":{ … }}The scope is evaluated once, when the connection opens — a stream can outlive any sane notion of
current permissions, so an app that needs re-authorization closes the connection. A delete carries no
document, so neither the scope nor a ?filter= can be checked against it; those events are dropped
rather than leaked past a scope that cannot be evaluated. StreamHeartbeat (30s) keeps proxies from
killing an idle connection; it is an SSE comment (: keep-alive), which EventSource consumes without
raising an event.
The stream is written directly rather than through TypedResults.ServerSentEvents, because that result
cannot send a comment line. It sets the same headers: Cache-Control: no-cache, Content-Encoding: identity (so response compression leaves it alone) and response buffering disabled.
Mapping Stream on a provider without change monitoring is a startup error, not a route that can
only ever return 501.
Schema-free collections
Section titled “Schema-free collections”MapDocumentCollection publishes a JSON collection that has no CLR type. Documents are plain JSON,
filtered with the string grammar. Relational providers only.
app.MapDocumentCollection("/events", "events", o =>{ o.Operations = DocumentEndpoints.Read | DocumentEndpoints.Count; o.IdProperty = "id"; o.AllowFilterOn("type", "createdAt");});Scopes here are grammar clauses rather than expressions, and they are enforced in SQL on every path —
including deletes, which resolve their target through the scoped query first. Inserts and replaces
are refused outright when a scope is present: a schema-free body has no evaluator, so the boundary
could not be enforced on the way in. Use MapDocuments<T> for scoped writes.
Composing policy
Section titled “Composing policy”MapDocuments and MapDocumentCollection map every route into one RouteGroupBuilder and return it, so
any endpoint convention lands on the whole resource:
app.MapDocuments<Order>("/orders", o => o.Operations = DocumentEndpoints.All) .RequireAuthorization("orders") .RequireRateLimiting("api") .RequireCors("app") .WithTags("Orders");That matters more than the syntax saved: adding an operation later cannot quietly leave one route unprotected.
OpenAPI
Section titled “OpenAPI”Each route carries the metadata ApiExplorer and Microsoft.AspNetCore.OpenApi read: an endpoint name used
as the operation id (ListOrder, GetOrder, CountOrder, StreamOrder, CreateOrder, ReplaceOrder,
PatchOrder, DeleteOrder; List-events and so on for a collection), a tag named after the type or
collection, the response types and problem statuses, and application/json request bodies on POST and
PUT. The names are endpoint names, so they must be unique in the app and also work with LinkGenerator.
PATCH deliberately declares no request content type: ASP.NET routing enforces declared content types,
and this one route reads merge patch under any JSON content type as well as JSON Patch. Because POST and
PUT declare application/json, a body sent with a non-JSON Content-Type there is a 415 from routing.
JSON metadata is required
Section titled “JSON metadata is required”o.TypeInfo = AppJson.Default.Order;There is no reflection serializer fallback when this is unset: the trim and AOT analyzers are on for every shipping project here, and a fallback that works under the JIT and throws in a trimmed or Native AOT deployment is worse than a clear error in both places. Leave it unset and the store’s own metadata is used; if there is none either, the first request fails with a message naming the property to set.
Compared with Shiny.DocumentDb.AspNetCore
Section titled “Compared with Shiny.DocumentDb.AspNetCore”Shiny.DocumentDb.AspNetCore |
This package | |
|---|---|---|
Missing TypeInfo |
Falls back to reflection | Clear error — no reflection fallback |
PATCH formats |
Merge patch | Merge patch, and JSON Patch for application/json-patch+json |
Everything else — the filter grammar, cursors, scopes, ETags, merge-patch semantics, the SSE frame shape — is the same, because the engine underneath is the same.
Before you expose one
Section titled “Before you expose one”A document store is usually the inside of an application. Publishing it over HTTP moves it to the
edge, and DocumentEndpoints.All includes delete. Put
authentication in front of it, prefer stating RequireAuthorization on
the resource so a new operation is protected by default, and set an AllowFilterOn allow-list before
anything untrusted can reach it.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


