OtterApi 2.0.3
dotnet add package OtterApi --version 2.0.3
NuGet\Install-Package OtterApi -Version 2.0.3
<PackageReference Include="OtterApi" Version="2.0.3" />
<PackageVersion Include="OtterApi" Version="2.0.3" />
<PackageReference Include="OtterApi" />
paket add OtterApi --version 2.0.3
#r "nuget: OtterApi, 2.0.3"
#:package OtterApi@2.0.3
#addin nuget:?package=OtterApi&version=2.0.3
#tool nuget:?package=OtterApi&version=2.0.3
OtterApi
![]()
OtterApi is an ASP.NET Core library that automatically generates a full REST API on top of your EF Core models. Register your entities once — and GET / POST / PUT / PATCH / DELETE routes, filtering, sorting, pagination, authorization, and Swagger documentation are all available without writing a single controller or repository.
Table of Contents
- Installation
- Quick Start
- Configuration
- Entity Registration
- Server-Side Query Filters
- Custom Named Routes
- Authorization
- REST API — Endpoint Reference
- Query Parameters
- BeforeSave / AfterSave Hooks
- Soft Delete
- OnDuplicate — Idempotent POST (Find-or-Create)
- Error Handling — OtterApiException
- Keyless Entities
- Swagger
- DI Architecture — IOtterApiRegistry
- Full Integration Example
- Limitations and Caveats
Installation
dotnet add package OtterApi
Dependencies (installed automatically):
| Package | Version |
|---|---|
| Microsoft.EntityFrameworkCore | 8.0.0 |
| Swashbuckle.AspNetCore.SwaggerGen | 6.3.1 |
Quick Start
// Program.cs / Startup.cs
// 1. Register services
builder.Services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api";
options.Entity<Product>("products");
});
// 2. Register middleware (before UseEndpoints / MapControllers)
app.UseOtterApi();
After startup, the following endpoints are available:
GET /api/products
GET /api/products/{id}
POST /api/products
PUT /api/products/{id}
PATCH /api/products/{id}
DELETE /api/products/{id}
GET /api/products/count
GET /api/products/pagedresult (if enabled)
Configuration
AddOtterApi
Two overloads are available:
// Overload 1 — path only
services.AddOtterApi<AppDbContext>("/api");
// Overload 2 — full configuration
services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api";
options.MaxPageSize = 100; // no request can return more than 100 items
options.Entity<Product>("products");
options.Entity<Category>("categories").Authorize();
// ...
});
| Property | Type | Description |
|---|---|---|
Path |
string |
Base prefix for all generated routes. Example: /api/v1 |
JsonSerializerOptions |
JsonSerializerOptions? |
Global serialization options. See below. |
MaxPageSize |
int |
Server-side cap on the number of items per page. Default 1000. Set to 0 to disable the limit (use with caution on large tables). When set, any client-supplied ?pagesize= value exceeding this cap is silently clamped. Applies to regular list requests, /pagedresult, and custom routes. |
UseOtterApi
app.UseOtterApi();
Registers the middleware that intercepts incoming HTTP requests, matches them against registered entities, and executes CRUD operations. Must be placed after UseAuthentication() / UseAuthorization() and before UseEndpoints() / MapControllers().
JsonSerializerOptions
Global serialization options apply to both incoming request bodies (POST, PUT) and outgoing responses.
services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api";
options.JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
options.Entity<Product>("products");
});
Note.
PropertyNameCaseInsensitive = trueand the enum converter (OtterApiCaseInsensitiveEnumConverterFactory) are always added automatically, regardless of your custom settings.
PATCH bodies are deserialized with a fresh default
JsonSerializerOptions(JsonSerializerDefaults.Web), regardless of any custom options. Custom naming policies or converters configured above are not applied to the PATCH document structure. They are, however, applied to individual field values when each patched field is deserialized. All other verbs (POST, PUT) fully respect custom options.
Entity Registration
options.Entity<TEntity>(route)
.Authorize(bool authorize = true)
.WithEntityPolicy(string policy)
.WithGetPolicy(string policy)
.WithPostPolicy(string policy)
.WithPutPolicy(string policy)
.WithPatchPolicy(string policy)
.WithDeletePolicy(string policy)
.Allow(OtterApiCrudOperation operations)
.ExposePagedResult(bool expose = true)
.WithQueryFilter(Expression<Func<T, bool>> predicate) // static server-side row filter
.WithScopedQueryFilter(Func<IServiceProvider, Expression<Func<T, bool>>> factory) // per-request filter
.WithCustomRoute("slug", ...) // named preset GET route (see below)
.BeforeSave(...)
.AfterSave(...);
Every method returns the same OtterApiEntityBuilder<T> instance, so calls can be chained fluently.
.Allow() restricts which HTTP methods are accepted. Requests for a disallowed method return 405 Method Not Allowed.
// Allow only GET and POST — disable PUT and DELETE
options.Entity<Product>("products")
.Allow(OtterApiCrudOperation.Get | OtterApiCrudOperation.Post);
Entity requirements:
- Must be registered as
DbSet<T>in yourDbContext. - The primary key is detected in the following order:
- A property marked with
[Key] - A property named
Id(case-insensitive) - A property named
{ClassName}Id(case-insensitive, e.g.ProductIdfor classProduct)
- If none of the above is found, the entity is treated as keyless (GET only).
- A property marked with
- Filterable/sortable property types: primitives,
string,Guid,DateTime,DateTimeOffset,enum, and nullable variants of all the above. - Navigation properties (collections, nested objects) are automatically excluded from filtering but are available via
?include=.
Server-Side Query Filters
OtterApi supports two types of server-side row filters. Both are applied to every GET request (list, by-Id, count, pagedresult, custom routes) before any client-supplied parameters.
| Type | Method | When the predicate is evaluated |
|---|---|---|
| Static | .WithQueryFilter(predicate) |
Compiled once at startup — constant values only |
| Scoped | .WithScopedQueryFilter(factory) |
Resolved on every request — can read from HTTP context |
Multiple filters of either type can be chained. All are composed with AND semantics.
Static Query Filters
.WithQueryFilter(predicate) registers a permanent, server-side row filter.
The predicate must use only EF-translatable operations and cannot reference request-scoped data.
Basic Usage
// Only expose available products — unavailable ones are completely invisible
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsAvailable);
After this:
| Request | Behaviour |
|---|---|
GET /api/products |
Returns only IsAvailable == true |
GET /api/products/10 (IsAvailable = false) |
404 — record is hidden, not revealed |
GET /api/products/count |
Counts only available products |
GET /api/products/pagedresult |
total and items reflect only available products |
PUT /api/products/10 (IsAvailable = false) |
404 — query filter is applied before update |
PATCH /api/products/10 (IsAvailable = false) |
404 |
The filter is applied transparently at the SQL/in-memory query level. A record that exists in the database but does not pass the filter behaves exactly as if it does not exist.
Chaining Multiple Filters (AND semantics)
Each .WithQueryFilter() call adds another predicate. All predicates are chained — a row must satisfy all of them to be visible.
// A product must be available AND have stock > 0
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsAvailable)
.WithQueryFilter(p => p.Stock > 0);
Compound Conditions in a Single Filter
// Hide cancelled and pending orders in one predicate
options.Entity<Order>("orders")
.WithQueryFilter(o => o.Status != OrderStatus.Cancelled && o.Status != OrderStatus.Pending);
// Expose items from tenant 1 OR tenant 2
options.Entity<Report>("reports")
.WithQueryFilter(r => r.TenantId == 1 || r.TenantId == 2);
Combining with Client Filters
Server-side query filters and client-supplied filter[...] parameters are composed with AND.
The server filter is applied first:
# Server filter: IsAvailable == true
# Client filter: CategoryId == 1
# Result: available products in category 1
GET /api/products?filter[categoryId]=1
Limitations
- The predicate must use only EF-translatable operations (field comparisons,
&&,||,!, constants). Calling arbitrary C# methods that cannot be converted to SQL will throw at runtime. - The predicate is compiled once at startup — it cannot reference request-scoped data such as the current user Id or a value from HTTP headers. Use
.WithScopedQueryFilter()for dynamic, per-request filtering.
Scoped Query Filters (per-request)
.WithScopedQueryFilter(factory) registers a dynamic filter whose predicate is resolved on every
request via IServiceProvider. Use this when the filter depends on runtime data — the current user
Id, tenant Id from a JWT token, or any other HTTP context value.
Requires
services.AddHttpContextAccessor()if you read fromIHttpContextAccessor.
Usage
// Each user sees only their own orders (userId from the JWT token)
options.Entity<Order>("orders")
.WithScopedQueryFilter(sp =>
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var userId = http.HttpContext?.User.FindFirst("sub")?.Value ?? "";
return o => o.UserId == userId;
});
// Multi-tenant: each request sees only its own tenant's data
options.Entity<Product>("products")
.WithScopedQueryFilter(sp =>
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var tenantId = int.Parse(http.HttpContext?.User.FindFirst("tenantId")?.Value ?? "0");
return p => p.TenantId == tenantId;
});
Chaining with Static Filters
Static and scoped filters can be freely combined. All are composed with AND semantics:
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsActive) // static: always applied
.WithScopedQueryFilter(sp => // dynamic: per-request
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var tenantId = int.Parse(http.HttpContext!.User.FindFirst("tenantId")!.Value);
return p => p.TenantId == tenantId;
});
Behaviour When No IServiceProvider Is Available
If the controller is created without a service provider (e.g. directly in unit tests), scoped filters are silently skipped — all records are returned as if no scoped filter existed.
Custom Named Routes
.WithCustomRoute(slug, ...) registers a named, pre-configured GET endpoint on an entity. The route is exposed at {entityRoute}/{slug} and returns a pre-filtered, pre-sorted subset of the entity's data — without writing any controller code.
Method Signature
.WithCustomRoute(
string slug, // URL segment, e.g. "last", "featured"
Expression<Func<T, bool>>? filter = null, // optional row predicate
string? sort = null, // optional Dynamic LINQ sort expression
int take = 0, // max rows to return (0 = no built-in limit)
bool single = false) // true = return T|404, false = return T[]
All parameters except slug are optional and can be combined freely.
Examples
// GET /api/orders/latest — the single most recent non-cancelled order (or 404)
options.Entity<Order>("orders")
.WithQueryFilter(o => o.Status != OrderStatus.Cancelled)
.WithCustomRoute("latest",
sort: "CreatedAt desc",
take: 1,
single: true);
// GET /api/products/featured — top-5 in-stock products by price descending
options.Entity<Product>("products")
.WithCustomRoute("featured",
filter: p => p.Stock > 0,
sort: "Price desc",
take: 5);
// GET /api/products/cheap — up to 10 items under 50 currency units
options.Entity<Product>("products")
.WithCustomRoute("cheap",
filter: p => p.Price < 50m,
sort: "Price asc",
take: 10);
// Multiple custom routes on the same entity
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsActive) // entity-level: hides inactive items globally
.WithCustomRoute("featured",
filter: p => p.Stock > 0,
sort: "Price desc",
take: 5)
.WithCustomRoute("recent",
sort: "CreatedAt desc",
take: 10);
Request / Response
Custom routes accept the same query parameters as a regular GET collection request. Client-supplied parameters stack on top of the route's built-in configuration:
GET /api/products/featured
GET /api/products/featured?filter[categoryId]=1 # client filter stacks (AND semantics)
GET /api/products/featured?sort[name]=asc # client sort overrides route sort
single value |
Response on match | Response when empty |
|---|---|---|
false (default) |
200 OK — JSON array |
200 OK — empty array [] |
true |
200 OK — JSON object |
404 Not Found |
Pipeline (order of operations)
| Step | What happens |
|---|---|
| 1 | Entity-level QueryFilters applied (access control / soft-delete) |
| 2 | Custom route's own filter predicate applied |
| 3 | ?include= navigation properties eagerly loaded |
| 4 | Client-supplied ?filter[...] applied (AND semantics) |
| 5 | Sort: ?sort[...] → route sort → default Id desc |
| 6 | single: true → return first item or 404 |
| 7 | take limit applied (client ?pagesize= overrides route take) |
Constraints
- Unique slugs per entity. Registering two routes with the same slug on the same entity throws
InvalidOperationExceptionat startup. - Reserved slugs are forbidden. The slugs
countandpagedresultconflict with built-in OtterApi paths — using them throws at startup. - EF-translatable predicates only. The same rule as
WithQueryFilter— the predicate must be expressible in SQL. - Static at startup. Predicates are compiled once and cannot reference request-scoped data (current user, HTTP headers, etc.).
- GET only. Custom routes are read-only. POST, PUT, PATCH, and DELETE on a custom route path are not handled — the request falls through to the next middleware.
Authorization
OtterApi uses the standard ASP.NET Core IAuthorizationService, so all policies are configured the usual way.
services.AddAuthorization(options =>
{
options.AddPolicy("IsAdmin", p => p.RequireRole("Admin"));
options.AddPolicy("IsManager", p => p.RequireRole("Admin", "Manager"));
});
| Method | Description |
|---|---|
.Authorize() |
Requires authentication for all HTTP methods |
.WithEntityPolicy("IsAdmin") |
Applies a policy to all methods (GET/POST/PUT/PATCH/DELETE) |
.WithGetPolicy("IsManager") |
Policy for GET only |
.WithPostPolicy("IsAdmin") |
Policy for POST only |
.WithPutPolicy("IsAdmin") |
Policy for PUT only |
.WithPatchPolicy("IsAdmin") |
Policy for PATCH only |
.WithDeletePolicy("IsAdmin") |
Policy for DELETE only |
Policies can be combined: EntityPolicy is checked first, then the method-specific policy.
options.Entity<Product>("products")
.Authorize() // any authenticated user
.WithEntityPolicy("IsManager") // ... who also has the Manager role
.WithPatchPolicy("IsManager") // PATCH requires Manager role
.WithDeletePolicy("IsAdmin"); // DELETE requires Admin role
Authorization error codes:
401 Unauthorized— user is not authenticated403 Forbidden— authenticated but lacks required permissions
REST API — Endpoint Reference
The following example model is used throughout this section:
public enum ProductStatus { Pending, Active, Discontinued }
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public ProductStatus Status { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; } // navigation property
}
Registered as:
options.Path = "/api";
options.Entity<Product>("products").ExposePagedResult();
GET /api/products
Returns all records, sorted by Id descending by default.
GET /api/products
Response 200 OK:
[
{ "id": 3, "name": "Laptop", "price": 999.99, "stock": 10, "isActive": true, "status": 1, "categoryId": 1 },
{ "id": 2, "name": "Mouse", "price": 29.99, "stock": 50, "isActive": true, "status": 1, "categoryId": 1 },
{ "id": 1, "name": "Old Desk", "price": 349.00, "stock": 5, "isActive": false, "status": 0, "categoryId": 2 }
]
GET /api/products/{id}
Returns a single record by primary key.
GET /api/products/3
Responses:
200 OK— record found404 Not Found— record does not exist
{ "id": 3, "name": "Laptop", "price": 999.99, "stock": 10, "isActive": true, "categoryId": 1 }
POST /api/products
Creates a new record. Request body is a JSON object of the entity.
POST /api/products
Content-Type: application/json
{
"name": "Keyboard",
"price": 79.99,
"stock": 25,
"isActive": true,
"status": 0,
"categoryId": 1
}
Responses:
201 Created— includesLocation: /api/products/4header and the created object in the body400 Bad Request— model validation failed (e.g. a[Required]field is missing)
Note. Enum fields in the request body accept both integers (
0) and strings ("Pending") — the deserialization is case-insensitive. Enum fields in the response are always returned as integers.
PUT /api/products/{id}
Updates an existing record. The Id in the request body must match the Id in the URL.
PUT /api/products/4
Content-Type: application/json
{
"id": 4,
"name": "Mechanical Keyboard",
"price": 129.99,
"stock": 20,
"isActive": true,
"status": 1,
"categoryId": 1
}
Responses:
200 OK— updated object400 Bad Request— Id missing from URL, Id mismatch between URL and body, or validation errors404 Not Found— record does not exist
DELETE /api/products/{id}
Deletes a record by Id.
DELETE /api/products/4
Responses:
204 No Content400 Bad Request— Id missing from URL404 Not Found
PATCH /api/products/{id}
Partially updates an existing record using RFC 7396 JSON Merge Patch semantics.
Only the fields present in the request body are updated. Omitted fields are left unchanged.
Navigation properties, unknown fields, and the primary key field are silently ignored — the primary key can never be changed via PATCH.
PATCH /api/products/4
Content-Type: application/merge-patch+json
{
"price": 149.99,
"stock": 15
}
The example above updates only price and stock. All other fields (name, isActive, categoryId, etc.) retain their original database values.
Responses:
200 OK— full updated object in the response body400 Bad Request— Id missing from URL, or validation errors on the patched entity404 Not Found— record does not exist (or is hidden by a server-side query filter)
Note. Enum fields accept both integers (
0) and strings ("Pending") in the patch body — deserialization is case-insensitive.
GET /api/products/count
Returns the total number of records, respecting any filters passed as query parameters.
GET /api/products/count
GET /api/products/count?filter[isActive]=true
Response 200 OK:
42
GET /api/products/pagedresult
Returns results in a paginated envelope. Only available when .ExposePagedResult() is called during entity registration.
GET /api/products/pagedresult?page=2&pagesize=10
Response 200 OK:
{
"items": [ ... ],
"page": 2,
"pageSize": 10,
"pageCount": 5,
"total": 47
}
Query Parameters
Filtering
Syntax: filter[propertyName]=value (default operator is eq)
GET /api/products?filter[isActive]=true
GET /api/products?filter[name]=Laptop
GET /api/products?filter[categoryId]=1
Filter Operators
Syntax: filter[propertyName][operator]=value
Operator names are case-insensitive:
eq,EQ, andEqall work identically.
| Operator | Supported types | Description | Example |
|---|---|---|---|
eq |
string, value types, Guid | Equal to | filter[name][eq]=Laptop |
neq |
string, value types, Guid | Not equal to | filter[status][neq]=pending |
like |
string | Contains substring (case-insensitive) | filter[name][like]=key |
nlike |
string | Does not contain substring (case-insensitive) | filter[name][nlike]=old |
lt |
value types (not Guid) | Less than | filter[price][lt]=100 |
lteq |
value types (not Guid) | Less than or equal to | filter[price][lteq]=100 |
gt |
value types (not Guid) | Greater than | filter[stock][gt]=0 |
gteq |
value types (not Guid) | Greater than or equal to | filter[price][gteq]=50 |
in |
string, value types, Guid | Value is in a JSON array | filter[categoryId][in]=[1,2,3] |
nin |
string, value types, Guid | Value is not in a JSON array | filter[status][nin]=["pending","discontinued"] |
GET /api/products?filter[price][gteq]=50&filter[price][lteq]=100
GET /api/products?filter[name][like]=book
GET /api/products?filter[categoryId][in]=[1,2,5]
GET /api/products?filter[status][nin]=["pending","discontinued"]
Note. For
in/ninon enum properties both integer values ([0,1]) and case-insensitive string names (["Pending","Active"]) are accepted, consistent with the scalareq/neqoperators.
Sorting
Syntax: sort[propertyName]=asc|desc
Descending values: desc, 1, descending. Everything else is treated as ascending.
GET /api/products?sort[price]=asc
GET /api/products?sort[name]=desc
GET /api/products?sort[price]=asc&sort[name]=desc
If no sort is specified, results are ordered by the
[Key]property descending.
Pagination
| Parameter | Description |
|---|---|
page |
Page number, starting from 1 (default: 1). Non-numeric or zero values silently default to 1. |
pagesize |
Number of items per page. Non-numeric values are ignored (no pagination applied). Clamped to MaxPageSize (default 1000). |
Server-side cap.
MaxPageSizedefaults to1000. Override it viaoptions.MaxPageSize. Set to0to disable the cap (use with caution on large tables). Any value over the cap is silently reduced to the cap value.
GET /api/products?page=1&pagesize=20
GET /api/products?filter[isActive]=true&sort[name]=asc&page=2&pagesize=10
Using
page/pagesizeon a regular GET request returns a flat array withSkip/Takeapplied. For a JSON envelope with metadata, use/pagedresult.
PagedResult
Available only when .ExposePagedResult() is configured for the entity.
GET /api/products/pagedresult
GET /api/products/pagedresult?page=3&pagesize=5&filter[isActive]=true&sort[price]=asc
Response structure:
{
"items": [ ... ],
"page": 3,
"pageSize": 5,
"pageCount": 10,
"total": 49
}
| Field | Description |
|---|---|
items |
Array of objects for the current page |
page |
Current page number |
pageSize |
Items per page |
pageCount |
Total number of pages |
total |
Total number of records matching the query |
Include (Navigation Properties)
Eagerly loads related entities, equivalent to EF Core's Include().
Syntax: include=NavPropertyName1,NavPropertyName2
GET /api/products?include=Category
GET /api/products?filter[isActive]=true&include=Category
Only navigation properties declared directly on the entity are supported. Unknown or scalar properties in
includeare silently ignored. Nested includes (deeper than one level) are not supported.
Count
Count can be combined with any filters:
GET /api/products/count?filter[isActive]=true
GET /api/products/count?filter[price][gt]=100&filter[stock][gt]=0
Response: a plain integer with 200 OK.
Compound Filters (AND / OR)
OtterApi supports two filter syntaxes. Both can be mixed in a single request.
Flat Syntax
All filter[...] parameters are collected into a single group. The operator parameter controls how they are joined.
# Default: AND — products that contain "book" AND cost less than 50
GET /api/products?filter[name][like]=book&filter[price][lt]=50
# OR — products that contain "laptop" OR "keyboard"
GET /api/products?filter[name][like]=laptop&filter[name][like]=keyboard&operator=or
operator value |
Logic |
|---|---|
| (not specified) | AND |
or |
OR |
Note. The flat
operatorparameter is global for the entire request — it is not possible to mix AND and OR for different fields using this syntax. Use Grouped Syntax for mixed logic.
Grouped Syntax
Filters are organised into numbered groups (filter[N][Property]). Each group has its own operator[N] parameter. Filters within a group are combined by that group's operator; groups are always combined with AND.
filter[N][PropertyName]=value
filter[N][PropertyName][operator]=value
operator[N]=or # (optional) use OR within group N; default is AND
Example: (Name LIKE "laptop" OR Name LIKE "keyboard") AND CategoryId = 1
GET /api/products?filter[0][name][like]=laptop&filter[0][name][like]=keyboard&operator[0]=or&filter[1][categoryId]=1
Example: (Price >= 100 OR Stock > 50) AND IsActive = true
GET /api/products?filter[0][price][gteq]=100&filter[0][stock][gt]=50&operator[0]=or&filter[1][isActive]=true
| Rule | Description |
|---|---|
Group index N |
A non-negative integer (0, 1, 2, …). Distinguishes groups from property names. |
| Within a group | Combined with operator[N] (AND by default, OR if operator[N]=or). |
| Between groups | Always combined with AND. |
| Flat + grouped | Flat filters (no group index) form an implicit "default" group, controlled by the bare operator key. |
BeforeSave / AfterSave Hooks
Hooks let you execute arbitrary logic before or after an entity is saved to the database.
Multiple .BeforeSave() and .AfterSave() calls can be chained on the same entity — all registered handlers run in registration order. See Chaining Multiple Hooks.
Signature
(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation)
| Parameter | Description |
|---|---|
context |
The current DbContext instance |
newEntity |
Incoming data for BeforeSave; saved/patched data for AfterSave |
originalEntity |
The database state before the change. null for POST. For DELETE, equals newEntity. |
operation |
OtterApiCrudOperation.Post, .Put, .Patch, or .Delete |
OtterApiCrudOperation
[Flags]
public enum OtterApiCrudOperation
{
Get = 1,
Post = 2,
Put = 4,
Delete = 8,
Patch = 16,
All = Get | Post | Put | Delete | Patch
}
Note.
BeforeSave/AfterSavehooks are invoked forPost,Put,Patch, andDeleteoperations.Getis included in the enum solely for use with.Allow().
Lambda Approach
Best for concise inline logic. Both synchronous (Action) and asynchronous (Func<..., Task>) overloads are supported.
BeforeSave
// Synchronous
options.Entity<Product>("products")
.BeforeSave((DbContext ctx, Product newProduct, Product? original, OtterApiCrudOperation op) =>
{
if (op == OtterApiCrudOperation.Post)
newProduct.CreatedAt = DateTime.UtcNow;
if (op == OtterApiCrudOperation.Put && original != null)
{
if (original.Price != newProduct.Price)
Console.WriteLine($"Price changed: {original.Price} → {newProduct.Price}");
}
});
// Asynchronous
options.Entity<Product>("products")
.BeforeSave(async (DbContext ctx, Product newProduct, Product? original, OtterApiCrudOperation op) =>
{
if (op == OtterApiCrudOperation.Post)
{
var exists = await ctx.Set<Product>()
.AnyAsync(p => p.Name == newProduct.Name);
if (exists)
throw new OtterApiException("DUPLICATE_NAME", "A product with this name already exists.", 409);
}
});
AfterSave
// Synchronous
options.Entity<Product>("products")
.AfterSave((DbContext ctx, Product saved, Product? original, OtterApiCrudOperation op) =>
{
if (op == OtterApiCrudOperation.Post)
Console.WriteLine($"New product created: id={saved.Id}");
});
// Asynchronous
options.Entity<Product>("products")
.AfterSave(async (DbContext ctx, Product saved, Product? original, OtterApiCrudOperation op) =>
{
if (op == OtterApiCrudOperation.Delete)
await NotificationService.SendAsync($"Product '{saved.Name}' was deleted.");
});
BeforeSaveis called beforedbContext.SaveChangesAsync().
AfterSaveis called afterdbContext.SaveChangesAsync().
Handler Approach (Interface)
Best for complex logic that uses DI dependencies, or when you want to separate concerns into dedicated classes.
IOtterApiBeforeSaveHandler<T>
public interface IOtterApiBeforeSaveHandler<T> where T : class
{
Task BeforeSaveAsync(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation);
}
IOtterApiAfterSaveHandler<T>
public interface IOtterApiAfterSaveHandler<T> where T : class
{
Task AfterSaveAsync(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation);
}
Implementation Example
// ProductBeforeSaveHandler.cs
public class ProductBeforeSaveHandler : IOtterApiBeforeSaveHandler<Product>
{
private readonly ILogger<ProductBeforeSaveHandler> _logger;
public ProductBeforeSaveHandler(ILogger<ProductBeforeSaveHandler> logger)
{
_logger = logger;
}
public async Task BeforeSaveAsync(
DbContext context,
Product newProduct,
Product? original,
OtterApiCrudOperation operation)
{
if (operation == OtterApiCrudOperation.Post)
{
newProduct.CreatedAt = DateTime.UtcNow;
_logger.LogInformation("Creating product: {Name}", newProduct.Name);
var duplicate = await context.Set<Product>()
.AnyAsync(p => p.Name == newProduct.Name);
if (duplicate)
throw new OtterApiException("DUPLICATE", "A product with this name already exists.", 409);
}
if (operation == OtterApiCrudOperation.Put && original != null)
{
_logger.LogInformation(
"Updating product {Id}: price {Old} → {New}",
newProduct.Id, original.Price, newProduct.Price);
}
}
}
// ProductAfterSaveHandler.cs
public class ProductAfterSaveHandler : IOtterApiAfterSaveHandler<Product>
{
private readonly IEventBus _eventBus;
public ProductAfterSaveHandler(IEventBus eventBus)
{
_eventBus = eventBus;
}
public async Task AfterSaveAsync(
DbContext context,
Product saved,
Product? original,
OtterApiCrudOperation operation)
{
await _eventBus.PublishAsync(new ProductChangedEvent
{
ProductId = saved.Id,
Operation = operation.ToString()
});
}
}
Registering Handlers
Pass a handler instance directly to the builder method. Multiple handlers can be registered on the same entity by chaining .BeforeSave() / .AfterSave() calls — they all run in registration order.
// Single handler per operation
services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api";
options.Entity<Product>("products")
.BeforeSave(new ProductBeforeSaveHandler(logger))
.AfterSave(new ProductAfterSaveHandler(eventBus));
});
// Chained: both BeforeSave handlers run in order before SaveChangesAsync
services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api";
options.Entity<Product>("products")
.BeforeSave(new ProductBeforeSaveHandler(loggerFactory.CreateLogger<ProductBeforeSaveHandler>()))
.BeforeSave((ctx, product, _, op) =>
{
// runs after ProductBeforeSaveHandler
if (op == OtterApiCrudOperation.Post)
product.Slug = product.Name.ToLowerInvariant().Replace(" ", "-");
})
.AfterSave(new ProductAfterSaveHandler(eventBus));
options.Entity<Category>("categories")
.BeforeSave((ctx, cat, _, op) =>
{
if (op == OtterApiCrudOperation.Post)
cat.Name = cat.Name.Trim();
});
});
Important. Hooks are registered once at application startup. If your handler requires scoped dependencies (e.g. another DbContext, HTTP client, etc.), use the lambda approach and resolve dependencies through the provided
DbContextor a scope factory.
Chaining Multiple Hooks
Best for combining simple inline logic with dependency-injected services, or for separating concerns into dedicated classes.
options.Entity<Order>("orders")
// BeforeSave chain — both run before SaveChangesAsync
.BeforeSave((ctx, order, _, op) =>
{
if (op == OtterApiCrudOperation.Post)
order.CreatedAt = DateTime.UtcNow;
})
.BeforeSave(new OrderValidationHandler()) // runs second
// AfterSave chain — both run after SaveChangesAsync
.AfterSave(new OrderAuditHandler()) // runs first
.AfterSave((_, order, _, op) =>
{
if (op == OtterApiCrudOperation.Post)
Console.WriteLine($"[Audit] New order #{order.Id}");
});
The second BeforeSave handler receives the entity in the state left by the first handler — mutations from earlier handlers are visible to later ones.
Order of execution for every mutating request:
BeforeSave[0] → BeforeSave[1] → ... → SaveChangesAsync → AfterSave[0] → AfterSave[1] → ...
Lambdas and interface-based handlers can be freely mixed in the same chain.
Soft Delete
.WithSoftDelete(p => p.IsDeleted) enables soft-delete for an entity. When a DELETE request is received, OtterApi sets the specified boolean property to true instead of calling dbContext.Remove(entity). The record stays in the database and is never physically removed.
A query filter p => !p.IsDeleted is automatically registered so soft-deleted records are invisible to all GET endpoints (list, by-Id, count, pagedresult, custom routes) — no extra .WithQueryFilter() call is needed.
Usage
options.Entity<Order>("orders")
.WithSoftDelete(o => o.IsDeleted);
After this:
| Request | Behaviour |
|---|---|
DELETE /api/orders/5 |
Sets IsDeleted = true, returns 204 No Content |
DELETE /api/orders/5 (already soft-deleted) |
404 — hidden by auto query filter |
GET /api/orders |
Returns only orders where IsDeleted == false |
GET /api/orders/5 (soft-deleted) |
404 |
GET /api/orders/count |
Counts only non-deleted orders |
PUT /api/orders/5 (soft-deleted) |
404 — auto query filter applied before update |
PATCH /api/orders/5 (soft-deleted) |
404 — auto query filter applied before update |
Combining with BeforeSave
BeforeSave / AfterSave hooks still fire on soft-delete. The flag is set before BeforeSave runs, so the hook receives the entity with IsDeleted = true. Use this to populate additional audit fields:
options.Entity<Order>("orders")
.WithSoftDelete(o => o.IsDeleted)
.BeforeSave((ctx, order, original, op) =>
{
if (op == OtterApiCrudOperation.Delete)
order.DeletedAt = DateTime.UtcNow;
});
Combining with other query filters
.WithSoftDelete registers the !IsDeleted filter first. Any subsequent .WithQueryFilter or .WithScopedQueryFilter calls are chained on top with AND semantics:
options.Entity<Order>("orders")
.WithSoftDelete(o => o.IsDeleted)
.WithQueryFilter(o => o.Status != OrderStatus.Cancelled);
// effective filter: !IsDeleted AND Status != Cancelled
Constraints
- The expression must be a simple property selector, e.g.
o => o.IsDeleted. Computed expressions such aso => !o.IsDeletedoro => truethrowArgumentExceptionat startup. - Only
boolproperties are supported. PUT/PATCHon a soft-deleted record return 404 (the auto query filter is applied to those operations too).
OnDuplicate — Idempotent POST (Find-or-Create)
.OnDuplicate(finder) registers a hook that runs at the start of every POST request before any BeforeSave hooks or database writes. If the delegate returns a non-null entity, that existing entity is returned as 200 OK and the new row is never inserted. When the delegate returns null, the normal create flow proceeds and 201 Created is returned.
Basic usage
options.Entity<Order>("orders")
.OnDuplicate(async (ctx, newOrder) =>
await ctx.Set<Order>()
.FirstOrDefaultAsync(o => o.CustomerEmail == newOrder.CustomerEmail
&& o.ProductId == newOrder.ProductId));
Complex logic
Because the delegate receives the full DbContext and the incoming entity, you can run any async queries, comparisons, or business logic inside it:
options.Entity<Order>("orders")
.OnDuplicate(async (ctx, newOrder) =>
{
// Find the most recent active order from the same customer for the same product.
var last = await ctx.Set<Order>()
.Where(o => o.CustomerEmail == newOrder.CustomerEmail
&& o.ProductId == newOrder.ProductId
&& o.Status != OrderStatus.Cancelled)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefaultAsync();
if (last == null) return null; // no existing order — create new
// Treat as duplicate only if placed within the last 24 hours
return (DateTime.UtcNow - last.CreatedAt).TotalHours < 24 ? last : null;
});
A synchronous overload is also available when no async operations are needed:
options.Entity<Product>("products")
.OnDuplicate((ctx, p) => ctx.Set<Product>().FirstOrDefault(x => x.Sku == p.Sku));
Behaviour summary
| Condition | Status | Body | DB write | Hooks |
|---|---|---|---|---|
| Finder returns existing entity | 200 OK |
existing entity | none | none |
Finder returns null |
201 Created |
new entity | insert | BeforeSave / AfterSave fire |
No .OnDuplicate configured |
201 Created |
new entity | insert | BeforeSave / AfterSave fire |
Notes
- BeforeSave and AfterSave hooks are skipped when a duplicate is found — there is nothing to save.
- The finder receives the incoming (validated, not yet tracked) entity, so you can read its fields freely.
.OnDuplicatecan be combined with.WithSoftDelete,.WithQueryFilter, and.BeforeSaveon the same entity.
Error Handling — OtterApiException
To return a structured error response from a BeforeSave or AfterSave hook, throw OtterApiException:
throw new OtterApiException(
code: "OUT_OF_STOCK",
message: "Cannot create order: the product is out of stock.",
statusCode: 422
);
The middleware catches the exception and returns:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"code": "OUT_OF_STOCK",
"message": "Cannot create order: the product is out of stock."
}
| Parameter | Type | Description |
|---|---|---|
code |
string |
Machine-readable error code |
message |
string |
Human-readable error message |
statusCode |
int |
HTTP status code (default: 400) |
Built-in error codes
OtterApi itself throws OtterApiException in the following situations:
| Code | Status | When |
|---|---|---|
INVALID_BODY |
400 |
Request body (POST / PUT) is null or empty |
INVALID_FILTER_OPERATOR |
400 |
Client uses an operator not supported for the property type (e.g. filter[price][like]=foo) |
INVALID_JSON |
400 |
Request body (POST / PUT / PATCH) contains invalid JSON, or filter[...][in] value is not a valid JSON array |
CONFLICT |
409 |
Database unique / primary-key constraint violation |
DB_UPDATE_ERROR |
422 |
Database update failed for another reason (FK violation, check constraint, etc.) |
KEYLESS_ENTITY |
405 |
POST / PUT / PATCH / DELETE attempted on a keyless entity |
Keyless Entities
If an entity is marked [Keyless] (e.g. a database view), it is registered in OtterApi as read-only: only GET is available.
[Keyless]
public class ProductSummaryView
{
public string Name { get; set; }
public decimal Price { get; set; }
public string CategoryName { get; set; }
}
options.Entity<ProductSummaryView>("product-summary");
Attempting POST, PUT, or DELETE on a keyless entity will result in an exception: "Operation not allowed for keyless entities".
Swagger
OtterApi ships with OtterApiSwaggerDocumentFilter, which adds all auto-generated routes to the Swagger documentation.
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
options.DocumentFilter<OtterApiSwaggerDocumentFilter>(); // <-- add this
});
The filter automatically generates:
- Schemas for all registered entities
- GET (list, by id, count, pagedresult), POST, PUT, PATCH, and DELETE operations — respecting each entity's
.Allow()configuration - Custom named routes (
WithCustomRoute) — with correctsingle-mode response schema (object vs. array) and404response when applicable - Query parameter descriptions for all operations:
filter[...],sort[...],page,pagesize,include,operator - Type mappings for:
string,bool,byte,sbyte,short,ushort,int,uint,long,ulong,float,double,decimal,DateTime,DateTimeOffset,Guid,byte[],enum - Enum schemas include
x-enumNames(for code generators such as NSwag / Kiota) and a human-readabledescriptionmapping integers to names (e.g.0 = Pending, 1 = Active) - The
pagedresultschema (when.ExposePagedResult()is used)
DI Architecture — IOtterApiRegistry
At startup, AddOtterApi builds an OtterApiRegistry singleton and registers it in the DI container under the IOtterApiRegistry interface:
services.AddSingleton<IOtterApiRegistry>(registry);
IOtterApiRegistry is the read-only contract that all internal consumers (OtterApiRequestProcessor, OtterApiRestController, OtterApiSwaggerDocumentFilter) depend on. Depending on the interface — not the concrete class — means:
- Testability. You can mock or stub
IOtterApiRegistryin unit tests without a real DI container. - Replaceability. Advanced scenarios can supply a custom implementation (e.g. for multi-tenant registries) by calling
AddSingleton<IOtterApiRegistry>(myCustomRegistry)directly.
public interface IOtterApiRegistry
{
IReadOnlyList<OtterApiEntity> Entities { get; }
OtterApiOptions Options { get; }
JsonSerializerOptions SerializationOptions { get; }
JsonSerializerOptions DeserializationOptions { get; }
JsonSerializerOptions PatchOptions { get; }
OtterApiEntity? FindEntityForPath(PathString requestPath, out PathString remainder);
}
FindEntityForPath is the O(1) route resolver — a case-insensitive dictionary built once at startup and consulted on every request (at most two probes: exact-match for collection routes, parent-segment match for by-id / sub-routes).
The concrete OtterApiRegistry class remains public for scenarios where you need to instantiate it directly (e.g. integration tests that construct OtterApiRestController by hand).
Full Integration Example
Models
public enum ProductStatus { Pending, Active, Discontinued }
public class Category
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public List<Product> Products { get; set; }
}
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public ProductStatus Status { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
}
DbContext
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
}
Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite("Data Source=app.db"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(/* ... */);
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("IsAdmin", p => p.RequireRole("Admin"));
opt.AddPolicy("IsManager", p => p.RequireRole("Admin", "Manager"));
});
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new OpenApiInfo { Title = "Shop API", Version = "v1" });
opt.DocumentFilter<OtterApiSwaggerDocumentFilter>();
});
// --- OtterApi ---
builder.Services.AddOtterApi<AppDbContext>(options =>
{
options.Path = "/api/v1";
// Only active categories are exposed — archived ones are completely invisible
options.Entity<Category>("categories")
.Authorize()
.WithDeletePolicy("IsAdmin")
.WithQueryFilter(c => c.IsActive)
.BeforeSave((ctx, cat, _, op) =>
{
if (op == OtterApiCrudOperation.Post)
cat.Name = cat.Name.Trim();
});
// Products must be available AND in stock.
// Two chained filters — both must pass (AND semantics).
options.Entity<Product>("products")
.Authorize()
.WithPostPolicy("IsManager")
.WithPutPolicy("IsManager")
.WithPatchPolicy("IsManager")
.WithDeletePolicy("IsAdmin")
.ExposePagedResult()
.WithQueryFilter(p => p.IsActive)
.WithQueryFilter(p => p.Stock > 0)
.BeforeSave(async (ctx, product, original, op) =>
{
if (op == OtterApiCrudOperation.Post)
{
product.CreatedAt = DateTime.UtcNow;
var categoryExists = await ctx.Set<Category>().AnyAsync(c => c.Id == product.CategoryId);
if (!categoryExists)
throw new OtterApiException("INVALID_CATEGORY", "The specified category was not found.", 404);
}
if ((op == OtterApiCrudOperation.Put || op == OtterApiCrudOperation.Patch)
&& original != null && original.Price != product.Price)
{
// log price change
}
})
.AfterSave(async (ctx, product, _, op) =>
{
if (op == OtterApiCrudOperation.Post)
{
// e.g. send a notification
await Task.CompletedTask;
}
});
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization();
app.UseOtterApi(); // OtterApi middleware — must come after auth
app.MapControllers();
app.Run();
Sample Requests
# All categories
GET /api/v1/categories
# Active products with price >= 100, sorted by name, page 2
GET /api/v1/products?filter[isActive]=true&filter[price][gteq]=100&sort[name]=asc&page=2&pagesize=15
# Products with status Active or Pending
GET /api/v1/products?filter[status][in]=["active","pending"]
# Products with category eagerly loaded
GET /api/v1/products?include=Category
# Paginated result envelope
GET /api/v1/products/pagedresult?page=1&pagesize=20&sort[price]=desc
# Count active products
GET /api/v1/products/count?filter[isActive]=true
# Create a product (requires Manager role)
POST /api/v1/products
Authorization: Bearer <token>
Content-Type: application/json
{ "name": "Widget", "price": 9.99, "stock": 100, "isActive": true, "status": 0, "categoryId": 1 }
# Fully update a product (requires Manager role)
PUT /api/v1/products/7
Authorization: Bearer <token>
Content-Type: application/json
{ "id": 7, "name": "Widget Pro", "price": 14.99, "stock": 80, "isActive": true, "status": 1, "categoryId": 1 }
# Partially update a product — only price and stock (requires Manager role)
PATCH /api/v1/products/7
Authorization: Bearer <token>
Content-Type: application/merge-patch+json
{ "price": 19.99, "stock": 60 }
# Delete a product (requires Admin role)
DELETE /api/v1/products/7
Authorization: Bearer <token>
Limitations and Caveats
| Limitation | Details |
|---|---|
Single [Key] |
Composite primary keys are not supported. |
MaxPageSize default |
MaxPageSize defaults to 1000. Set to 0 to disable the limit (use with caution on large tables). |
EF Core DbSet |
The entity must be registered as DbSet<T> in the provided DbContext. If it is not, an InvalidOperationException is thrown at startup. |
| Filterable property types | Supported: primitives, string, Guid, DateTime, DateTimeOffset, enum, and nullable variants. Objects and collections cannot be used as filter fields. |
include depth |
Only navigation properties declared directly on the entity are loaded. Nested includes (deeper than one level) are not supported. Unknown property names in include are silently ignored. |
| Hooks and DI | Hooks are registered once at startup. Scoped dependencies must be resolved manually through the provided DbContext or a service scope factory. |
| Keyless entities | GET only (list + filter). POST, PUT, PATCH, and DELETE throw an exception. |
operator=or is global (flat syntax) |
The flat operator=or parameter switches the join logic for all filters in the request. For mixed AND/OR logic on different fields, use grouped filter syntax instead. |
| Validation | OtterApi validates Data Annotations ([Required], [MaxLength], etc.) using the standard IObjectModelValidator. Invalid requests return 400 Bad Request with the model state. |
| Enum serialization | Enums are serialized as integers in all responses. Enums are deserialized case-insensitively as both strings ("Pending") and integers (0) in request bodies. Swagger schemas include x-enumNames and a description mapping integers to names (e.g. 0 = Pending, 1 = Active). |
| Filter operator names | Operator names (eq, like, in, etc.) are case-insensitive in the URL. Passing an unsupported operator for a given type returns 400 Bad Request with code INVALID_FILTER_OPERATOR. |
| PUT / DELETE / PATCH without Id | PUT, PATCH, and DELETE without an Id segment in the URL return 400 Bad Request. The Id must always be part of the URL path. |
| Trailing slash | A trailing slash (e.g. /api/products/) is treated as a collection request, identical to /api/products. |
.Allow() and HTTP methods |
Requests for a method not included in AllowedOperations return 405 Method Not Allowed. The default allows all methods (GET, POST, PUT, PATCH, DELETE). |
| Middleware order | UseOtterApi() must be placed after UseAuthentication() / UseAuthorization() and before UseEndpoints() / MapControllers(). |
WithQueryFilter — EF-translatable only |
Predicates passed to .WithQueryFilter() must be expressible in SQL (field comparisons, &&, \|\|, constants). Arbitrary C# logic that cannot be converted to a query will throw at runtime. |
WithQueryFilter — static only |
Predicates are compiled once at application startup. They cannot reference request-scoped data (current user, HTTP headers, etc.). Use .WithScopedQueryFilter() for dynamic per-request filtering. |
WithScopedQueryFilter — EF-translatable only |
The predicate returned by the factory must still be EF-translatable. The factory itself can use any C# logic to build or select the predicate. |
WithCustomRoute — reserved slugs |
The slugs count and pagedresult are reserved and throw InvalidOperationException at startup if used. |
WithCustomRoute — unique slugs |
Each slug must be unique per entity. Duplicate slugs throw InvalidOperationException at startup. |
WithCustomRoute — GET only |
Custom routes are read-only GET endpoints. POST, PUT, PATCH, and DELETE are not supported on custom route paths. |
| Target framework | .NET 8.0 is required. |
| Soft delete — bool only | .WithSoftDelete only accepts a bool property selector. DateTime?-based soft-delete (setting a DeletedAt timestamp) is not built-in — use a BeforeSave hook to populate audit fields alongside the bool flag. |
| Soft delete — expression constraint | The expression passed to .WithSoftDelete must be a direct property access (p => p.IsDeleted). Computed or constant expressions throw ArgumentException at startup. |
| BeforeSave / AfterSave and transactions | Pre-save and post-save handlers are not wrapped in a database transaction. SaveChangesAsync is called between the two handler lists. If a PostSaveHandler throws (e.g. while publishing an event to Kafka or sending an email), the database changes are already committed and will not be rolled back. If you need atomic side-effects, manage the transaction manually inside a BeforeSave handler (using DbContext.Database.BeginTransactionAsync), or implement the Outbox pattern. |
| Optimistic concurrency | If your entities use [ConcurrencyCheck] or a row-version / timestamp column, a concurrent update will throw DbUpdateConcurrencyException. OtterApi catches this as a generic DbUpdateException and returns 422 Unprocessable Entity with code DB_UPDATE_ERROR. It is not mapped to 409 Conflict. If optimistic concurrency is important for your domain, handle DbUpdateConcurrencyException in a BeforeSave / AfterSave handler or in a wrapping middleware. |
PATCH and custom JsonSerializerOptions |
The PATCH document (the top-level JsonObject) is parsed with a fresh default JsonSerializerOptions (JsonSerializerDefaults.Web), regardless of any custom options passed via options.JsonSerializerOptions. This is intentional: JSON Merge Patch (RFC 7396) documents have no naming-policy requirements. Custom converters and naming policies are applied when each individual field value is deserialized from the patch node. All other verbs (POST, PUT) fully respect the custom options. |
| IOtterApiRegistry | The IOtterApiRegistry singleton is the read-only contract for the startup configuration. It is registered as AddSingleton<IOtterApiRegistry>. If you instantiate OtterApiSwaggerDocumentFilter or OtterApiRestController manually (e.g. in tests), you can pass a concrete OtterApiRegistry instance — it implements IOtterApiRegistry. |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- Microsoft.EntityFrameworkCore (>= 8.0.0)
- Swashbuckle.AspNetCore.SwaggerGen (>= 6.3.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.