.NET 8.0 ASP.NET Core EF Core MIT License

REST API generation
without the boilerplate

Register your EF Core entities once and get full GET / POST / PUT / PATCH / DELETE endpoints, filtering, sorting, pagination, authorization, and Swagger docs — with zero controllers and zero repositories.

$ dotnet add package OtterApi

Everything, out of the box

One registration call replaces an entire data-access layer.

Auto CRUD Endpoints

Full GET (list + by id), POST, PUT, PATCH, and DELETE routes are generated automatically for every registered entity. No controller code needed.

🔍

Filtering & Sorting

10 filter operators — eq, neq, like, lt, gt, in, and more. Multi-field sorting with sort[field]=asc|desc. Compound AND / OR groups supported.

📄

Pagination

Simple ?page=&pagesize= for flat arrays, or /pagedresult for a rich envelope with total, pageCount, and items.

🔒

Authorization

Integrates with ASP.NET Core's standard policy system. Require auth or specific policies globally or per HTTP method — .WithDeletePolicy("IsAdmin").

🪝

BeforeSave / AfterSave Hooks

Attach sync or async logic before or after every save operation. Use lambdas for quick inline code, or implement IOtterApiBeforeSaveHandler<T> for DI-friendly classes.

📑

Swagger / OpenAPI

OtterApiSwaggerDocumentFilter auto-generates full Swagger schemas for every route, including query parameters, response shapes, and enum descriptions.

🛡️

Server-Side Query Filters

Static or per-request predicates that silently restrict which rows a client can ever see. Perfect for soft-deletes, multi-tenant isolation, and access control.

🗺️

Custom Named Routes

Define pre-filtered, pre-sorted GET endpoints like /products/featured or /orders/latest — no extra controllers, just one fluent call.

🔗

Navigation Properties

Eager-load related entities on any GET request with ?include=Category. Equivalent to EF Core's Include(), zero extra code.

Up and running in minutes

Two lines of setup in Program.cs and your entire entity is exposed as a REST API — complete with filtering, sorting, and pagination.

After startup, the following routes are live for your entity:

  • 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
Read the full guide →
Program.cs
// 1. Register services
builder.Services.AddOtterApi<AppDbContext>(options =>
{
    options.Path = "/api";
    options.Entity<Product>("products")
        .Authorize()
        .WithDeletePolicy("IsAdmin")
        .ExposePagedResult();
});

// 2. Register middleware
app.UseOtterApi();
Example request
GET /api/products?filter[isActive]=true&sort[price]=asc&page=1&pagesize=20

// Response 200 OK
[
  { "id": 3, "name": "Laptop", "price": 999.99 },
  { "id": 2, "name": "Mouse",  "price":  29.99 }
]

Server-side query filters

Restrict which rows a client can ever access at the query level — not in application code. Use static predicates compiled once at startup, or scoped predicates that read from the JWT token on every request.

Hidden rows behave as if they don't exist: GET /products/10 returns 404, not 403. The same filter applies to list, count, pagedresult, and write operations.

Server-side Filters →
Multi-tenant isolation
options.Entity<Order>("orders")
    .WithQueryFilter(o => o.IsActive)        // static
    .WithScopedQueryFilter(sp =>
    {
        var http = sp.GetRequiredService<IHttpContextAccessor>();
        var uid  = http.HttpContext?.User.FindFirst("sub")?.Value;
        return o => o.UserId == uid;         // per-request
    });

BeforeSave / AfterSave hooks

Execute arbitrary logic around every write operation. Set timestamps, validate business rules, publish events, or send notifications — without touching a controller.

Hooks can be synchronous or async, inline lambdas or full DI-enabled handler classes. Multiple hooks chain in registration order, and each hook sees the state left by the previous one.

Hooks Documentation →
Async hook with validation
options.Entity<Product>("products")
    .BeforeSave(async (ctx, product, _, op) =>
    {
        if (op == OtterApiCrudOperation.Post)
            product.CreatedAt = DateTime.UtcNow;

        var exists = await ctx.Set<Product>()
            .AnyAsync(p => p.Name == product.Name);
        if (exists)
            throw new OtterApiException("DUPLICATE", "Name taken.", 409);
    });

Custom named routes

Register pre-configured GET endpoints with a single fluent call. Define a filter predicate, a sort expression, a row limit, and a URL slug — and OtterApi handles the rest.

Custom routes stack with client-supplied query parameters, respect entity-level query filters, and fully support ?include= and ?filter[...] stacking.

Custom Routes →
Featured & latest routes
options.Entity<Product>("products")
    .WithCustomRoute("featured",
        filter: p => p.Stock > 0,
        sort:   "Price desc",
        take:   5)
    .WithCustomRoute("latest",
        sort:   "CreatedAt desc",
        take:   10);

// These are now live:
// GET /api/products/featured
// GET /api/products/latest