Skip to content
Shiny.NET

Shiny.DocumentDb

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.DocumentDb
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.DocumentDb

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

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.

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.

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.

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 store
o.RequireIfMatch = true; // on the endpoint

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

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 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/a
Content-Type: application/json-patch+json
If-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.

DocumentEndpoints.Stream maps GET /stream as Server-Sent Events, one frame per change:

event: insert
data: {"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.

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.

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.

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.

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.

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.

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.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

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

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

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

View shiny-aspnet Skill