Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c52e4e2
Add ASP.NET Core REST API skills: minimal-api-endpoints and business-…
javiercn Jun 29, 2026
882d556
Add minimal-api-concurrency skill
javiercn Jun 29, 2026
e3912c6
Add author-controller-endpoints skill
javiercn Jun 29, 2026
dd89b2a
Add test-apis-with-webapplicationfactory skill
javiercn Jun 29, 2026
b491470
Add controller-concurrency skill (dual ETag + Last-Modified validators)
javiercn Jun 30, 2026
0073b86
Rework minimal-api-concurrency to dual ETag + Last-Modified validators
javiercn Jun 30, 2026
af255da
Rebuild structure-api-business-logic eval on MessagingApi domain
javiercn Jun 30, 2026
90ae177
Rescope author endpoint skills to endpoint-authoring concerns; fix cues
javiercn Jun 30, 2026
8a69ee1
Add data-access skills teaching bounded pagination on collection endp…
javiercn Jun 30, 2026
cf1ced2
Refine test-apis skill to per-test database isolation; rebuild eval
javiercn Jun 30, 2026
27d6261
Add authorize-api-endpoints skill (authorization framework, declarati…
javiercn Jun 30, 2026
216bcb4
Add minimal-api-parameter-binding and minimal-api-endpoint-filters sk…
javiercn Jun 30, 2026
70c3590
Refocus minimal-api-endpoint-filters on the filter-only use cases
javiercn Jul 1, 2026
293eef1
Add rate-limiting and long-running-operations skills
javiercn Jul 1, 2026
365f3c0
Upgrade data-access skills: keyset pagination, total order, RFC 8288 …
javiercn Jul 1, 2026
c19fc6f
Add change-tracking-delta skill (incremental sync)
javiercn Jul 1, 2026
22c7815
Add structured-logging and patch-partial-updates skills
javiercn Jul 1, 2026
d02c55c
change-tracking-delta: represent removals as 204, add wire sample
javiercn Jul 1, 2026
ad2c23d
Add design-collection-api and filter-and-select skills
javiercn Jul 1, 2026
0b5a9e1
Merge remote-tracking branch 'origin/main' into javiercn/aspnetcore-a…
AbhitejJohn Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,6 @@ eval-results/

# Local design plan kept out of the PR
docs/design/pr-triage-workflows-plan.md

# Preserved Vally agent workspaces for A/B review
eval-workspaces/
2 changes: 1 addition & 1 deletion plugins/dotnet-aspnetcore/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dotnet-aspnetcore",
"version": "0.1.0",
"description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns.",
"description": "ASP.NET Core skills for building REST APIs with controllers and minimal APIs: endpoints, result types, status codes, validation, and business-logic structure.",
"skills": ["./skills/"]
}
100 changes: 100 additions & 0 deletions plugins/dotnet-aspnetcore/skills/author-controller-endpoints/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
name: author-controller-endpoints
description: >-
Author ASP.NET Core controller-based Web API actions with correct result types and status codes. USE FOR: adding or editing controllers that derive from ControllerBase with [ApiController]; choosing an action's return type; returning 201 Created with a Location header, 204 No Content, 404, or 400 from controller actions; declaring produced status codes for OpenAPI; validating request input and query parameters. DO NOT USE FOR: minimal API route handlers (use author-minimal-api-endpoints); designing DTOs or entity mapping (use the model-payloads skills); EF Core querying or pagination internals (use the data-access skills); optimistic concurrency or ETags (use controller-concurrency); service-layer structure and the Result pattern (use structure-api-business-logic).
license: MIT
---

# Author Controller Endpoints

Write `[ApiController]` actions whose return type, status codes, and input validation are explicit and correct.

## Return ActionResult<T> with the typed helpers

Derive the controller from `ControllerBase`, annotate it with `[ApiController]` and attribute routes, and return `ActionResult<T>` using the status helpers (`Ok`, `CreatedAtAction`, `NotFound`, `NoContent`, `ValidationProblem`). `ActionResult<T>` lets an action return either a typed body or a status result, and it tells OpenAPI the success body type.

```csharp
[ApiController]
[Route("orders")]
public class OrdersController(StoreDbContext db) : ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OrderDto>> GetOrder(int id) =>
await db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id) is Order order
? Ok(order.ToDto())
: NotFound();
}
```

## Pick the right status code

| Operation | Helper | Status |
| --- | --- | --- |
| Create a resource | `CreatedAtAction(nameof(GetOrder), new { id = order.Id }, body)` | 201 + `Location` |
| Read | `Ok(body)`; `NotFound()` when absent | 200 / 404 |
| Update returning no body | `NoContent()` | 204 |
| Update returning the updated body | `Ok(body)` | 200 |
| Delete | `NoContent()`; `NotFound()` when absent | 204 / 404 |
| Invalid input | `ValidationProblem()` / `BadRequest(...)` | 400 |

A create returns **201 with a `Location`** header. `CreatedAtAction` builds the URL from a named GET action, so reference the actual get-by-id action.

❌ Returning the entity directly or `Ok(created)` from a create action loses the `Location` header and the 201 semantics.
✅ `return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order.ToDto());`

## Declare the produced status codes

`ActionResult<T>` describes the success body, but the non-success codes an action can return are invisible to OpenAPI unless declared. Add `[ProducesResponseType]` for each additional status the action produces.

```csharp
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderDto>> CreateOrder(CreateOrderRequest req)
{
var order = Order.FromRequest(req);
db.Orders.Add(order);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order.ToDto());
}

[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteOrder(int id)
{
var order = await db.Orders.FindAsync(id);
if (order is null)
{
return NotFound();
}

// Delete maps to 204 / 404 regardless of mechanism; a soft-deletable resource marks itself deleted rather than being removed.
order.MarkDeleted(); // db.Orders.Remove(order) only when the resource is not soft-deletable
await db.SaveChangesAsync();
return NoContent();
}
```

## Validate input

With `[ApiController]`, a failed model validation automatically produces a 400 `ValidationProblemDetails` before the action body runs, so annotate request models and bound parameters with data annotations rather than hand-checking.

```csharp
public record ProductQuery
{
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
[Range(1, 100)] public int PageSize { get; init; } = 20;
public int? CategoryId { get; init; }
}
```

❌ Reading `page`/`pageSize` straight into `Skip`/`Take` with no bounds, so `page = 0` or an unbounded size runs against the database.
✅ Constrain them with `[Range]` (auto-400) or check explicitly and return `ValidationProblem`.

## Verify

- Controllers derive from `ControllerBase` with `[ApiController]` and attribute routing.
- Actions return `ActionResult<T>`; creates use `CreatedAtAction` (201 + `Location`); deletes and empty updates return 204; missing resources return 404; invalid input returns 400 `ValidationProblemDetails`.
- Each non-success status an action can produce is declared with `[ProducesResponseType]`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: author-minimal-api-endpoints
description: >
Author ASP.NET Core Minimal API endpoints with correct HTTP result types and status codes.
USE FOR: adding or editing app.MapGet/MapPost/MapPut/MapDelete route handlers; choosing the
return type of a minimal API endpoint; returning 201 Created, 204 No Content, 404, or 400 from
minimal APIs; validating request input and returning ProblemDetails; making endpoint responses
strongly typed so OpenAPI can describe them.
DO NOT USE FOR: MVC or controller actions and [ApiController] (use author-controller-endpoints);
designing DTOs or entity-to-DTO mapping (use model-minimal-api-payloads); EF Core querying,
pagination internals, or DbContext setup (use minimal-api-data-access); optimistic concurrency or
ETags (use minimal-api-concurrency); content negotiation, XML formatters, or file uploads.
license: MIT
---

# Author Minimal API Endpoints

Write minimal API route handlers whose return type, status codes, and input validation are explicit and correct.

## Return typed results

Declare each handler's return type as a `Results<...>` union of the concrete result types it can produce, and construct every result with `TypedResults.*` (never the untyped `Results.*`). The union gives compile-time checking that the handler only returns statuses it declares, and it emits response-type metadata so OpenAPI describes every outcome automatically.

```csharp
group.MapGet("/{id:int}", async Task<Results<Ok<Order>, NotFound>> (int id, StoreDbContext db) =>
await db.Orders.FindAsync(id) is Order order
? TypedResults.Ok(order)
: TypedResults.NotFound());
```

When a handler has exactly one outcome, return the concrete type directly (`Task<Ok<Order>>`); reach for the union only when more than one status is reachable.

## Pick the right status code

| Operation | Result | Status |
| --- | --- | --- |
| Create a resource | `TypedResults.Created(uri, body)` or `TypedResults.CreatedAtRoute(body, routeName, values)` | 201 + `Location` |
| Read | `TypedResults.Ok(body)`; `TypedResults.NotFound()` when absent | 200 / 404 |
| Update returning no body | `TypedResults.NoContent()` | 204 |
| Update returning the updated body | `TypedResults.Ok(body)` | 200 |
| Delete | `TypedResults.NoContent()`; `NotFound()` when absent | 204 / 404 |
| Invalid input | `TypedResults.ValidationProblem(errors)` or `BadRequest(...)` | 400 |

A create must return **201 with a `Location`** header pointing at the new resource. Name the GET route (`.WithName("GetOrder")`) and reference it from `CreatedAtRoute`.

For a create-or-update against a known URL (for example a one-to-one nested resource at `/customers/{id}/address`), use `PUT` and make it idempotent: return 204 (or 200) whether the resource was created or updated; reserve 201 for the case where you mint a brand-new sub-resource and return its location.

## Validate input, return ProblemDetails

Validate the request before touching the database and return `TypedResults.ValidationProblem(...)` (an RFC 7807 problem document) for bad input, listed in the handler's union. Guard query parameters the same way: reject a non-positive page or an oversized page size rather than letting `Skip`/`Take` run with bad values.

```csharp
group.MapPost("/{orderId:int}/items",
async Task<Results<CreatedAtRoute<OrderItem>, NotFound, ValidationProblem>> (
int orderId, AddItemRequest req, StoreDbContext db) =>
{
if (req.Quantity < 1)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["quantity"] = ["Quantity must be at least 1."]
});
}

if (await db.Orders.FindAsync(orderId) is null || await db.Products.FindAsync(req.ProductId) is null)
{
return TypedResults.NotFound();
}

var item = new OrderItem { OrderId = orderId, ProductId = req.ProductId, Quantity = req.Quantity };
db.OrderItems.Add(item);
await db.SaveChangesAsync();
return TypedResults.CreatedAtRoute(item, "GetOrder", new { id = orderId });
});
```

## Verify

- `dotnet build` succeeds.
- Every handler's declared `Results<...>` union lists exactly the statuses it returns (the compiler enforces this once the return type is declared).
- Creates return 201 with a `Location`; deletes and empty updates return 204; missing resources return 404; invalid input returns 400 with a ProblemDetails body.
173 changes: 173 additions & 0 deletions plugins/dotnet-aspnetcore/skills/authorize-api-endpoints/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
---
name: authorize-api-endpoints
description: >
Authorize ASP.NET Core API endpoints (controllers and minimal APIs) with the authorization framework
instead of inline checks, choosing declarative or imperative by where the decision data lives.
USE FOR: enforcing who may call an endpoint or act on a resource; role/claim rules as named policies;
resource ownership or tenant/membership rules; writing IAuthorizationRequirement + AuthorizationHandler;
reading the route, endpoint, and endpoint metadata from context.Resource (the HttpContext) inside a
handler; deciding between [Authorize(Policy=...)] / RequireAuthorization and an imperative
IAuthorizationService.AuthorizeAsync; returning 401 vs 403 vs 404; wiring authentication/authorization
and middleware order.
DO NOT USE FOR: authenticating users or issuing tokens/login UI; endpoint result types and status codes
in general (use author-controller-endpoints / author-minimal-api-endpoints); service-layer structure
(use structure-api-business-logic).
license: MIT
---

# Authorize API Endpoints

Express every access rule through the authorization framework (requirements, handlers, and named policies), not as inline `if (User...)` checks scattered through actions. The one design decision that matters: **where does the data the rule needs live?**

- **In the request and the caller's claims** (a route value, a header, a role, a tenant claim): decide it **declaratively**. Write a policy backed by a requirement and handler; the handler reads `context.Resource` as the `HttpContext` to reach the route and the endpoint's metadata. No database load.
- **In the stored entity** (a field such as `OwnerId` you only know after loading the row): decide it **imperatively**. Load the entity, then call `IAuthorizationService.AuthorizeAsync(User, entity, policy)` and translate the result.

Most rules are the first kind. Reach for the second only when the decision genuinely needs persisted state.

## Declarative: a handler that reads HttpContext and endpoint metadata

In endpoint routing, `AuthorizationHandlerContext.Resource` is the `HttpContext`. From it the handler reaches the route values and the endpoint, including custom metadata you attach to the endpoint. Carrying the "what to check" as endpoint metadata keeps one handler reusable across endpoints with different route shapes.

```csharp
// Metadata attached to an endpoint describes how this resource is addressed.
public sealed class OrgRouteMetadata(string routeKey)
{
public string RouteKey { get; } = routeKey; // e.g. "orgId"
}

public sealed class SameOrgRequirement : IAuthorizationRequirement;

public sealed class SameOrgHandler : AuthorizationHandler<SameOrgRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SameOrgRequirement requirement)
{
// Endpoint-routing authorization passes the HttpContext as the resource.
if (context.Resource is not HttpContext http)
{
return Task.CompletedTask;
}

var endpoint = http.GetEndpoint();
var route = endpoint?.Metadata.GetMetadata<OrgRouteMetadata>();
if (route is null)
{
return Task.CompletedTask;
}

var routeOrg = http.Request.RouteValues[route.RouteKey] as string;
var userOrg = context.User.FindFirstValue("org");

// Succeed when the rule is met; admins are allowed regardless.
if (context.User.IsInRole("admin")
|| (routeOrg is not null && string.Equals(routeOrg, userOrg, StringComparison.Ordinal)))
{
context.Succeed(requirement);
}

return Task.CompletedTask; // never Fail: see OR semantics below
}
}
```

Register the handler and expose the requirement as a named policy, then opt endpoints in and attach the metadata:

```csharp
builder.Services.AddSingleton<IAuthorizationHandler, SameOrgHandler>();

builder.Services.AddAuthorizationBuilder()
.AddPolicy("same-org", policy => policy.Requirements.Add(new SameOrgRequirement()));

// Controller: [Authorize(Policy = "same-org")] on the action/controller, plus the metadata via an attribute,
// or, minimal API:
var projects = app.MapGroup("/orgs/{orgId}/projects")
.RequireAuthorization("same-org")
.WithMetadata(new OrgRouteMetadata("orgId"));
```

### OR semantics: Succeed, never Fail

A handler calls `context.Succeed(requirement)` when its rule is met and otherwise simply returns. Do **not** call `context.Fail()` for an unmet rule: a requirement can be satisfied by any one of several handlers (for example a tenant-match handler and an admin-allowance handler), and `Fail()` vetoes all of them. Absence of `Succeed` already denies by default.

## Imperative: when the rule needs the loaded entity

Ownership lives in the row, not the route, so the decision can only be made after loading. Inject `IAuthorizationService`, author a resource-typed handler, and authorize the loaded entity.

```csharp
public sealed class OwnerOrAdminRequirement : IAuthorizationRequirement;

public sealed class OwnerOrAdminHandler : AuthorizationHandler<OwnerOrAdminRequirement, Project>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OwnerOrAdminRequirement requirement, Project resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (context.User.IsInRole("admin") || string.Equals(resource.OwnerId, userId, StringComparison.Ordinal))
{
context.Succeed(requirement);
}

return Task.CompletedTask;
}
}

// In the action / handler:
var project = await db.Projects.FindAsync([id], ct);
if (project is null)
{
return TypedResults.NotFound(); // 404: resource does not exist
}

var authz = await authorizationService.AuthorizeAsync(User, project, "owner-or-admin");
if (!authz.Succeeded)
{
return TypedResults.Forbid(); // 403: exists, but caller may not
}
```

Check existence first so a missing resource is **404** and an existing-but-forbidden one is **403**; an unauthenticated caller is **401** (the framework returns this when no policy is satisfied and no user is present).

## Named policies for role and claim rules

Plain role or claim gates need no handler: declare them as named policies and apply them.

```csharp
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()) // every endpoint requires a signed-in user
.AddPolicy("admin", policy => policy.RequireRole("admin"))
.AddPolicy("same-org", policy => policy.Requirements.Add(new SameOrgRequirement()));

// Destructive operations require the admin policy; reads inherit the fallback (authenticated).
adminOnly.MapDelete("/{id}", ...).RequireAuthorization("admin");
```

A **fallback policy** secures every endpoint by default, so a new endpoint is not accidentally left open. Opt specific endpoints out with `[AllowAnonymous]` / `AllowAnonymous()`.

## Wire-up and lifetimes

```csharp
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorizationBuilder() /* policies as above */;

app.UseAuthentication(); // establishes who the caller is
app.UseAuthorization(); // enforces the policies; must come after UseAuthentication, after routing
```

Register a handler as a **singleton** when it has no scoped dependencies; register it as **scoped** if it needs a scoped service (for example a `DbContext`) so it is not captured by a singleton.

## Verify

- Access rules are requirements/handlers exposed as named policies, applied with `[Authorize(Policy=...)]` or `.RequireAuthorization(...)`, not inline `if (User...)` checks duplicated per action.
- A route/claim-derivable rule is declarative; its handler reads `context.Resource` as `HttpContext` for route values and endpoint metadata and does not load the entity.
- An ownership/state rule that needs the loaded entity uses `IAuthorizationService.AuthorizeAsync(User, entity, policy)` after loading, mapping to **403** (forbidden) versus **404** (absent).
- Handlers `Succeed` only and never `Fail`, so OR-combined rules and admin allowances still pass.
- A fallback policy requires authentication everywhere; `UseAuthentication` precedes `UseAuthorization`.
- Decisions use the authenticated `User`'s claims, compared with `StringComparison.Ordinal`, never a client-supplied id from the body or query.

❌ `if (User.FindFirstValue("org") != routeOrg) return Forbid();` repeated in every action.
✅ One `SameOrgRequirement` + handler exposed as a `same-org` policy and reused.

❌ Forcing an ownership rule that needs the loaded row into `[Authorize]` before the entity exists.
✅ Load, then `AuthorizeAsync(User, entity, policy)`; 404 before 403.

❌ `context.Fail()` in a handler that is one of several OR alternatives.
✅ `context.Succeed(requirement)` when met; return otherwise.
Loading
Loading