Advanced
This page covers keyless entity support, the internal DI architecture via
IOtterApiRegistry, and a complete reference table of known limitations and caveats.
Keyless Entities
If an entity is marked with [Keyless] (e.g. a database view), OtterApi registers
it as read-only: only GET is available.
Attempting POST, PUT, PATCH, or DELETE returns 405 Method Not Allowed with
code KEYLESS_ENTITY.
[Keyless]
public class ProductSummaryView
{
public string Name { get; set; }
public decimal Price { get; set; }
public string CategoryName { get; set; }
}
// Registration
options.Entity<ProductSummaryView>("product-summary");
// Only GET is available:
// GET /api/product-summary
// GET /api/product-summary/count
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 interface
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).
Why depend on the interface
-
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.
OtterApiRegistry class remains public for scenarios where
you need to instantiate it directly (e.g. integration tests that construct
OtterApiRestController by hand).
Limitations & Caveats
A complete reference of known constraints and edge cases:
| Limitation | Details |
|---|---|
Single [Key] |
Composite primary keys are not supported. |
MaxPageSize default |
MaxPageSize defaults to 1000. Set to 0 to disable (use with caution on large tables). |
EF Core DbSet required |
The entity must be registered as DbSet<T> in the provided DbContext. If it is not, InvalidOperationException is thrown at startup. |
| Filterable property types | Supported: primitives, string, Guid, DateTime, DateTimeOffset, enum, and nullable variants. Objects and collections cannot be 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 + count). POST, PUT, PATCH, and DELETE return 405. |
operator=or is global (flat syntax) |
The flat operator=or switches join logic for all filters in the request. Use grouped syntax for mixed AND/OR logic. |
| Validation | OtterApi validates Data Annotations ([Required], [MaxLength], etc.) using IObjectModelValidator. Invalid requests return 400 with model state. |
| Enum serialization | Enums are serialized as integers in all responses. Deserialized case-insensitively as both strings ("Pending") and integers (0) in request bodies. |
| PUT / DELETE / PATCH without Id | 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. |
| Middleware order | UseOtterApi() must be placed after UseAuthentication() / UseAuthorization() and before UseEndpoints() / MapControllers(). |
WithQueryFilter — EF-translatable only |
Predicates must be expressible in SQL. Arbitrary C# logic that cannot be converted to a query will throw at runtime. |
WithQueryFilter — static only |
Compiled once at startup. Cannot reference request-scoped data. Use .WithScopedQueryFilter() for dynamic filtering. |
WithScopedQueryFilter — EF-translatable only |
The returned predicate must still be EF-translatable. The factory itself can use any C# logic. |
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. |
| Target framework | .NET 8.0 is required. |
| BeforeSave / AfterSave and transactions |
Pre-save and post-save handlers are not wrapped in a database transaction.
SaveChangesAsync is called between the two lists. If an AfterSave handler throws,
the database changes are already committed and will not be rolled back.
For atomic side-effects, manage the transaction manually inside a BeforeSave handler
or implement the Outbox pattern.
|
| Optimistic concurrency |
DbUpdateConcurrencyException is caught as a generic DbUpdateException
and returns 422 with code DB_UPDATE_ERROR. It is not
mapped to 409 Conflict. Handle DbUpdateConcurrencyException in a hook
or wrapping middleware if needed.
|
PATCH and custom JsonSerializerOptions |
The PATCH document is parsed with a fresh default JsonSerializerOptions
(JsonSerializerDefaults.Web) regardless of custom options. Custom naming policies
and converters are applied when individual field values are deserialized from the patch node.
All other verbs (POST, PUT) fully respect custom options.
|
IOtterApiRegistry |
Registered as AddSingleton<IOtterApiRegistry>. When instantiating
OtterApiSwaggerDocumentFilter or OtterApiRestController manually
(e.g. in tests), pass a concrete OtterApiRegistry instance — it implements
IOtterApiRegistry.
|