EnGen.OhData.AspNetCore.OpenApi
1.7.0
dotnet add package EnGen.OhData.AspNetCore.OpenApi --version 1.7.0
NuGet\Install-Package EnGen.OhData.AspNetCore.OpenApi -Version 1.7.0
<PackageReference Include="EnGen.OhData.AspNetCore.OpenApi" Version="1.7.0" />
<PackageVersion Include="EnGen.OhData.AspNetCore.OpenApi" Version="1.7.0" />
<PackageReference Include="EnGen.OhData.AspNetCore.OpenApi" />
paket add EnGen.OhData.AspNetCore.OpenApi --version 1.7.0
#r "nuget: EnGen.OhData.AspNetCore.OpenApi, 1.7.0"
#:package EnGen.OhData.AspNetCore.OpenApi@1.7.0
#addin nuget:?package=EnGen.OhData.AspNetCore.OpenApi&version=1.7.0
#tool nuget:?package=EnGen.OhData.AspNetCore.OpenApi&version=1.7.0
OhData
Convention-based OData 4.0 server and typed client for ASP.NET Core. Define a profile class, assign handler delegates, and get a spec-faithful OData API - no controllers required (see docs/spec-compliance.md for exactly what's covered). Consume it from .NET with a fluent, LINQ-native client.
📖 Documentation site: en-gen.github.io/OhData — getting started, the EF Core walkthrough, and every feature guide.
Try it live — fire real $filter/$orderby/$expand queries (writes too) at a deployed OhData demo service from an interactive API reference, or hit the raw v2 service document directly:
(Free-tier hosting: the first load after a quiet spell takes a moment to wake up, and demo data is ephemeral — anything you write disappears whenever the instance recycles.)
Or run it locally: the clone-and-run EF Core + SQLite sample puts a real relational database behind OhData and logs the SQL, so you can watch $filter/$orderby/$top become WHERE/ORDER BY/LIMIT.
Getting Started
Install the server package:
dotnet add package EnGen.OhData.AspNetCore
Install the client package:
dotnet add package EnGen.OhData.Client
Packages
| Package | What it does |
|---|---|
| The server framework. | |
| The typed LINQ client. | |
Optional API-documentation companions — each documents the OData query parameters ($filter, $orderby, $top, ...) in its respective OpenAPI stack with one line of registration. |
Server quick start
// 1. Define your entity
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
// 2. Create a profile - assign only the handlers you need
public class ProductProfile : EntitySetProfile<int, Product>
{
public ProductProfile(AppDbContext db) : base(x => x.Id)
{
FilterEnabled = true;
OrderByEnabled = true;
CountEnabled = true;
SelectEnabled = true;
// IQueryable path: returning the un-materialized queryable is synchronous, so this one
// handler stays Task.FromResult; EF Core translates $filter/$orderby/$skip/$top and
// materializes the result asynchronously when the framework enumerates it.
GetQueryable = _ => Task.FromResult<IQueryable<Product>>(db.Products);
GetById = async (id, ct) => await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
Post = async (p, ct) => { db.Products.Add(p); await db.SaveChangesAsync(ct); return p; };
Put = async (id, p, ct) => { db.Products.Update(p); await db.SaveChangesAsync(ct); return p; };
Patch = async (id, delta, ct) =>
{
var e = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (e is null) return null;
delta.Patch(e);
await db.SaveChangesAsync(ct);
return e;
};
Delete = async (id, ct) =>
{
var e = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (e is null) return false;
db.Products.Remove(e);
await db.SaveChangesAsync(ct);
return true;
};
}
}
// 3. Register in Program.cs
builder.Services.AddOhData(o => o
.WithPrefix("/odata")
.AddEntitySetProfile<ProductProfile>() // list profiles explicitly
// ...or scan assemblies for every EntitySetProfile they contain:
.AddProfilesFromAssembly(Assembly.GetExecutingAssembly()) // by assembly instance
.AddProfilesFromAssemblyOf<ProductProfile>()); // by marker type
// 4. Map endpoints after app.Build()
app.MapOhData();
This produces:
| Method | Route | Handler |
|---|---|---|
GET |
/odata |
Service document |
GET |
/odata/$metadata |
CSDL (EDM) |
GET |
/odata/Products |
GetQueryable - supports $filter, $orderby, $skip, $top, $select, $count |
GET |
/odata/Products/$count |
filtered row count |
GET |
/odata/Products({key}) |
GetById |
GET |
/odata/Products({key})/Name |
individual property (OData envelope) - rides GetById |
GET |
/odata/Products({key})/Name/$value |
raw property value (text/plain) |
PUT/PATCH |
/odata/Products({key})/Name |
set an individual property ({"value":...}) - rides Patch |
DELETE |
/odata/Products({key})/Name |
set an individual property to null - rides Patch |
POST |
/odata/Products |
Post |
PUT |
/odata/Products({key}) |
Put |
PATCH |
/odata/Products({key}) |
Patch |
DELETE |
/odata/Products({key}) |
Delete |
Only routes with a handler assigned are registered. Unassigned handlers produce no route.
OpenAPI / Swagger documentation
Each OpenAPI stack has an optional companion package that documents the OData query parameters
($filter, $orderby, $top, $skip, $select, $expand, $count, $search) on OhData
endpoints, driven by each entity set's capability flags. Install the one matching your stack and
call its one-line AddOhData() — the canonical wiring recipe that registers both the operation and
schema components; the core package has no dependency on any OpenAPI stack:
| Package | Registration |
|---|---|
EnGen.OhData.AspNetCore.OpenApi |
builder.Services.AddOpenApi(o => o.AddOhData()); |
EnGen.OhData.AspNetCore.Swashbuckle |
builder.Services.AddSwaggerGen(c => c.AddOhData()); |
EnGen.OhData.AspNetCore.NSwag |
builder.Services.AddOpenApiDocument((s, sp) => s.AddOhData(sp)); |
On the OpenApi and NSwag variants, AddOhData takes optional authRequirements / securitySchemeId
parameters to also reflect OhData's per-operation authorization (security requirement + 401/403)
into the document.
See docs/openapi.md, docs/swashbuckle.md, docs/nswag.md, and docs/versioning.md (multi-doc / versioned setup) for details.
Beyond the basics
The rest of the surface rides other profile declarations - navigation properties (HasMany/HasOptional/HasRequired), UseETag, and BindFunction/BindAction - rather than the plain CRUD handlers above. Each declaration registers its routes; the trailing comments show what you get:
public class OrdersProfile : EntitySetProfile<int, Order>
{
public OrdersProfile(AppDbContext db) : base(x => x.Id)
{
GetQueryable = _ => Task.FromResult<IQueryable<Order>>(db.Orders);
// Collection navigation. getAll gives the read routes; every parameter after it is
// OPTIONAL - supply only the ones whose route you want:
HasMany(
navigation: x => x.Lines,
getAll: async (orderId, ct) => await db.Lines.Where(l => l.OrderId == orderId).ToListAsync(ct),
// GET /Orders({key})/Lines (+ /Lines/$count)
post: (orderId, line, ct) => /* … */, // optional → POST /Orders({key})/Lines (create a related entity)
addRef: (orderId, lineId, ct) => /* … */, // optional → POST/PUT /Orders({key})/Lines/$ref (link existing)
removeRef: (orderId, lineId, ct) => /* … */, // optional → DELETE /Orders({key})/Lines/$ref (unlink)
refTargetEntitySet: "Lines"); // optional → $ref routes emit @odata.id links
// Single-valued navigation → GET /Orders({key})/Customer.
HasOptional(
navigation: x => x.Customer,
get: async (orderId, ct) =>
await db.Orders.Where(o => o.Id == orderId).Select(o => o.Customer).FirstOrDefaultAsync(ct));
// ETag response header + If-Match concurrency on GET/PUT/PATCH/DELETE.
UseETag(x => x.RowVersion);
// Bound operations become routes. The entity-bound pair takes the key as its first parameter.
BindFunction(Discounted); // GET /Orders/Discounted?minOff=…
BindAction(Archive); // POST /Orders/Archive
BindEntityFunction(Total); // GET /Orders({key})/Total
BindEntityAction(Approve); // POST /Orders({key})/Approve
}
static Task<IEnumerable<Order>> Discounted(decimal minOff) => /* … */;
static Task Archive() => /* … */;
static Task<decimal> Total(int key) => /* … */; // first parameter is the entity key
static Task Approve(int key, string note) => /* … */; // first parameter is the entity key
}
HasMany(x => x.Lines) on its own — with no handlers — registers no routes at all; it just declares the navigation for $metadata and $expand. The same optional-parameter pattern applies to HasOptional/HasRequired.
See docs/navigation-routing.md, docs/property-access.md, docs/deep-insert.md, and docs/bound-operations.md for the full details behind each declaration.
And to shrink the surface instead of growing it: Ignore(x => x.CostBasis) hides a property
from $metadata, query options, routes, and every request/response body — without touching the
CLR model. One exception: overriding AdvancedConfigure takes the EDM out of OhData's hands,
which drops Ignore()'s EDM half — the property is back in $metadata and query-addressable,
though still absent from every response body. MapOhData() emits a startup Warning naming each
affected property and the one-line remedy. See
docs/ignoring-properties.md.
Authorization
OhData rides ASP.NET Core's own authentication and authorization — you keep your existing scheme, policies, roles and IAuthorizationHandlers, and profiles never reference an ASP.NET Core type. What OhData adds on top is a declaration layer: five operation categories (Read/Create/Update/Delete/Invoke, plus the Writes and All selectors) that map to routes, .RequireResource() for instance-level checks, per-operation Invoke(name, …) rules with their own startup validation, an authorize lambda on unbound operations, and a startup audit that warns about routes left anonymous in a registration that requires authorization elsewhere. Requirements are stored as plain policy/role/claim names and replayed onto the endpoints; the evaluation is entirely ASP.NET Core's.
Protect a whole entity set with one call:
RequireAuthorization("AdminOnly"); // or RequireAuthorization() / RequireRoles("Admin")
…or authorize per operation with ConfigureAuthorization, whose per-category lambdas mirror AuthorizationPolicyBuilder (requirements accumulate and AND):
ConfigureAuthorization(auth => auth
.Read(r => r.AllowAnonymous()) // catalog reads are public
.Create(c => c.RequirePolicy("Editors"))
.Update(u => u.RequireRole("Editors").RequireResource()) // Editor AND owns the row
.Delete(d => d.RequireRole("Admin"))
.Invoke("Approve", i => i.RequirePolicy("Approvers")));
The requirements above are coarse — they answer "can this kind of user touch this operation." .RequireResource() adds the instance-level check "can this user touch this row" (owner checks, tenant isolation). OhData loads the {key} entity and hands it to ASP.NET Core's native resource-based authorization, so you write one standard handler:
// profile: an Update must come from an Editor who also owns the row
ConfigureAuthorization(auth => auth
.Update(u => u.RequireRole("Editors").RequireResource()));
// handler: the resource IS the loaded entity; requirement.Name selects the operation
public sealed class OrderAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx, OperationAuthorizationRequirement req, Order order)
{
if (req.Name == OhDataOperations.Update.Name &&
order.OwnerId == ctx.User.FindFirst("sub")?.Value)
ctx.Succeed(req); // this Editor owns this order → allow
return Task.CompletedTask;
}
}
// Program.cs: services.AddScoped<IAuthorizationHandler, OrderAuthorizationHandler>();
So a request to PATCH /odata/Orders(42) runs the role check (must be an Editors member) and loads order 42 and asks the handler whether this caller owns it — both must pass. The check covers property/navigation/$ref routes too (the resource is the parent entity in the path), so none of this profile's routes escapes this profile's rule. .RequireResource("PolicyName") evaluates a named policy against the entity instead. Profiles stay free of ASP.NET Core types — requirements are stored as plain policy/role/claim names.
Read that scope literally: a rule is per profile, and it does not compose across a navigation. A navigation is authorized by the profile that declares it, never by the profile that owns its target entity set — so if Customers declares a navigation into a separately registered, more strictly gated Tickets set, the nav GET, its /$count, all four $ref routes, the navigation-POST and $expand all run under the Customers rule. The writes are on that list too. MapOhData() emits a startup Warning for every such pair. Microsoft.AspNetCore.OData behaves the same way and structurally cannot do otherwise. See docs/authorization.md for the full route table, the reasoning, and the two remedies.
Client quick start
// Setup - inject via IHttpClientFactory or create directly
var client = new OhDataClient("https://api.example.com/odata");
// Query with LINQ-style filter, ordering, and pagination
var page = await client.For<Product>()
.Filter(x => x.Price > 10 && x.Name.StartsWith("W"))
.OrderBy(x => x.Name)
.Top(20)
.Skip(0)
.ToPageAsync(); // returns ODataPage<Product> with Items, TotalCount, NextLink
// Traverse all pages automatically via IAsyncEnumerable - follows @odata.nextLink
await foreach (Product p in client.For<Product>().Filter(x => x.Price > 0).ToAsyncEnumerable())
{
Console.WriteLine(p.Name);
}
// Get a single entity - returns null on 404
Product? p = await client.For<Product>().Key(42).GetAsync();
// Mutate
Product created = (await client.For<Product>().InsertAsync(new Product { Name = "Cog", Price = 4.99m }))!;
var updated = await client.For<Product>().Key(created.Id)
.PutAsync(new Product { Id = created.Id, Name = created.Name, Price = 5.49m });
await client.For<Product>().Key(42).PatchAsync(new { Price = 3.99m });
await client.For<Product>().Key(42).DeleteAsync();
With IHttpClientFactory:
// Registration - the typed-client overload configures the HttpClient and registers
// OhDataClient to be constructed with it (OhDataClient has an HttpClient constructor).
builder.Services.AddHttpClient<OhDataClient>(c =>
c.BaseAddress = new Uri("https://api.example.com/odata/"));
// Injection
public class MyService(OhDataClient client) { ... }
Performance
OhData's minimal-API pipeline was benchmarked head-to-head against Microsoft.AspNetCore.OData's
ODataController + [EnableQuery] pipeline over the full HTTP round-trip (routing → OData
query-option processing → handler → serialization), same dataset, same requests, correctness
verified before every run. OhData won all 11 scenarios:
| Scenario | OhData | Microsoft.AspNetCore.OData | Speedup | Alloc ratio |
|---|---|---|---|---|
| GetAll page (100) | 763 µs | 2,821 µs | 3.7× | 6.3× |
$filter |
1,778 µs | 3,393 µs | 1.9× | 6.0× |
$orderby |
968 µs | 2,949 µs | 3.0× | 5.4× |
$select |
878 µs | 1,858 µs | 2.1× | 1.3× |
$top + $skip |
1,262 µs | 2,061 µs | 1.6× | 4.6× |
$count=true (+$filter) |
2,831 µs | 4,740 µs | 1.7× | 5.4× |
| GetById | 37 µs | 112 µs | 3.0× | 3.0× |
| POST | 51 µs | 286 µs | 5.6× | 7.7× |
| PUT | 57 µs | 281 µs | 4.9× | 7.7× |
| PATCH | 53 µs | 325 µs | 6.2× | 7.1× |
| DELETE | 16 µs | 24 µs | 1.5× | 1.3× |
The biggest gaps are on writes (POST/PUT/PATCH, ~5-6× — MS OData's OData-JSON formatters and EDM-bound serialization dominate there) and full-page reads (~3-3.7×). "Alloc ratio" is how many times more memory the MS OData pipeline allocates per request. BenchmarkDotNet over in-process TestServer hosts, identical 1,000-entity dataset and byte-identical requests on both sides, with a correctness gate run before measurement; see src/OhData.Server.Benchmarks/docs/server-comparison-report.md for the full methodology, raw output, and known asymmetries between the two pipelines.
Battle-testing
OhData sits on your request path, so it's tested like it belongs there:
- Integration tests, not mocks. The server suite spins up a real ASP.NET Core host and drives it over HTTP — every route, every query option, navigation and
$reflink management, ETag concurrency, and per-operation and instance-level authorization. A large share is deliberately adversarial: malformed JSON bodies, hostile and oversized query options, and concurrent or cancelled requests, each asserted to fail cleanly with the correct OData error envelope rather than a 500. - Proven against a real database. EF Core + SQLite tests capture the SQL the provider actually emits and assert that
$filter/$orderby/$selectare translated into the SQL query itself — executed by the database, not by fetching every row and filtering in memory. - Exercised end-to-end, client and server together. OhData's own typed client is integration-tested against a live server spun up in-process, so every query, write, and concurrency path round-trips through the real HTTP pipeline. A separate suite drives the server through the official
Microsoft.OData.Client, proving on-the-wire interoperability with a widely used third-party consumer — conformance you can see, not conformance on paper. - OpenAPI across every supported stack. The generated document is tested against the built-in
AddOpenApi, NSwag, and Swashbuckle, so it's correct whichever you wire up. - Load and performance, on every change. CI runs a k6 load test against a live server on each build, and BenchmarkDotNet suites track server and client throughput and allocations so a regression shows up in review, not in production.
Run the whole thing yourself with dotnet test src/OhData.sln.
Versioning & support
OhData follows SemVer: patch releases fix bugs, minor releases add
functionality without breaking the public API, and any breaking change means a major version.
The no-breaking-changes half of that contract is enforced at build time, not just promised —
every release is diffed against the previously published API surface via .NET package validation
(PackageValidationBaselineVersion), so an unintended breaking change fails the release build.
Behavior changes that don't break the API are called out explicitly in the
CHANGELOG.
The latest 1.x release is the supported version. Fixes — including security fixes — ship as a
new release on top of it; older releases receive no back-ports. develop carries pre-release
work and is not for production use. See SECURITY.md for vulnerability reporting
and the full support policy.
Documentation
The full documentation — getting started, the EF Core + SQLite walkthrough, and every guide below — is published at en-gen.github.io/OhData. The same guides live in docs/:
| Topic | Guide |
|---|---|
Query options ($filter, $orderby, $select, $expand, $count, $search) |
docs/query-options.md |
Navigation property routing, $ref, and POST-to-navigation |
docs/navigation-routing.md |
Individual property access, reads/writes, and /$value |
docs/property-access.md |
| Deep insert (nested related entities in POST), and deep update's enforced non-support | docs/deep-insert.md |
| Delta mapping (DTO → entity write path, dependency-free) | docs/delta-mapping.md |
| Open types (dynamic property bags on complex types) | docs/open-types.md |
| Bound functions and actions | docs/bound-operations.md |
| ETags and optimistic concurrency | docs/etags.md |
| Authorization | docs/authorization.md |
| API versioning | docs/versioning.md |
OpenAPI (built-in AddOpenApi) integration |
docs/openapi.md |
| Swashbuckle integration | docs/swashbuckle.md |
| NSwag integration | docs/nswag.md |
| Client guide | docs/client/index.md |
| OData 4.0 spec compliance | docs/spec-compliance.md |
| Framework architecture | docs/architecture.md |
| Migrating from Microsoft.AspNetCore.OData | docs/migrating-from-microsoft-odata.md |
| Deployment (Dockerfile, Render) | docs/deployment.md |
| Releasing to NuGet | docs/releasing.md |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- EnGen.OhData.AspNetCore (>= 1.7.0)
- Microsoft.AspNetCore.OpenApi (>= 10.0.11 && < 11.0.0)
- Microsoft.OpenApi (>= 2.12.2 && < 3.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.