Overlay overview
Bielu.Overlay.NET and Bielu.Overlay.NET.Readers provide a framework-free object model, reader,
validator, and apply engine for the Overlay Specification
— the OpenAPI Initiative's companion spec for declarative, repeatable transformations of an API
description.
An overlay is a small JSON/YAML document listing ordered actions. Each action selects nodes with a
JSONPath target, then merges (update), copies (copy), or deletes (remove) at those locations.
Instead of writing imperative C# to reshape a generated document, you write the transformation down as a
reviewable artifact that lives next to the description it changes.
⚠️ Note: Before version 1.0.0, this library is regarded as unstable and breaking changes may be introduced.
What makes this implementation different
The Overlay Specification is written against OpenAPI, and the .NET implementations that exist are built
on the Microsoft.OpenApi object model — so they can only transform an OpenApiDocument.
Nothing in the overlay mechanism is OpenAPI-specific, though: select by JSONPath, then
merge/copy/remove. This engine therefore operates on System.Text.Json.Nodes.JsonNode and nothing else,
which makes it a strict superset — OpenAPI, AsyncAPI, and Arazzo descriptions are all just JSON/YAML
trees, and all three are valid targets.
Applying an overlay to an AsyncAPI document is not something other tooling can do, and it is the reason this library exists in a repository that generates AsyncAPI.
⚠️ Applying overlays to non-OpenAPI documents is not officially supported by the specification.
The OAI closed Overlay-Specification#268 as
not plannedin February 2026, reasoning that although it "might technically be possible today", it "is not a 'core function' of this specification, which is intended for OpenAPI descriptions".We are trying to change that. #367 revisits the question, with draft PR #370 proposing a
targetFormatdeclaration. It argues chiefly that the OAI has already made this commitment elsewhere: released Arazzo 1.1.0 normatively defines a Source Description'stypeas"openapi" | "asyncapi" | "arazzo", has AsyncAPI-specific behaviour (correlationIdis "only applicable to asyncapi steps with action receive"), and resolves references against "an OpenAPI or AsyncAPI description". So Overlay could adopt an existing OAI value set rather than invent a registry.That discussion is open and the working group remains broadly aligned with the original decision, so treat AsyncAPI and Arazzo targeting as an extension this library offers, not a conformance claim. Behaviour against OpenAPI documents stays spec-exact regardless, so overlays you write for OpenAPI remain portable to other tooling.
Installation
dotnet add package Bielu.Overlay.NET
dotnet add package Bielu.Overlay.NET.Readers
Bielu.Overlay.NET depends only on JsonPath.Net (RFC 9535 JSONPath). YamlDotNet is confined to
Bielu.Overlay.NET.Readers, so consumers that only build and apply overlays in memory never pull in a
YAML parser.
Reading an overlay
using Bielu.Overlay.Readers;
var result = OverlayStringReader.Read(yamlOrJsonText);
if (result.HasErrors)
{
foreach (var diagnostic in result.Diagnostics)
{
Console.WriteLine(diagnostic); // "error at /actions/0/target: ..."
}
return;
}
var overlay = result.Document!;
OverlayStringReader auto-detects JSON vs. YAML from the input's first non-whitespace character.
OverlayStreamReader and OverlayTextReader cover the Stream/TextReader equivalents. Reading never
throws for malformed input — problems come back as diagnostics on the returned OverlayReadResult.
Applying an overlay
using System.Text.Json.Nodes;
using Bielu.Overlay;
var document = JsonNode.Parse(File.ReadAllText("asyncapi.json"));
var result = OverlayApplier.Apply(document, overlay);
Console.WriteLine(result.Document!.ToJsonString());
Apply never mutates the document you pass in — it works on a deep copy and returns a distinct tree, so
the same overlay can safely be applied to several documents in turn.
Application is best-effort: an action that fails is reported in Diagnostics and skipped, and the
remaining actions still run. Check result.HasErrors to decide whether to trust the output.
Strict mode
The specification permits a target that matches nothing — it is simply a no-op. That is convenient
when authoring and dangerous in CI, where it usually means the overlay has drifted out of sync with the
document it transforms. Strict promotes those from warnings to errors:
var result = OverlayApplier.Apply(document, overlay, new OverlayApplyOptions { Strict = true });
Validating an overlay
using Bielu.Overlay.Validation;
foreach (var diagnostic in OverlayValidator.Validate(overlay))
{
Console.WriteLine(diagnostic);
}
OverlayValidator checks the overlay on its own terms, with no target document in hand: a recognized
version, non-empty info.title/info.version, at least one action, target/copy expressions that
actually parse as RFC 9535 JSONPath, and copy used only where the declared version supports it.
A worked example: publishing a public AsyncAPI document
Suppose your service generates this internally:
{
"asyncapi": "3.0.0",
"info": { "title": "Streetlights", "version": "1.0.0" },
"channels": {
"lightMeasured": { "address": "light/measured" },
"internalDebug": { "address": "internal/debug" }
}
}
An overlay strips the internal channel and adds partner-facing metadata:
overlay: 1.1.0
info:
title: Public distribution of the Streetlights API
version: 1.0.0
actions:
- target: $.channels.internalDebug
description: Internal diagnostics channel, not for partners
remove: true
- target: $.info
update:
description: Public event API for the Streetlights platform
x-audience: partner
Applying it yields a document with internalDebug gone and info enriched — with the original
untouched on disk.
Targeting Arazzo documents
⚠️ As above, Arazzo is not a specification-sanctioned overlay target — see #268 (closed
not planned) and the open #367 we filed to revisit it.
Arazzo works too, but it is worth knowing that it targets differently, because of how the specification shapes its collections:
| Specification | Collection | Shape | Targeting |
|---|---|---|---|
| OpenAPI | paths |
map keyed by path | $.paths['/items'] |
| AsyncAPI | channels |
map keyed by name | $.channels.lightMeasured |
| Arazzo | workflows, steps, sourceDescriptions |
array of objects carrying an id field | $.workflows[?@.workflowId == 'measureAndAlert'] |
There is no $.workflows.measureAndAlert to target — a workflow is an array element, not a map entry, so
every Arazzo target is a filter expression. That leans directly on RFC 9535 filters, which is one
practical reason to declare overlay: 1.1.0: 1.0.0 never pinned the JSONPath dialect, so filter support
there varies between tools.
overlay: 1.1.0
info:
title: Public distribution of the Streetlights workflows
version: 1.0.0
actions:
# Remove a whole workflow, selected out of the array by its id
- target: $.workflows[?@.workflowId == 'internalDiagnostics']
remove: true
# Remove a step from whichever workflow contains it — a filter inside a filter
- target: $.workflows[*].steps[?@.stepId == 'dumpDebugState']
remove: true
# Merge into a workflow; `summary` and `steps` survive because object targets merge
- target: $.workflows[?@.workflowId == 'measureAndAlert']
update:
description: Publishes a measurement, then waits for the alert it triggers.
# Append to an array
- target: $.sourceDescriptions
update:
name: events
url: https://example.com/asyncapi.json
type: asyncapi
Because removals here delete array elements, several matches in the same array shift each other's
indexes. The engine resolves each match's index against the live array at removal time, so
$.workflows[*].steps[?@.stepId == 'debug'] correctly removes every matching step across every workflow
regardless of the order matches come back in.
Action semantics
Actions apply in sequence, each against the result of the previous one. That is what lets an overlay delete a node in one action and re-create it in a later one.
target selects |
update behaviour |
|---|---|
| an object | its properties are recursively merged into the selected object |
| an array | an array concatenates element-wise (1.1.0); anything else appends as one entry |
| a primitive | the value replaces it in place (1.1.0 only) |
remove deletes the selected nodes from the object or array containing them. copy takes a JSONPath
selecting a single node elsewhere in the same document and applies it to the targets using the same
rules as update — sequenced with update or remove, that expresses moves and renames.
update, copy, and remove are not mutually exclusive in the specification. Where more than one is
present the precedence is remove > copy > update (update "has no impact" when outranked); the
validator reports the redundancy as a warning rather than rejecting the document.
Interpretation notes
Two places where the specification leaves room, and what this implementation does:
- Nested arrays during a recursive merge are replaced, not concatenated. The concatenate/append rule
is defined for a
targetthat selects an array node; extending it to arrays met partway through a merge would make it impossible to overwrite an array at all. - A
nullupdateis treated as absent. JSONnullhas no sanctioned meaning forupdate— deleting isremove's job, not a null-merge as in JSON Merge Patch (RFC 7386).
Version differences
The library targets 1.1.0 and accepts 1.0.0, gating semantics on the document's own overlay field. An
unrecognized version warns and falls back to 1.1.0 semantics rather than refusing to apply.
| Aspect | 1.0.0 | 1.1.0 |
|---|---|---|
target grammar |
"a JSONPath expression" — unpinned | RFC 9535 JSONPath |
| Legal target nodes | objects and arrays only | objects, arrays, and primitives |
update on an array |
appends a single entry | concatenates an array; appends anything else |
update on a primitive |
not permitted | replaces in place |
remove of a primitive array item |
not permitted | supported |
copy |
— | new |
Using a 1.1.0-only feature in a document that declares overlay: 1.0.0 is reported as an error rather
than silently applied.
extends is never dereferenced
An overlay may carry extends, a URI identifying the document it was written for. This library exposes
it on the model but never fetches it. Resolving it over the network from inside a hosted application
would let an overlay file drive outbound requests; use it at most to verify that the document you are
about to transform is the one the overlay expected.
Applying overlays in an ASP.NET Core app
Everything above transforms a document you already have in hand. If the document is one your own app
generates, you can skip the file-in/file-out step entirely: Bielu.AspNetCore.AsyncApi.Overlay and
Bielu.AspNetCore.Arazzo.Overlay apply overlays inside the generation pipeline, so the document served
by MapAsyncApi() / MapArazzo() is already transformed.
builder.Services.AddAsyncApi("v1", options =>
{
options.AddOverlay("overlays/public.yaml");
});
See Pipeline Integration for ordering, strict mode, failure behaviour, and how this interacts with build-time document generation.
CLI Tool: dotnet overlay
Bielu.Overlay.Cli provides apply and validate commands for overlays and the descriptions they
transform — see the CLI Tool article.
dotnet tool install -g Bielu.Overlay.Cli
dotnet overlay apply --file asyncapi.json --overlay public.overlay.yaml --output public.json --strict
Example project
A runnable end-to-end sample lives in
src/examples/OverlayDemo.
It runs the same engine twice — once over an AsyncAPI document and once over an Arazzo one — printing
before/after and diagnostics for each, so the map-keyed and array-keyed targeting styles sit side by side.
cd src/examples/OverlayDemo
dotnet run