diff --git a/.gitignore b/.gitignore index 9fdf6b977d..e177b33475 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/plugins/dotnet-aspnetcore/plugin.json b/plugins/dotnet-aspnetcore/plugin.json index 14ed9b1d76..05ac71a2b3 100644 --- a/plugins/dotnet-aspnetcore/plugin.json +++ b/plugins/dotnet-aspnetcore/plugin.json @@ -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/"] } diff --git a/plugins/dotnet-aspnetcore/skills/author-controller-endpoints/SKILL.md b/plugins/dotnet-aspnetcore/skills/author-controller-endpoints/SKILL.md new file mode 100644 index 0000000000..1254f23737 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/author-controller-endpoints/SKILL.md @@ -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 with the typed helpers + +Derive the controller from `ControllerBase`, annotate it with `[ApiController]` and attribute routes, and return `ActionResult` using the status helpers (`Ok`, `CreatedAtAction`, `NotFound`, `NoContent`, `ValidationProblem`). `ActionResult` 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> 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` 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> 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 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`; 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]`. diff --git a/plugins/dotnet-aspnetcore/skills/author-minimal-api-endpoints/SKILL.md b/plugins/dotnet-aspnetcore/skills/author-minimal-api-endpoints/SKILL.md new file mode 100644 index 0000000000..6a9a799b1b --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/author-minimal-api-endpoints/SKILL.md @@ -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, 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>`); 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, NotFound, ValidationProblem>> ( + int orderId, AddItemRequest req, StoreDbContext db) => +{ + if (req.Quantity < 1) + { + return TypedResults.ValidationProblem(new Dictionary + { + ["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. diff --git a/plugins/dotnet-aspnetcore/skills/authorize-api-endpoints/SKILL.md b/plugins/dotnet-aspnetcore/skills/authorize-api-endpoints/SKILL.md new file mode 100644 index 0000000000..31381fa4c5 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/authorize-api-endpoints/SKILL.md @@ -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 +{ + 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(); + 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(); + +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 +{ + 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. diff --git a/plugins/dotnet-aspnetcore/skills/change-tracking-delta/SKILL.md b/plugins/dotnet-aspnetcore/skills/change-tracking-delta/SKILL.md new file mode 100644 index 0000000000..9a4ed3cd42 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/change-tracking-delta/SKILL.md @@ -0,0 +1,212 @@ +--- +name: change-tracking-delta +description: > + Add incremental change tracking (delta) to a collection API so a client can sync a local copy and then + fetch only what changed - added, updated, and removed - since its last sync, without re-downloading the + whole collection. + USE FOR: an endpoint that returns changes since a client's last snapshot; a change-tracking / delta link + the client returns with; reporting deletions to the client (tombstones) rather than letting them + silently vanish; choosing and safely advancing a change watermark (rowversion or timestamp); a + multipart change response that keeps entity payloads unchanged; conveying no-changes; expiring a stale + token. + DO NOT USE FOR: ordinary forward pagination of a collection (use the data-access skills); optimistic + concurrency on a single resource (use the concurrency skills); real-time push/streaming. +license: MIT +--- + +# Change Tracking (Delta) + +After an initial sync, a client should be able to ask for only what changed since last time - entities **added**, **updated**, and **removed** - instead of re-reading the whole collection. This is keyset pagination whose ordering key is a per-entity **change marker**: the client holds an opaque token for "the last change I saw," and the server returns everything past it, then a fresh token. + +## What the collection must provide + +- **A monotonic change marker per entity** that advances on every create, update, **and** soft-delete, so "changed since token" is a queryable range. A database **rowversion** is the right default: it is assigned and bumped by the database on every write, so it is monotonic without app code. +- **Soft delete** (`IsDeleted` + `DeletedAt`), with the change marker bumped at deletion time. A hard delete erases the row, so the delta can never *report* the deletion - the client would keep a phantom. Deleted rows are retained as tombstones. + +## Map the rowversion as a comparable marker + +A SQL Server rowversion is stored as 8 bytes, which C# cannot range-compare with `>`. Map it to a `ulong` with an **order-preserving** (big-endian) converter so a range query over it is valid and monotonic. + +```csharp +public class Contact +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public ulong Version { get; set; } // the row's change marker (a rowversion) +} + +// OnModelCreating: rowversion column, exposed as an order-preserving number. +modelBuilder.Entity() + .Property(c => c.Version) + .IsRowVersion() + .HasConversion(new NumberToBytesConverter()); +``` + +## The query: everything past the watermark, in total order + +Decode the client's token to a watermark, return rows whose marker is beyond it - **including soft-deleted ones** so removals surface - ordered by the marker with a unique tiebreaker, and page with keyset. + +```csharp +var changed = await db.Contacts + .AsNoTracking() + .Where(c => c.Version > watermark) // include soft-deleted: do not filter IsDeleted here + .OrderBy(c => c.Version).ThenBy(c => c.Id) // total order over the change marker + .Take(limit + 1) // one extra to detect another page + .ToListAsync(ct); +``` + +If your provider cannot translate `>` on the mapped rowversion, run the range predicate as raw SQL (`FromSql($"... WHERE [Version] > {watermark}")`); the ordering is the same. + +## The response: multipart, so entity payloads stay unchanged + +Return the changes as a `multipart/mixed` body whose parts are each an `application/http` sub-response (RFC 9112). An added or updated entity is a `200 OK` part carrying its **normal representation**; a removed entity is a body-less `204 No Content` part - the client reads "body present" as upsert and "no body" as remove, and `Content-Location` identifies which resource. This keeps every part in the success range (a `204` mirrors a successful `DELETE`) rather than using a `4xx` for a resource that is legitimately gone. Nothing is wrapped and no entity gains a `removed` field. The change-tracking link travels in the response `Link` header. + +Add and update are both `200` with the current representation, so the client upserts (no local copy means add, otherwise update). Distinguishing them is only possible, and only worth it, if you keep a **creation marker separate from the last-change marker**: then you may return `201 Created` for an entity whose creation is past the client's watermark and `200` for one merely updated. Without that separate marker, `200` covers both. + +```csharp +public sealed record DeltaItem(string SelfUrl, bool Removed, object? Body); + +public static class DeltaResponse +{ + public static async Task WriteAsync( + HttpResponse response, IReadOnlyList items, string deltaLink, string? nextLink, CancellationToken ct) + { + var boundary = "delta_" + Guid.NewGuid().ToString("N"); + response.StatusCode = StatusCodes.Status200OK; + response.ContentType = $"multipart/mixed; boundary=\"{boundary}\""; + + // rel="deltaLink": come back here for the next round; rel="next": more pages in this delta. + var link = $"<{deltaLink}>; rel=\"deltaLink\""; + if (nextLink is not null) + { + link += $", <{nextLink}>; rel=\"next\""; + } + + response.Headers["Link"] = link; + + await using var writer = new StreamWriter(response.Body, new UTF8Encoding(false), leaveOpen: true); + foreach (var item in items) + { + await writer.WriteAsync($"\r\n--{boundary}\r\nContent-Type: application/http\r\n\r\n"); + if (item.Removed) + { + // No body: 204 keeps the part in the success range and reads as a DELETE-style outcome. + await writer.WriteAsync($"HTTP/1.1 204 No Content\r\nContent-Location: {item.SelfUrl}\r\n\r\n"); + } + else + { + var json = JsonSerializer.Serialize(item.Body); + await writer.WriteAsync( + $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Location: {item.SelfUrl}\r\n\r\n{json}\r\n"); + } + } + + await writer.WriteAsync($"\r\n--{boundary}--\r\n"); + } +} +``` + +## The endpoint + +An absent token means the first sync (watermark of zero); a present-but-unreadable or expired token means the client must start over (`410`). Hand back a `deltaLink` carrying the new high-water mark; when the delta itself spans pages, also emit a `next` link that continues from where this page ended. + +```csharp +[HttpGet("delta")] +public async Task Delta(string? token, CancellationToken ct) +{ + ulong watermark = 0; + if (token is not null && !DeltaToken.TryDecode(token, out watermark)) + { + return StatusCode(StatusCodes.Status410Gone); // stale/unknown token: client does a full resync + } + + const int limit = 100; + var changed = await db.Contacts + .AsNoTracking() + .Where(c => c.Version > watermark) + .OrderBy(c => c.Version).ThenBy(c => c.Id) + .Take(limit + 1) + .ToListAsync(ct); + + var hasNext = changed.Count > limit; + var page = hasNext ? changed.Take(limit).ToList() : changed; + var newWatermark = page.Count > 0 ? page[^1].Version : watermark; + + var items = page + .Select(c => new DeltaItem($"/contacts/{c.Id}", c.IsDeleted, c.IsDeleted ? null : c.ToDto())) + .ToList(); + + var deltaLink = $"/contacts/delta?token={DeltaToken.Encode(newWatermark)}"; + var nextLink = hasNext ? deltaLink : null; + + await DeltaResponse.WriteAsync(Response, items, deltaLink, nextLink, ct); + return new EmptyResult(); // the body is already written +} +``` + +A minimal API handler is identical apart from the entry point: take `string? token`, `HttpContext http`, call `DeltaResponse.WriteAsync(http.Response, ...)`, and `return Results.Empty;`. + +**No changes** falls out naturally: `changed` is empty, so the body is an empty multipart and the `Link` header still carries a refreshed `deltaLink` at the same watermark - the client learns "nothing new, come back with this." + +## What it looks like on the wire + +One added or updated resource (both `200` with the representation) and one removed resource (`204`, no body): + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary="delta_9f2c1a7b" +Link: ; rel="deltaLink" + +--delta_9f2c1a7b +Content-Type: application/http + +HTTP/1.1 200 OK +Content-Location: /contacts/1042 +Content-Type: application/json +ETag: "0x000000000000A3E1" + +{"id":1042,"name":"Ada Lovelace","email":"ada@example.com"} + +--delta_9f2c1a7b +Content-Type: application/http + +HTTP/1.1 204 No Content +Content-Location: /contacts/993 + +--delta_9f2c1a7b-- +``` + +When the change set spans pages, every page but the last carries `rel="next"`; only the final page carries `rel="deltaLink"`. A no-changes response is just the closing boundary plus a refreshed `deltaLink`. The optional `ETag` on a `200` part is the resource's rowversion, letting the client do conditional requests on it later. + +## Advancing the watermark safely + +The token is **opaque** to the client (an encoded watermark); only the server reads it. The subtlety is not losing changes at the boundary. + +Rowversion is strictly monotonic, but a long transaction can be *assigned* a low rowversion yet *commit later* than a reader who already advanced the watermark, so a naive `> watermark` next time skips it. Do not hand out a watermark above the oldest still-open write: cap it at `MIN_ACTIVE_ROWVERSION()` on SQL Server, or overlap slightly and dedup by id. Always order by `(marker, id)` so the watermark advances deterministically. + +## When rowversion is not available: a timestamp watermark + +On a store without a rowversion, use a `LastModifiedAt` timestamp that **application code sets on every create, update, and soft-delete**, and treat it as the marker. It is portable but weaker, so add safeguards: + +- **Overlap and dedup:** clock skew and coarse resolution mean two rows can share a boundary value, so query `c.LastModifiedAt >= watermark` (not `>`) with a small safety overlap, and have the client **dedup by id**. Keep the `(LastModifiedAt, id)` tiebreaker so a tie is not split across a page boundary. +- **Set it consistently:** every write path, including soft-delete, must stamp `LastModifiedAt` (ideally from a single server clock), or changes are missed. + +Everything else - soft-delete tombstones, the multipart response, the `deltaLink`, no-changes, `410` on a stale token - is the same. + +## Standards basis + +This uses only general HTTP: `multipart/mixed` (RFC 2046) of `application/http` messages (RFC 9112), the `Link` header with `rel="next"`/`rel="self"` and an extension `rel="deltaLink"` (RFC 8288), `204 No Content` for a removed resource (a DELETE-style outcome, kept in the success range), and `410 Gone` for an expired change-tracking token. A simpler, less strict alternative keeps a bare JSON array and marks deletions inline (`{ "id": "...", "removed": true }`) with the links in the `Link` header; prefer that only when a multipart body is impractical for the client. + +## Verify + +- Changes are found by a monotonic per-entity change marker - a rowversion by default (mapped order-preserving so `> watermark` is valid), or an app-maintained timestamp when no rowversion exists - returning only entities past the client's watermark, not a full rescan. +- Deletions are reported: soft-deleted rows are included and surface as body-less `204 No Content` tombstones (the client removes them), so the client stops keeping them; a hard delete would make the deletion invisible. +- The client gets an opaque change-tracking link back via the `Link` header (`rel="deltaLink"`), and a `next` link when the delta spans pages. +- The delta is ordered by `(marker, id)`; the watermark advances deterministically and the boundary hazard is handled (rowversion in-flight commits via `MIN_ACTIVE_ROWVERSION`; timestamp via `>=` overlap and dedup). +- No-changes returns an empty delta plus a refreshed `deltaLink`; a stale/unknown token returns `410 Gone` so the client resyncs. +- Entity payloads are unchanged - added/updated entities carry their normal representation, not an envelope or an added field. + +❌ Query `LastModifiedAt > since` with no tiebreaker, hard deletes, and a body that just omits removed rows - the client never learns about deletions and can miss changes at the boundary. +✅ A monotonic marker ordered `(marker, id)`, soft-delete tombstones reported as body-less `204` parts, an opaque `deltaLink`, and boundary-safe watermark advancement. diff --git a/plugins/dotnet-aspnetcore/skills/configuring-opentelemetry-dotnet/SKILL.md b/plugins/dotnet-aspnetcore/skills/configuring-opentelemetry-dotnet/SKILL.md deleted file mode 100644 index c99fe5c521..0000000000 --- a/plugins/dotnet-aspnetcore/skills/configuring-opentelemetry-dotnet/SKILL.md +++ /dev/null @@ -1,289 +0,0 @@ ---- -name: configuring-opentelemetry-dotnet -description: Configure OpenTelemetry distributed tracing, metrics, and logging in ASP.NET Core using the .NET OpenTelemetry SDK. Use when adding observability, setting up OTLP exporters, creating custom metrics/spans, or troubleshooting distributed trace correlation. -license: MIT ---- - -# Configuring OpenTelemetry in .NET - -## When to Use - -- Adding distributed tracing to an ASP.NET Core application -- Setting up OpenTelemetry exporters (OTLP is the primary protocol; Jaeger accepts OTLP natively; Prometheus OTLP ingestion requires explicit opt-in) -- Creating custom metrics or trace spans for business operations -- Troubleshooting distributed trace context propagation across services - -## When Not to Use - -- The user wants application-level logging only (use ILogger, Serilog) -- The user is using Application Insights SDK directly (different API) -- The user needs APM with a commercial vendor's proprietary SDK - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| ASP.NET Core project | Yes | The application to instrument | -| Observability backend | No | Where to export: OTLP collector, Aspire dashboard, Jaeger (accepts OTLP natively) | - -## Workflow - -### Step 1: Install the correct packages - -**There are many OpenTelemetry NuGet packages. Install exactly these:** - -```bash -# Core SDK + ASP.NET Core instrumentation + logging integration -dotnet add package OpenTelemetry.Extensions.Hosting -dotnet add package OpenTelemetry.Instrumentation.AspNetCore -dotnet add package OpenTelemetry.Instrumentation.Http - -# Exporter -dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # OTLP exporter for traces, metrics, AND logs - -# Optional — dev/local debugging only (do NOT include in production deployments) -# dotnet add package OpenTelemetry.Exporter.Console -``` - -**Do NOT install `OpenTelemetry` alone** — you need `OpenTelemetry.Extensions.Hosting` for proper DI integration. - -#### Optional: additional auto-instrumentation packages - -Install only the packages that match the libraries your application uses: - -```bash -dotnet add package OpenTelemetry.Instrumentation.SqlClient # SQL Server queries -dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore # EF Core -dotnet add package OpenTelemetry.Instrumentation.GrpcNetClient # gRPC calls -dotnet add package OpenTelemetry.Instrumentation.Runtime # GC, thread pool metrics -``` - -### Step 2: Configure all signals in Program.cs - -```csharp -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -using OpenTelemetry.Metrics; -using OpenTelemetry.Logs; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddOpenTelemetry() - .ConfigureResource(resource => resource - .AddService(serviceName: builder.Environment.ApplicationName)) - .WithTracing(tracing => tracing - .AddAspNetCoreInstrumentation(options => - { - // Filter out health check endpoints from traces - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments("/healthz"); - }) - .AddHttpClientInstrumentation(options => - { - options.RecordException = true; - }) - // Optional: add SQL instrumentation if using SqlClient directly - // .AddSqlClientInstrumentation(options => - // { - // options.SetDbStatementForText = true; - // options.RecordException = true; - // }) - // Custom activity sources (must match ActivitySource names in your code) - .AddSource("MyApp.Orders") - .AddSource("MyApp.Payments") - .AddSource("MyApp.Messaging")) - .WithMetrics(metrics => metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - // Optional: .AddRuntimeInstrumentation() for GC and thread pool metrics - // (requires OpenTelemetry.Instrumentation.Runtime package) - // Custom meters (must match Meter names in your code) - .AddMeter("MyApp.Metrics")) - .WithLogging(logging => - { - logging.IncludeScopes = true; - // logging.IncludeFormattedMessage = true; // Enable if you need the formatted message string in log exports - }) - // Single OTLP exporter for all signals — reads OTEL_EXPORTER_OTLP_ENDPOINT - // env var (defaults to http://localhost:4317). Override via environment variable - // or appsettings.json configuration. - .UseOtlpExporter(); -``` - -### Step 3: Understanding log–trace correlation - -The `.WithLogging()` call in Step 2 integrates ILogger with OpenTelemetry: - -- Each log entry automatically includes TraceId and SpanId for correlation with traces -- The service resource from `.ConfigureResource()` propagates to logs automatically -- `UseOtlpExporter()` applies to logs alongside traces and metrics -- No additional packages or separate `SetResourceBuilder` call needed - -### Step 4: Create custom spans (Activities) for business operations - -```csharp -using System.Diagnostics; -using Microsoft.Extensions.Logging; - -public class OrderService -{ - // Create an ActivitySource matching what you registered in Step 2 - private static readonly ActivitySource ActivitySource = new("MyApp.Orders"); - private readonly ILogger _logger; - - public OrderService(ILogger logger) => _logger = logger; - - public async Task ProcessOrderAsync(CreateOrderRequest request) - { - // Start a new span - using var activity = ActivitySource.StartActivity("ProcessOrder"); - - // Add attributes (tags) to the span - activity?.SetTag("order.customer_id", request.CustomerId); - activity?.SetTag("order.item_count", request.Items.Count); - - try - { - // Child span for validation - using (var validationActivity = ActivitySource.StartActivity("ValidateOrder")) - { - await ValidateOrderAsync(request); - validationActivity?.SetTag("validation.result", "passed"); - } - - // Child span for payment - using (var paymentActivity = ActivitySource.StartActivity("ProcessPayment", - ActivityKind.Client)) // Client = outgoing call - { - paymentActivity?.SetTag("payment.method", request.PaymentMethod); - await ProcessPaymentAsync(request); - } - - var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId, Status = "Completed" }; - - activity?.SetTag("order.status", "completed"); - activity?.SetStatus(ActivityStatusCode.Ok); - - return order; - } - catch (Exception ex) - { - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - // Log via ILogger — OpenTelemetry captures this with trace correlation. - // Prefer logging over activity.RecordException() as OTel is deprecating - // span events for exception recording in favor of log-based exceptions. - _logger.LogError(ex, "Order processing failed for customer {CustomerId}", request.CustomerId); - throw; - } - } -} -``` - -**Critical: `ActivitySource` name must match `AddSource("...")` in configuration.** Unmatched sources are silently ignored — this is the #1 debugging issue. - -### Step 5: Create custom metrics - -Use `IMeterFactory` (injected via DI) to create meters — this ensures proper lifetime management and testability. - -```csharp -using System.Diagnostics; -using System.Diagnostics.Metrics; - -public class OrderMetrics -{ - private readonly Counter _ordersProcessed; - private readonly Histogram _orderProcessingDuration; - private readonly UpDownCounter _activeOrders; - - public OrderMetrics(IMeterFactory meterFactory) - { - // Meter name must match AddMeter("...") in configuration - var meter = meterFactory.Create("MyApp.Metrics"); - - // Counter — use for things that only go up - _ordersProcessed = meter.CreateCounter( - "orders.processed", "orders", "Total orders successfully processed"); - - // Histogram — use for measuring distributions (latency, sizes) - _orderProcessingDuration = meter.CreateHistogram( - "orders.processing_duration", "ms", "Time to process an order"); - - // UpDownCounter — use for things that go up AND down - _activeOrders = meter.CreateUpDownCounter( - "orders.active", "orders", "Currently processing orders"); - } - - public void RecordOrderProcessed(string region, double durationMs) - { - // Tags enable dimensional filtering (by region, status, etc.) - var tags = new TagList - { - { "region", region }, - { "order.type", "standard" } - }; - - _ordersProcessed.Add(1, tags); - _orderProcessingDuration.Record(durationMs, tags); - } -} -``` - -Register `OrderMetrics` in DI: - -```csharp -builder.Services.AddSingleton(); -``` - -### Step 6: Configure context propagation for distributed scenarios - -Trace context propagation is automatic for HTTP calls when using `AddHttpClientInstrumentation()`. For non-HTTP scenarios: - -```csharp -using System; -using System.Collections.Generic; -using System.Diagnostics; -using OpenTelemetry.Context.Propagation; - -// ActivitySource should be static — register via .AddSource("MyApp.Messaging") in Step 2 -private static readonly ActivitySource MessageSource = new("MyApp.Messaging"); - -// Manual context propagation (e.g., across message queues) -// On the SENDING side: -var propagator = Propagators.DefaultTextMapPropagator; -var activityContext = Activity.Current?.Context ?? default; -var context = new PropagationContext(activityContext, Baggage.Current); -var carrier = new Dictionary(); - -propagator.Inject(context, carrier, (dict, key, value) => dict[key] = value); -// Send carrier dictionary as message headers - -// On the RECEIVING side: -var parentContext = propagator.Extract(default, carrier, - (dict, key) => dict.TryGetValue(key, out var value) ? new[] { value } : Array.Empty()); - -Baggage.Current = parentContext.Baggage; -using var activity = MessageSource.StartActivity("ProcessMessage", - ActivityKind.Consumer, - parentContext.ActivityContext); // Links to parent trace! -``` - -## Validation - -- [ ] Traces appear in the observability backend (Jaeger, Aspire dashboard, etc.) -- [ ] HTTP requests automatically create spans with correct verb, URL, status code -- [ ] Custom `ActivitySource` names match `AddSource()` registrations -- [ ] Custom `Meter` names match `AddMeter()` registrations -- [ ] Logs include TraceId and SpanId for correlation -- [ ] Health check endpoints are filtered from traces -- [ ] Exception details appear on error spans - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| `ActivitySource.StartActivity` returns null | Source name doesn't match any `AddSource()` — names must match exactly | -| Traces not appearing in exporter | Check OTLP endpoint: gRPC uses port 4317, HTTP uses 4318 | -| Missing HTTP client spans | Ensure `AddHttpClientInstrumentation()` is registered; it works for both `IHttpClientFactory`/DI and `new HttpClient()` (use `IHttpClientFactory` for lifetime management) | -| High cardinality tags | Don't use user IDs, request IDs, or UUIDs as metric tags — explodes storage | -| OTLP gRPC vs HTTP mismatch | Default is gRPC (port 4317); if collector only accepts HTTP, set `OtlpExportProtocol.HttpProtobuf` | -| `Meter` / `ActivitySource` lifecycle | `ActivitySource` should be static; create `Meter` via `IMeterFactory` from DI (not `new Meter()`) for proper lifetime management and testability | diff --git a/plugins/dotnet-aspnetcore/skills/controller-concurrency/SKILL.md b/plugins/dotnet-aspnetcore/skills/controller-concurrency/SKILL.md new file mode 100644 index 0000000000..82c35afa3b --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/controller-concurrency/SKILL.md @@ -0,0 +1,173 @@ +--- +name: controller-concurrency +description: > + Add optimistic concurrency and HTTP conditional requests to ASP.NET Core controller actions, using + both validators a resource can offer. + USE FOR: protecting controller updates against concurrent edits and lost updates; exposing an ETag + and a Last-Modified header from a resource; honoring If-Match and If-None-Match (the content/ETag + validator) and If-Modified-Since and If-Unmodified-Since (the time/Last-Modified validator); + returning 304, 412, or 428; handling DbUpdateConcurrencyException. + DO NOT USE FOR: minimal API route handlers (use minimal-api-concurrency); basic action result types + and status codes (use author-controller-endpoints); general EF Core querying (use the data-access + skills); service-layer structure (use structure-api-business-logic). +license: MIT +--- + +# Controller Concurrency and Conditional Requests + +A resource can carry two independent validators, and a complete implementation offers both: an **ETag** for content and an **Last-Modified** for time. Emit both on reads and honor both on writes. + +- **ETag** comes from the concurrency token (a database rowversion mapped with `[Timestamp]`, or an app-managed version). It changes only when the resource's content changes. It drives `If-Match` (write precondition: stale write to 412) and `If-None-Match` (read: unchanged to 304; `*` means create-only). +- **Last-Modified** comes from the resource's last-modified timestamp. It is the time-based validator. It drives `If-Modified-Since` (read: not changed since to 304) and `If-Unmodified-Since` (write precondition: changed since to 412). + +A resource that has a last-modified timestamp must offer `Last-Modified`, not only an `ETag`. The two answer different questions ("is it the exact same version" versus "has it changed since this time") and clients rely on each. + +## One helper that reads both validators off the entity + +```csharp +public static class ConditionalRequest +{ + public static string ETag(byte[] rowVersion) + { + return $"\"{Convert.ToBase64String(rowVersion)}\""; + } + + public static string LastModified(DateTimeOffset lastModifiedAt) + { + return lastModifiedAt.ToString("R"); + } + + public static void WriteValidators(HttpResponse response, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + response.Headers.ETag = ETag(rowVersion); + response.Headers.LastModified = LastModified(lastModifiedAt); + } + + // A read can answer 304 when the client's validators still match the current state. + public static bool IsNotModified(HttpRequest request, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + var ifNoneMatch = request.Headers.IfNoneMatch.ToString(); + if (!string.IsNullOrEmpty(ifNoneMatch)) + { + return string.Equals(ifNoneMatch, ETag(rowVersion), StringComparison.Ordinal); + } + + if (DateTimeOffset.TryParse(request.Headers.IfModifiedSince, out var since)) + { + // HTTP-date has one-second resolution. + return lastModifiedAt <= since.AddSeconds(1); + } + + return false; + } + + // A write must be refused with 412 when the client based it on a stale copy. + public static bool PreconditionFailed(HttpRequest request, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + var ifMatch = request.Headers.IfMatch.ToString(); + if (!string.IsNullOrEmpty(ifMatch) && !string.Equals(ifMatch, "*", StringComparison.Ordinal)) + { + return !string.Equals(ifMatch, ETag(rowVersion), StringComparison.Ordinal); + } + + if (DateTimeOffset.TryParse(request.Headers.IfUnmodifiedSince, out var limit)) + { + return lastModifiedAt > limit.AddSeconds(1); + } + + return false; + } + + // True when the client supplied no write precondition at all; an endpoint that + // requires one answers 428 Precondition Required instead of writing blindly. + public static bool HasNoPrecondition(HttpRequest request) + { + return string.IsNullOrEmpty(request.Headers.IfMatch) + && string.IsNullOrEmpty(request.Headers.IfUnmodifiedSince); + } +} +``` + +Compare ETag and header tokens with `StringComparison.Ordinal`, never the culture-sensitive default. + +## Conditional GET + +```csharp +[HttpGet("{id:int}")] +public async Task> Get(int id, CancellationToken ct) +{ + var resource = await db.Resources.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id, ct); + if (resource is null) + { + return NotFound(); + } + + if (ConditionalRequest.IsNotModified(Request, resource.RowVersion, resource.LastModifiedAt)) + { + return StatusCode(StatusCodes.Status304NotModified); + } + + ConditionalRequest.WriteValidators(Response, resource.RowVersion, resource.LastModifiedAt); + return Ok(resource.ToDto()); +} +``` + +## Conditional update + +```csharp +[HttpPut("{id:int}")] +public async Task> Update(int id, UpdateResourceRequest request, CancellationToken ct) +{ + var resource = await db.Resources.FindAsync([id], ct); + if (resource is null) + { + return NotFound(); + } + + // An endpoint that requires a precondition refuses a blind write. + if (ConditionalRequest.HasNoPrecondition(Request)) + { + return StatusCode(StatusCodes.Status428PreconditionRequired); + } + + if (ConditionalRequest.PreconditionFailed(Request, resource.RowVersion, resource.LastModifiedAt)) + { + return StatusCode(StatusCodes.Status412PreconditionFailed); + } + + Apply(resource, request); + resource.LastModifiedAt = DateTimeOffset.UtcNow; // bump the time validator on every content change + + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + // Two writers raced past the header check; the rowversion in the UPDATE caught it. + return StatusCode(StatusCodes.Status412PreconditionFailed); + } + + ConditionalRequest.WriteValidators(Response, resource.RowVersion, resource.LastModifiedAt); + return Ok(resource.ToDto()); +} +``` + +The header check rejects the obvious stale write; the tracked entity's rowversion still backstops the race at `SaveChanges`, raising `DbUpdateConcurrencyException`. Catch it and map it to 412 (or 409 for an endpoint with no conditional header); never let it surface as a 500. + +## Verify + +- A read emits **both** `ETag` and `Last-Modified`; `If-None-Match` returns 304 and `If-Modified-Since` returns 304, each independently. +- A write honors **both** `If-Match` (412 when stale) and `If-Unmodified-Since` (412 when changed since); a write that requires a precondition but receives none returns 428 Precondition Required. +- A 304 response has no body. +- The `ETag` is quoted and reflects the rowversion (content); `Last-Modified` reflects the timestamp; ETag comparisons are ordinal. +- A `SaveChanges` race is caught as `DbUpdateConcurrencyException` and returned as 412 or 409. + +❌ Offering only an `ETag` when the resource also carries a last-modified timestamp. +✅ Emit and honor both the `ETag` and `Last-Modified` validators. + +❌ Comparing ETags or header values with the culture-sensitive default `==`. +✅ `string.Equals(..., StringComparison.Ordinal)`. + +❌ Checking the precondition header but not handling the `SaveChanges` race. +✅ Catch `DbUpdateConcurrencyException` as well. diff --git a/plugins/dotnet-aspnetcore/skills/controller-data-access/SKILL.md b/plugins/dotnet-aspnetcore/skills/controller-data-access/SKILL.md new file mode 100644 index 0000000000..c663367eee --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/controller-data-access/SKILL.md @@ -0,0 +1,87 @@ +--- +name: controller-data-access +description: > + Read collections efficiently from controller actions: keyset pagination over a deterministic total + order, the next page delivered as an RFC 8288 Link header, reading without tracking, and projecting in + the query. + USE FOR: implementing a controller action that lists or queries a collection backed by EF Core; paging + a large or frequently-changing collection correctly; ordering by a total order with a unique tiebreaker; + keyset/cursor paging instead of Skip/OFFSET; delivering the next-page link via the Link header; reading + with AsNoTracking and projecting to a DTO; computing counts in the query. + DO NOT USE FOR: minimal API route handlers (use minimal-api-data-access); endpoint result types and + status codes (use author-controller-endpoints); DTO shape design (use the model-payloads skills); + optimistic concurrency or ETags (use controller-concurrency); incremental change tracking / delta + (use the change-tracking skill). +license: MIT +--- + +# Controller Data Access + +A collection endpoint must return a bounded page, ordered by a deterministic total order, and hand the client the next page rather than make the client compute offsets. Returning the whole table grows unbounded, and offset paging silently breaks when the collection changes between requests. + +## Page with a total order, a keyset cursor, and a Link header + +- **Total order:** order by a key whose final component is unique (the primary key). If the sort column has ties, the database may break them differently between queries, so `Skip`/`Take` can repeat or skip rows across pages. End every `OrderBy` with `ThenBy(x => x.Id)`. +- **Keyset, not offset:** seek past the last item of the previous page with a `WHERE (sortKey, id) > (lastSortKey, lastId)` comparison instead of `Skip`/`OFFSET`. A value boundary does not shift when earlier rows are inserted or deleted, so pages do not duplicate or skip; it also seeks on the index instead of scanning `offset + limit` rows. +- **Immutable seek key:** sort and seek on an immutable key (the id, or an insertion-ordered column) so a row is not re-emitted if a mutable sort field changes after it was returned. +- **Link header (RFC 8288):** return the collection itself as the body and put the next page in the `Link` header with `rel="next"`; the client follows it opaquely. Omit the header on the last page. + +```csharp +[HttpGet] +public async Task>> List( + string namespaceName, [FromQuery] string? cursor, [FromQuery] int limit, CancellationToken ct) +{ + limit = Math.Clamp(limit == 0 ? 20 : limit, 1, 100); // bound the page size + + var source = db.Queues + .AsNoTracking() + .Where(q => q.Namespace.Name == namespaceName && !q.IsDeleted); + + if (Cursor.TryDecode(cursor, out var lastName, out var lastId)) + { + // Seek past the previous page: (Name, Id) > (lastName, lastId). + source = source.Where(q => + string.Compare(q.Name, lastName) > 0 || (q.Name == lastName && q.Id > lastId)); + } + + var rows = await source + .OrderBy(q => q.Name).ThenBy(q => q.Id) // total order ending in the unique key + .Take(limit + 1) // fetch one extra to detect a next page + .Select(q => new QueueDto(q.Id, q.Name, q.Status)) + .ToListAsync(ct); + + var hasNext = rows.Count > limit; + var items = hasNext ? rows.Take(limit).ToList() : rows; + + if (hasNext) + { + var last = items[^1]; + var next = Cursor.Encode(last.Name, last.Id); + var url = Url.Action(nameof(List), new { namespaceName, cursor = next, limit }); + Response.Headers["Link"] = $"<{url}>; rel=\"next\""; + } + + return Ok(items); // the body is the bare collection +} +``` + +`Cursor` is a small helper that encodes the last row's `(Name, Id)` into an opaque token (for example base64url) and decodes it back; the client treats the token as a black box. + +## Read without tracking, project, and count in the query + +- Read with `AsNoTracking` and project to the DTO inside the query, so only the needed columns are fetched. +- When a scalar summary is needed (for example a count of children), compute it in the query with `CountAsync` or a projection; do not load a collection into memory just to count it. +- If clients need a total count, run a separate `CountAsync` and return it in an `X-Total-Count` header rather than forcing it into the body. + +## Verify + +- Results are ordered by a total order whose final key is unique (`ThenBy` the id), so paging cannot repeat or skip rows on ties. +- Paging is keyset (`(sortKey, id) > (lastSortKey, lastId)`) applied in the query, not `Skip`/`OFFSET`, and the page size is bounded. +- The next page is delivered as an RFC 8288 `Link` header with `rel="next"`, and the body is the collection itself. +- Reads use `AsNoTracking` and project to the response shape in the query; counts are computed in the query. + +❌ `OrderBy(q => q.Name)` alone, then `Skip`/`Take` — a non-unique sort plus offset repeats or skips rows when the data changes. +✅ `OrderBy(q => q.Name).ThenBy(q => q.Id)` with a keyset seek and a `Link` header. + +❌ Returning a page-number/offset envelope the client must assemble into the next request. +✅ Hand the client an opaque `next` link in the `Link` header. diff --git a/plugins/dotnet-aspnetcore/skills/convert-blazor-server-to-webapp/SKILL.md b/plugins/dotnet-aspnetcore/skills/convert-blazor-server-to-webapp/SKILL.md deleted file mode 100644 index 819ed43d94..0000000000 --- a/plugins/dotnet-aspnetcore/skills/convert-blazor-server-to-webapp/SKILL.md +++ /dev/null @@ -1,295 +0,0 @@ ---- -name: convert-blazor-server-to-webapp -license: MIT -description: > - Guides conversion of a pre-.NET 8 Blazor Server app into a .NET 8+ Blazor Web App. - USE FOR: migrating apps that use AddServerSideBlazor and MapBlazorHub to the - AddRazorComponents/MapRazorComponents model, converting _Host.cshtml to an App.razor - root component, replacing blazor.server.js with blazor.web.js, migrating - CascadingAuthenticationState to a service, adopting new Blazor Web App features - like enhanced navigation and streaming rendering. - DO NOT USE FOR: apps that are already Blazor Web Apps (already use AddRazorComponents - and MapRazorComponents), Blazor WebAssembly or hosted Blazor WebAssembly apps - (different migration path), apps that should stay on the Blazor Server hosting - model without converting, or apps still targeting .NET Framework. ---- - -# Convert Blazor Server App to Blazor Web App - -This skill helps an agent convert a pre-.NET 8 Blazor Server app into a .NET 8+ Blazor Web App. The old hosting model uses `AddServerSideBlazor`/`MapBlazorHub` with a `_Host.cshtml` Razor Page as the entry point. The new Blazor Web App model uses `AddRazorComponents`/`MapRazorComponents` with an `App.razor` root component, enabling per-component render modes, enhanced navigation, streaming rendering, and other .NET 8+ features. The converted app uses `InteractiveServer` render mode to preserve existing interactive behavior. - -## When to Use - -- Migrating a Blazor Server app from .NET 6 or .NET 7 to .NET 8+ -- App currently uses `AddServerSideBlazor()` and `MapBlazorHub()` in `Program.cs` (or `Startup.cs`) -- App uses `Pages/_Host.cshtml` (or `_Host.razor`) as the host page with Component Tag Helpers -- Want to adopt new Blazor Web App features while keeping interactive server rendering - -## When Not to Use - -- **The app already uses `AddRazorComponents` and `MapRazorComponents`.** It is already a Blazor Web App — no conversion is needed. Stop here and tell the user the app is already using the Blazor Web App model. -- Blazor WebAssembly or hosted Blazor WebAssembly app — these have a different migration path -- The app should stay on the legacy Blazor Server hosting model (just update TFM and packages) -- The app targets .NET Framework — it must be migrated to .NET first - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Blazor Server project | Yes | The `.csproj` and source files of the Blazor Server app | -| Target framework | Yes | .NET 8 or later (e.g., `net8.0`, `net9.0`, `net10.0`) | -| `Program.cs` or `Startup.cs` | Yes | The app's service and middleware configuration | -| `_Host.cshtml` location | Recommended | Usually `Pages/_Host.cshtml`; may be `_Host.razor` in some projects | - -## Workflow - -> **Commit strategy:** Commit after each logical step so the migration is reviewable and bisectable. - -### Step 1: Update the project file - -Update the `.csproj` file: - -1. Change the Target Framework Moniker (TFM) to the target version: - ```xml - net8.0 - ``` -2. Update all `Microsoft.AspNetCore.*`, `Microsoft.EntityFrameworkCore.*`, `Microsoft.Extensions.*`, and `System.Net.Http.Json` package references to the matching version. - -For non-Blazor project file changes (nullable reference types, implicit usings, HTTP/3 support, etc.), see the [general ASP.NET Core migration guide](https://learn.microsoft.com/aspnet/core/migration/70-to-80). - -### Step 2: Create `Routes.razor` from `App.razor` - -The old `App.razor` contains the `` component. This content moves to a new `Routes.razor` file so that `App.razor` can become the root HTML document component. - -1. Create a new file `Routes.razor` in the project root. -2. Move the entire content of `App.razor` into `Routes.razor`. -3. If the content is wrapped in ``, remove that wrapper (it will be replaced by a service in Step 5). -4. Leave `App.razor` empty for the next step. - -The resulting `Routes.razor` should look similar to: - -```razor - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
-``` - -If the app uses `` instead of ``, keep it — it works the same way in Blazor Web Apps. - -### Step 3: Convert `_Host.cshtml` to `App.razor` - -Move the HTML shell from `Pages/_Host.cshtml` into the now-empty `App.razor` and transform it from a Razor Page into a Razor component: - -1. **Remove Razor Page directives** — delete `@page "/"`, `@using Microsoft.AspNetCore.Components.Web`, `@namespace`, and `@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers`. - -2. **Add component injection** — if using environment-conditional error UI, add: - ```razor - @inject IHostEnvironment Env - ``` - -3. **Fix the base tag** — replace `` with ``. - -4. **Replace HeadOutlet Component Tag Helper** — replace: - ```html - - ``` - with: - ```razor - - ``` - -5. **Replace App Component Tag Helper with Routes** — replace: - ```html - - ``` - with: - ```razor - - ``` - -6. **Replace Environment Tag Helpers** — replace: - ```html - - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - ``` - with: - ```razor - @if (Env.IsDevelopment()) - { - - An unhandled exception has occurred. See browser dev tools for details. - - } - else - { - - An error has occurred. This app may no longer respond until reloaded. - - } - ``` - -7. **Update the Blazor script** — replace: - ```html - - ``` - with: - ```html - - ``` - -8. **Add render mode import** — add to `_Imports.razor`: - ```razor - @using static Microsoft.AspNetCore.Components.Web.RenderMode - ``` - -9. **Delete `Pages/_Host.cshtml`** (and `Pages/_Host.cshtml.cs` if it exists). - -**Prerendering note:** If the original app used `render-mode="Server"` (not `"ServerPrerendered"`), prerendering was disabled. Preserve this by using `new InteractiveServerRenderMode(prerender: false)` instead of `InteractiveServer` for both `HeadOutlet` and `Routes`. - -### Step 4: Update `Program.cs` - -Make the following changes to `Program.cs` (or `Startup.cs` if the app uses the older hosting pattern): - -1. **Replace Blazor Server services** — replace: - ```csharp - builder.Services.AddServerSideBlazor(); - ``` - with: - ```csharp - builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); - ``` - - If `AddServerSideBlazor` had options configured (e.g., circuit options, hub options, detailed errors), migrate them to `AddInteractiveServerComponents`: - ```csharp - // Old: - builder.Services.AddServerSideBlazor(options => - { - options.DetailedErrors = true; - options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); - }); - - // New: - builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(options => - { - options.DetailedErrors = true; - options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); - }); - ``` - -2. **Replace Blazor endpoint mapping** — replace: - ```csharp - app.MapBlazorHub(); - ``` - with: - ```csharp - app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - ``` - - Ensure there is a `using` statement for the project's root namespace so that `App` resolves to the `App.razor` component. - -3. **Remove the fallback route** — delete: - ```csharp - app.MapFallbackToPage("/_Host"); - ``` - -4. **Remove explicit routing middleware** — delete if present: - ```csharp - app.UseRouting(); - ``` - Endpoint routing is the default and explicit `UseRouting()` is no longer needed. - -5. **Add antiforgery middleware** — add after `UseAuthentication`/`UseAuthorization` if present: - ```csharp - app.UseAntiforgery(); - ``` - `AddRazorComponents` registers antiforgery services automatically, but the middleware must be explicitly added to the pipeline. Without it, form POST requests fail with 400 errors. - -### Step 5: Migrate `CascadingAuthenticationState` (if present) - -If the app used `` to wrap the router: - -1. Remove the `` component wrapper (already done in Step 2 if following this workflow). -2. Add the cascading authentication state service in `Program.cs`: - ```csharp - builder.Services.AddCascadingAuthenticationState(); - ``` - -The component wrapper approach does not work across render mode boundaries in Blazor Web Apps. The service-based approach provides `Task` as a cascading value to all components regardless of render mode. - -### Step 6: Recommended improvements (optional) - -These are optional modernization improvements — not required for the conversion to work. If you suggest any of these, state explicitly that they are optional. - -- **Replace `UseStaticFiles` with `MapStaticAssets`** (.NET 9+): `app.MapStaticAssets()` provides optimized static file serving with fingerprinting, pre-compression, and content-based ETags. See [MapStaticAssets documentation](https://learn.microsoft.com/aspnet/core/fundamentals/static-files#mapstaticassets). -- **Add `@attribute [StreamRendering]`** to pages with async data loading (`OnInitializedAsync`) for improved perceived performance. The page renders its initial synchronous content immediately and re-renders when async data arrives. -- **Update CSS isolation bundle reference** if the `` tag referenced a `_Host` assembly name; ensure it matches the project's actual assembly name: ``. -- For other non-Blazor improvements (minimal hosting, HTTP/3, output caching, etc.), see the [general ASP.NET Core migration guide](https://learn.microsoft.com/aspnet/core/migration/70-to-80). - -### Step 7: Verify the migration - -1. Build the project targeting the new framework. Confirm no compile errors. -2. Search for remaining references to removed APIs: - - `AddServerSideBlazor` - - `MapBlazorHub` - - `MapFallbackToPage` - - `blazor.server.js` - - `_Host.cshtml` -3. Run the app and verify: - - Pages load and render correctly - - Interactive features work (forms, event handlers, SignalR circuits) - - Navigation between pages works - - Authentication and authorization flows work if present -4. Run existing tests. - -## Validation - -- [ ] No references to `AddServerSideBlazor` remain -- [ ] No references to `MapBlazorHub` remain -- [ ] No references to `MapFallbackToPage("/_Host")` remain -- [ ] No references to `blazor.server.js` remain -- [ ] `Pages/_Host.cshtml` has been deleted -- [ ] `App.razor` serves as the root component with a full HTML document structure -- [ ] `Routes.razor` contains the `` configuration -- [ ] `Program.cs` uses `AddRazorComponents().AddInteractiveServerComponents()` -- [ ] `Program.cs` uses `MapRazorComponents().AddInteractiveServerRenderMode()` -- [ ] `app.UseAntiforgery()` is present in the middleware pipeline -- [ ] If the app used ``, it has been replaced with `AddCascadingAuthenticationState()` service registration -- [ ] App builds and runs successfully on the target framework - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Missing `UseAntiforgery()` middleware | `AddRazorComponents` registers antiforgery services, but the middleware must be explicitly added. Place `app.UseAntiforgery()` after `UseAuthentication`/`UseAuthorization`. Without it, form POST requests fail with 400 errors. | -| Forgetting to replace `blazor.server.js` with `blazor.web.js` | The old script does not work with the Blazor Web App model. Replace all references to `_framework/blazor.server.js` with `_framework/blazor.web.js`. | -| Not removing `` wrapper | The component wrapper does not work across render mode boundaries in Blazor Web Apps. Use `builder.Services.AddCascadingAuthenticationState()` instead. | -| Leaving `app.UseRouting()` in the pipeline | Explicit `UseRouting()` is no longer needed and can interfere with endpoint routing. Remove it unless other middleware specifically requires it. | -| Using `InteractiveServer` when prerendering was disabled | If the original app used `render-mode="Server"` (not `"ServerPrerendered"`), use `new InteractiveServerRenderMode(prerender: false)` to preserve the same behavior. Using `InteractiveServer` enables prerendering which can cause unexpected issues with components that depend on JS interop during initialization. | -| Not migrating `AddServerSideBlazor` circuit options | If circuit options, hub options, or detailed error settings were configured, migrate them to `AddInteractiveServerComponents(options => { ... })`. Otherwise those settings are silently lost. | -| `UseAntiforgery()` placed before authentication middleware | The antiforgery middleware must be placed after `UseAuthentication` and `UseAuthorization`. Placing it before causes antiforgery validation to run before the user identity is established. | -| CSS isolation bundle link has wrong assembly name | If the `` tag referenced the old project name, update it to match the current assembly name. | - -## More Info - -- [Convert a Blazor Server app into a Blazor Web App](https://learn.microsoft.com/aspnet/core/migration/70-to-80#convert-a-blazor-server-app-into-a-blazor-web-app) — the official step-by-step migration guide -- [ASP.NET Core Blazor render modes](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes) — understanding InteractiveServer, InteractiveWebAssembly, and InteractiveAuto -- [Migrate CascadingAuthenticationState to services](https://learn.microsoft.com/aspnet/core/migration/70-to-80#migrate-the-cascadingauthenticationstate-component-to-cascading-authentication-state-services) — replacing the component wrapper with a service -- [MapStaticAssets](https://learn.microsoft.com/aspnet/core/fundamentals/static-files#mapstaticassets) — optimized static file serving in .NET 9+ -- [Migrate from ASP.NET Core 7.0 to 8.0](https://learn.microsoft.com/aspnet/core/migration/70-to-80) — general migration guide for all ASP.NET Core changes -- [Stream rendering with Blazor](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes#streaming-rendering) — `@attribute [StreamRendering]` for async data loading -- [Cascading values and render mode boundaries](https://learn.microsoft.com/aspnet/core/blazor/components/cascading-values-and-parameters#cascading-valuesparameters-and-render-mode-boundaries) — why cascading parameters do not cross render mode boundaries diff --git a/plugins/dotnet-aspnetcore/skills/design-collection-api/SKILL.md b/plugins/dotnet-aspnetcore/skills/design-collection-api/SKILL.md new file mode 100644 index 0000000000..a5507841aa --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/design-collection-api/SKILL.md @@ -0,0 +1,94 @@ +--- +name: design-collection-api +description: > + Build a complete, production-ready collection (list) API for a resource and reconcile it with the EF + Core data model, instead of a naive "return everything" list. + USE FOR: implementing, adding, building, or scaffolding the list + item (read) endpoints for a resource; + a "build the API for " or "list " task; deciding which cross-cutting capabilities a + collection needs (stable ordering, pagination, filtering, field selection, incremental change tracking, + optimistic concurrency, status codes) and what columns/indexes the data model must add (order key, + rowversion/timestamp watermark, soft-delete, concurrency token), evolving the entity or scoping a + capability out on purpose. + DO NOT USE FOR: the mechanics of a single endpoint (use author-controller-endpoints / + author-minimal-api-endpoints); nesting child resources or lifecycle transitions; the detailed + implementation of one capability - route to that capability's own skill. +license: MIT +--- + +# Design a Collection API + +A collection endpoint is rarely just `return db.Orders.ToList()`. Before writing the list + item endpoints for a resource, decide which cross-cutting capabilities it needs, because **each capability imposes a requirement on the data model** - an indexed order key, a change watermark, a soft-delete flag, a concurrency token. Deciding late means a schema migration and a breaking API change. So the design move is: pick the capabilities, **reconcile them with the entity** (add the columns/indexes, or consciously scope the capability out), then implement each via its dedicated skill. + +Always offer **filtering** and **incremental change tracking**, and reconcile the persistence columns they need even when the entity already has them - these are the axes most often dropped from a collection API. Run the checklist so nothing is omitted silently. + +## The checklist: decide each axis, then reconcile with the model + +For the resource's collection, decide each row - "yes and implement", or "no, out of scope for now" (a deliberate, recorded choice, not an oversight). The right column is the data-model obligation the "yes" answer creates. + +| Capability | Data-model requirement it imposes | Implement via skill | +|---|---|---| +| **Stable ordering** (deterministic list order) | An indexed sort column, plus a unique final tiebreaker (the id) | controller-data-access / minimal-api-data-access | +| **Pagination** (bounded page, forward nav) | Keyset over that same indexed order key (not OFFSET) | controller-data-access / minimal-api-data-access | +| **Filtering** (narrow the list) | Each filterable field indexed; opt-in allow-list | filter-and-select | +| **Field selection** (smaller responses) | none (projection only) - but keep the order key internally | filter-and-select | +| **Incremental change tracking** (delta sync) | A monotonic per-row watermark (**rowversion**, else an app-set timestamp) **+ soft-delete** (`IsDeleted`/`DeletedAt`) with tombstone retention | change-tracking-delta | +| **Optimistic concurrency** (safe updates) | A concurrency token (the same rowversion) surfaced as `ETag` | controller-concurrency / minimal-api-concurrency | +| **Correct status codes / result types** | none | author-controller-endpoints / author-minimal-api-endpoints | +| **Long-running writes** (202) | Persisted operation-status record | long-running-operations | +| **Rate limiting** | A partition key (tenant/subject) | rate-limiting | +| **Partial update** (PATCH) | Nullable-vs-required field distinction | patch-partial-updates | + +## Reconcile with the data model - the step that gets skipped + +Open the entity and, for every "yes" row, confirm the column exists or add it; do not assume the capability works against the entity as-is. + +- **Ordering / pagination** need an **indexed** column with a natural order (a `CreatedAt` or a sequence) and a unique tiebreaker. If the only candidate is a mutable `Name`, ordering by it alone is unstable - add the id tiebreaker and index `(sortKey, id)`. +- **Change tracking** needs a **rowversion** (`[Timestamp]` / `IsRowVersion()`), **soft-delete** columns, and a retention policy for tombstones. A hard-delete model *cannot* report deletions to a syncing client - decide this before clients depend on delta. +- **Concurrency** reuses that rowversion as the `ETag` validator - one column serves both delta and concurrency. +- If a needed column is missing and you will not add it now, **scope the capability out explicitly** and say why, rather than shipping a half-working version. + +A well-designed entity for a syncable, pageable, concurrency-checked collection therefore carries: the id, the business fields, `CreatedAt`/`LastModifiedAt`, `IsDeleted`/`DeletedAt`, and a `RowVersion` - and indexes the ordering/filtering columns. + +```csharp +public class Shipment // a resource designed for a full collection API +{ + public Guid Id { get; set; } + public required string TrackingCode { get; set; } + public ShipmentStatus Status { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + public bool IsDeleted { get; set; } // soft-delete: lets delta report removals + public DateTimeOffset? DeletedAt { get; set; } + [Timestamp] public byte[] RowVersion { get; set; } = []; // ETag validator AND delta watermark +} +// index (CreatedAt, Id) for stable keyset order; index Status if it is filterable. +``` + +## Combine the capabilities in the right order + +These features compose in one query pipeline; the order matters: + +**filter (allow-listed `Where`) → stable order (`OrderBy(key).ThenBy(id)`) → keyset page (`Where` against the cursor) → project (`Select`, keeping the order key) → shape the response.** + +Delta is the same pipeline with the ordering key set to the change marker (see change-tracking-delta). Field selection must never drop the order key from the query even if the client did not ask for it, or paging breaks. + +Always end the sort on a unique column so ties cannot skip or repeat rows across pages: + +```csharp +query.OrderBy(s => s.CreatedAt).ThenBy(s => s.Id) // total order: unique final key +``` + +## Start simple, then watch for amplification + +Ship the smallest surface that meets the need, but design the entity so the next capability is an additive migration, not a rewrite. Adding `RowVersion` + soft-delete columns *now* (even before delta ships) keeps change tracking and concurrency a pure addition later. Adding them *after* clients depend on hard deletes is a breaking change. + +## Verify + +- Every checklist axis is a conscious decision - implemented, or scoped out on purpose with a reason - not silently omitted. In particular, filtering and change tracking were each considered, not skipped by default. +- For each implemented axis, the entity actually carries the column/index it needs (order key + id tiebreaker; rowversion; soft-delete; concurrency token), or a migration adds it. +- The list returns a deterministic total order ending in a unique key, and is paged with a bounded size - never an unbounded `ToList()`. +- One `RowVersion` column serves both the `ETag` concurrency validator and the delta watermark; soft-delete is present if delta is (or in) scope. +- Each capability is implemented through its named skill rather than re-invented here; the response projects to a DTO instead of leaking the entity graph. + +❌ A list endpoint that returns the whole table in database order, with no filtering, no change feed, and an entity that has no rowversion or soft-delete - so adding those later breaks clients. +✅ A capability decision per axis, an entity reconciled to support the "yes" ones (indexed order key, rowversion, soft-delete), and each capability delegated to its skill. diff --git a/plugins/dotnet-aspnetcore/skills/dotnet-webapi/SKILL.md b/plugins/dotnet-aspnetcore/skills/dotnet-webapi/SKILL.md deleted file mode 100644 index c22ba5ca10..0000000000 --- a/plugins/dotnet-aspnetcore/skills/dotnet-webapi/SKILL.md +++ /dev/null @@ -1,506 +0,0 @@ ---- -name: dotnet-webapi -description: > - Guides creation and modification of ASP.NET Core Web API endpoints with - correct HTTP semantics, OpenAPI metadata, and error handling. - USE FOR: adding new API endpoints (controllers or minimal APIs), wiring up - OpenAPI/Swagger, creating .http test files, setting up global error handling - middleware. - DO NOT USE FOR: general C# coding style, EF Core data access or query - optimization (use optimizing-ef-core-queries), frontend/Blazor work, gRPC - services, or SignalR hubs. -license: MIT ---- - -# ASP.NET Core Web API - -Produce well-structured ASP.NET Core Web API endpoints with proper HTTP -semantics, OpenAPI documentation, and error handling. - -## When to Use - -Use this skill when working on ASP.NET Core HTTP APIs, including: - -- adding or modifying Web API endpoints implemented with controllers or minimal APIs; -- wiring up OpenAPI/Swagger metadata and endpoint documentation; -- defining request/response DTOs and consistent HTTP status code behavior; -- adding `.http` files or similar request-based API testing artifacts; -- configuring centralized API error handling middleware or exception mapping. - -## When Not to Use - -Do not use this skill for: - -- general C# coding style or non-API refactoring; -- EF Core data modeling or query optimization work; use `optimizing-ef-core-queries`; -- frontend, Razor, or Blazor UI changes; -- gRPC services; -- SignalR hubs or real-time messaging flows. - -## Inputs / prerequisites - -Before applying this skill, gather the project context needed to match the -existing API style and wiring: - -- the ASP.NET Core entry point, typically `Program.cs`; -- any existing controllers, especially classes inheriting `ControllerBase` or - using `[ApiController]`; -- any existing minimal API registrations such as `app.MapGet`, `app.MapPost`, - `app.MapPut`, or `app.MapDelete`; -- related DTO, model, validation, and error-handling types already used by the project; -- available build, run, and test commands so changes can be verified. - -If the user asks for a new endpoint, inspect the current project structure first -so the implementation follows the established conventions rather than mixing styles. -## Workflow - -### Step 1: Determine the API style - -Scan the project for existing endpoint patterns before writing any code. - -1. Search for classes inheriting `ControllerBase` or decorated with `[ApiController]`. -2. Search `Program.cs` or endpoint files for `app.MapGet`, `app.MapPost`, etc. -3. If the project already uses **controllers**, continue with controllers. -4. If the project already uses **minimal APIs**, continue with minimal APIs. -5. If neither exists (new project), **default to minimal APIs** unless the user - explicitly requests controllers. - -Do not mix styles in the same project. - -### Step 2: Define request and response types - -Create dedicated types for API input and output. Never expose EF Core entities -directly in request or response bodies. - -**Use `sealed record` for all DTOs.** Records enforce immutability, provide -value-based equality, and produce concise code. Seal them to prevent unintended -inheritance and enable JIT devirtualization (CA1852). - -**Naming convention:** - -| Role | Convention | Example | -|------|-----------|---------| -| Input (create) | `Create{Entity}Request` | `CreateProductRequest` | -| Input (update) | `Update{Entity}Request` | `UpdateProductRequest` | -| Output (single) | `{Entity}Response` | `ProductResponse` | -| Output (list) | `{Entity}ListResponse` | `ProductListResponse` | - -**XML doc comments on all DTOs:** Add `` XML doc comments to every -request and response type exposed in the API. These comments are automatically -included in the generated OpenAPI specification, producing richer documentation -without extra metadata calls. - -Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments - -**Date and time values — use `DateTimeOffset`:** When a DTO includes a date or -time property, always use `DateTimeOffset` instead of `DateTime`. -`DateTimeOffset` preserves the UTC offset, avoids ambiguous timezone -conversions, and serializes to ISO 8601 with offset information in JSON — which -is what API consumers expect. - -Reference: https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset -**JSON serialization options — preserve existing behavior by default:** For -existing APIs, do **not** introduce stricter serialization/deserialization settings -unless the project already uses them or the user explicitly asks for them. Settings -such as case-sensitive property matching and strict number handling can break -existing clients. For **new projects**, or when strict JSON handling is explicitly -requested, configure options like the following to minimize the potential of -processing malicious requests: - -```csharp -// Apply these settings only for new projects, when the existing project already -// uses them, or when the user explicitly requests stricter JSON behavior. -builder.Services.ConfigureHttpJsonOptions(options => -{ - // disallow reading numbers from JSON strings - options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict; - // match properties with exact casing during deserialization - options.SerializerOptions.PropertyNameCaseInsensitive = false; - // reject duplicate JSON property names during deserialization - options.SerializerOptions.AllowDuplicateProperties = false; - // omit null properties from serialized output - options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; -}); -``` -**Enum properties — serialize as strings by default:** Unless the user -explicitly requests integer serialization, all enum properties should be -serialized as strings. String-serialized enums are human-readable, less fragile -when values are reordered, and produce better OpenAPI documentation. See Step 4 -for the `JsonStringEnumConverter` configuration. - -**Response DTOs** — use positional sealed records for concise, immutable output: - -```csharp -/// Represents a product returned by the API. -public sealed record ProductResponse( - int Id, - string Name, - decimal Price, - Category Category, - bool IsAvailable, - DateTimeOffset CreatedAt); -``` - -**Request DTOs** — use sealed records with `init` properties so data annotations -work naturally: - -```csharp -/// Payload for creating a new product. -public sealed record CreateProductRequest -{ - [Required, MaxLength(200)] - public required string Name { get; init; } - - [Range(0.01, 999999.99)] - public required decimal Price { get; init; } - - public required Category Category { get; init; } -} -``` - -Follow the same pattern for `Update{Entity}Request` records, adding any -additional properties the update requires (e.g., `IsAvailable`). - -**Minimal API validation — register explicitly:** Data-annotation validation -(`[Required]`, `[MaxLength]`, `[Range]`, etc.) is automatic in MVC controllers, -but minimal APIs require explicit opt-in. For **.NET 10+** projects using minimal -APIs, add the validation services in `Program.cs`: - -```csharp -builder.Services.AddValidation(); -``` - -This wires up an endpoint filter that validates parameters decorated with data -annotations before the handler executes, returning a `400 Bad Request` with a -validation problem details response on failure. - -Reference: https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-10.0 - -**Do not** use mutable classes (`{ get; set; }`) for DTOs. Mutable DTOs allow -accidental modification after construction and lose the self-documenting -immutability that records provide. - -### Step 3: Implement the endpoints - -Whether using controllers or minimal APIs, follow these HTTP conventions -consistently. - -**Organizing minimal API endpoints:** For projects using minimal APIs, organize -endpoints by resource using static classes with a static `Map` method. -This pattern keeps endpoint definitions grouped by resource type, making the -code more maintainable and easier to navigate as the API grows. - -**Pattern structure:** - -1. Create one static class per resource (e.g., `ProductEndpoints`, `CategoryEndpoints`). -2. Define a static `Map(this WebApplication app)` extension method. -3. Inside the method, call `MapGet`, `MapPost`, `MapPut`, `MapDelete`, etc. for - that resource's endpoints. -4. In `Program.cs`, call each resource's `Map` method in order. - -**Minimal API return types — prefer `TypedResults`:** - -Always prefer `TypedResults` over the `Results` factory. `TypedResults` embeds -response type information in the method signature, giving the OpenAPI generator -richer metadata automatically. - -When a handler returns **multiple result types** (e.g., `Ok` or `NotFound`), -annotate the lambda with an explicit `Results` return type. This -lets you use `TypedResults` while still giving the compiler a common type: - -```csharp -async Task, NotFound>> (int id, ...) => ... -``` - -**Do not** use `TypedResults.Ok(x)` and `TypedResults.NotFound()` in a bare -ternary without an explicit return type annotation. `Ok` and `NotFound` are -different types with no common base the compiler can infer, which causes -`CS1593: Delegate 'RequestDelegate' does not take N arguments` because the -compiler falls back to matching `RequestDelegate(HttpContext)`. - -**Fallback — `Results` factory:** If a handler has many conditional branches -(7+ result types), you may use the `Results` factory (`Results.Ok()`, -`Results.NotFound()`) which returns `IResult`, sacrificing compile-time OpenAPI -inference for simpler signatures. - -**Status codes:** - -| Operation | Success | Common errors | -|-----------|---------|---------------| -| GET (single) | `200 OK` | `404 Not Found` | -| GET (list) | `200 OK` | — | -| POST (create) | `201 Created` with `Location` header | `400 Bad Request`, `409 Conflict` | -| PUT (full update) | `200 OK` | `400 Bad Request`, `404 Not Found` | -| PATCH (partial/action) | `200 OK` | `400 Bad Request`, `404 Not Found` | -| DELETE | `204 No Content` | `404 Not Found`, `409 Conflict` | - -**POST 201 responses:** Always return a `Location` header pointing to the -newly created resource. - -- Controllers: use `CreatedAtAction(nameof(GetById), new { id = ... }, response)` -- Minimal APIs: use `TypedResults.Created($"/api/products/{id}", response)` - -**CancellationToken:** Accept `CancellationToken` in every endpoint signature -and forward it through to all async calls (service methods, EF Core queries, -`HttpClient` calls). This allows the server to stop work when a client -disconnects. - -```csharp -// Controller example -[HttpGet("{id}")] -public async Task> GetById( - int id, CancellationToken cancellationToken) -{ - var product = await _productService.GetByIdAsync(id, cancellationToken); - return product is null ? NotFound() : Ok(product); -} - -// Minimal API example — TypedResults with explicit return type (recommended) -app.MapGet("/api/products/{id}", async Task, NotFound>> ( - int id, IProductService service, CancellationToken cancellationToken) => -{ - var product = await service.GetByIdAsync(id, cancellationToken); - return product is null ? TypedResults.NotFound() : TypedResults.Ok(product); -}); -``` - -### Step 4: Wire up OpenAPI - -Every ASP.NET Core Web API should have OpenAPI documentation. Check whether -the project already has OpenAPI configured before adding it. - -**For .NET 9+ projects**, use the built-in ASP.NET Core OpenAPI support -(`builder.Services.AddOpenApi()` + `app.MapOpenApi()` in development). -This is all that is needed — no additional packages required. - -**Do NOT add any `Swashbuckle.*` NuGet package** (`Swashbuckle.AspNetCore`, -`Swashbuckle.AspNetCore.SwaggerUI`, `Swashbuckle.AspNetCore.SwaggerGen`, -etc.) to .NET 9+ projects. Swashbuckle has known compatibility issues with -.NET 9+ and .NET 10 OpenAPI types. For projects targeting .NET 8 or earlier, -Swashbuckle is acceptable. If the project already has Swashbuckle installed, -keep it unless the user asks to remove it. - -Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview - -**OpenAPI metadata on endpoints:** Add descriptive metadata so the generated -documentation is useful, not just a list of routes. For minimal APIs, chain -the metadata methods: - -```csharp -app.MapGet("/api/products/{id}", handler) - .WithName("GetProductById") - .WithSummary("Get a product by ID") - .WithDescription("Returns the full product details including category.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); -``` - -**Enum serialization (strings by default):** Configure JSON serialization so -enums appear as readable strings in both API responses and OpenAPI schemas. -Always add this configuration unless the user explicitly requests integer -enum serialization. Configure it for both minimal APIs and controllers, as -they use different option types: - -```csharp -// Minimal APIs -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); - -// Controllers / MVC -builder.Services.AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); -``` - -### Step 5: Set up error handling - -Use a global exception handler so that individual endpoints do not need -try-catch blocks. Return RFC 7807 Problem Details for all error responses. - -**For .NET 8+ projects**, prefer the built-in exception handler middleware: - -```csharp -builder.Services.AddProblemDetails(); - -app.UseExceptionHandler(); -app.UseStatusCodePages(); -``` - -If the project needs custom exception-to-status-code mapping (e.g., a -`NotFoundException` should return 404), implement `IExceptionHandler`: - -```csharp -internal sealed class ApiExceptionHandler(ILogger logger) - : IExceptionHandler -{ - public async ValueTask TryHandleAsync( - HttpContext httpContext, - Exception exception, - CancellationToken cancellationToken) - { - var (statusCode, title) = exception switch - { - KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found"), - ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request"), - InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict"), - _ => (0, (string?)null) - }; - - if (statusCode == 0) - return false; // Let the default handler deal with it - - // Important: returning true below suppresses the exception diagnostics middleware - // for this exception, so ensure it is logged/telemetrized before returning. - logger.LogWarning(exception, "Handled API exception: {Title}", title); - - httpContext.Response.StatusCode = statusCode; - await httpContext.Response.WriteAsJsonAsync(new ProblemDetails - { - Status = statusCode, - Title = title, - // Do not use exception.Message here — it may leak sensitive internal details. - // Use a safe, user-facing message instead. - Detail = title, - Instance = httpContext.Request.Path - }, cancellationToken); - - return true; - } -} -``` - -Register it: - -```csharp -builder.Services.AddExceptionHandler(); -builder.Services.AddProblemDetails(); - -app.UseExceptionHandler(); -``` - -**File placement:** Always place exception handler classes in a `Middleware/` -folder to maintain consistent project organization. Do not place them at the -project root. - -### Step 6: Use a service layer - -Do not inject data stores directly into controllers or endpoint handlers. -Create a service interface and a sealed implementation class that owns the -data access logic and mapping between entities and request/response types. - -Always define an interface for every service — this enables unit testing with -mocks and follows the Dependency Inversion Principle: - -```csharp -// Services/IProductService.cs -public interface IProductService -{ - Task> GetAllAsync(CancellationToken ct); - Task GetByIdAsync(int id, CancellationToken ct); - Task CreateAsync(CreateProductRequest request, CancellationToken ct); -} - -// Services/ProductService.cs -public sealed class ProductService(...) : IProductService -{ - // Data access logic, entity-to-DTO mapping -} -``` - -Register with the interface, not the concrete type: - -```csharp -// In Program.cs -builder.Services.AddScoped(); -``` - -For EF Core data access patterns (migrations, Fluent API configuration, -`AsNoTracking`, seed data), see the `optimizing-ef-core-queries` skill. - -### Step 7: Create a .http test file - -After implementing endpoints, create a `.http` file in the project root that -demonstrates how to call every new endpoint. This serves as living -documentation and a quick manual test harness. - -```http -@baseUrl = http://localhost:5000 - -### Get all products -GET {{baseUrl}}/api/products - -### Get product by ID -GET {{baseUrl}}/api/products/1 - -### Create a product -POST {{baseUrl}}/api/products -Content-Type: application/json - -{ - "name": "Wireless Mouse", - "price": 29.99, - "category": "Electronics" -} - -### Delete a product -DELETE {{baseUrl}}/api/products/1 -``` - -Include at least one request per endpoint with realistic bodies. Show error -paths (e.g., non-existent IDs). Match the port to `launchSettings.json`. - -### Step 8: Build and verify - -1. Run `dotnet build` — confirm zero errors and zero warnings. -2. Start the app and verify the OpenAPI document loads (default: `/openapi/v1.json`). -3. Run the requests in the `.http` file and confirm correct status codes. - -## Validation - -- [ ] All endpoints return correct HTTP status codes per the table in Step 3 -- [ ] POST endpoints return `201 Created` with a `Location` header -- [ ] DELETE endpoints return `204 No Content` -- [ ] Every endpoint signature includes `CancellationToken` -- [ ] `CancellationToken` is forwarded to all downstream async calls -- [ ] OpenAPI document is generated and includes all new endpoints -- [ ] Endpoints have summary/description metadata for OpenAPI -- [ ] Enum values appear as strings in JSON responses and OpenAPI schemas (unless user explicitly requested integer serialization) -- [ ] Error responses use RFC 7807 Problem Details format -- [ ] Domain entities are not exposed directly in API request/response bodies -- [ ] All API-exposed DTOs have `` XML doc comments -- [ ] Date and time properties use `DateTimeOffset`, not `DateTime` -- [ ] A `.http` file exists with a request for every new endpoint -- [ ] `dotnet build` passes with zero errors and zero warnings -- [ ] All DTOs are `sealed record` types (not mutable classes) -- [ ] Minimal API handlers use `TypedResults` with explicit `Results` return types -- [ ] Every service has a corresponding interface registered in DI -- [ ] Exception handlers are placed in the `Middleware/` folder - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Exposing domain entities as API responses | Create separate `sealed record` request/response types. Entities leak navigation properties and internal fields. | -| Forgetting `CancellationToken` | Add to every endpoint and forward through the entire async call chain. | -| Returning `200 OK` from POST create | Return `201 Created` with a `Location` header. | -| Missing OpenAPI metadata | Chain `.WithName()`, `.WithSummary()`, `.WithDescription()`, `.Produces()` on every endpoint. | -| Injecting data stores directly into endpoints | Use a service layer with an interface for separation and testability. | -| Mixing controller and minimal API styles | Pick one per project and be consistent. | -| `TypedResults` in ternary without explicit return type | `Ok` and `NotFound` have no common base — annotate with `Task, NotFound>>` or fall back to `Results` factory. | -| Using mutable classes for DTOs | Use `sealed record` with positional syntax (responses) or `init` properties (requests). | -| Registering services without interfaces | Define `IService` and register with `AddScoped()`. | -| Adding any `Swashbuckle.*` package to new .NET 9+ projects | Use built-in `AddOpenApi()` + `MapOpenApi()`. Do not add `Swashbuckle.AspNetCore`, `Swashbuckle.AspNetCore.SwaggerUI`, or any other Swashbuckle package. | -| Missing XML doc comments on DTOs | Add `` XML doc comments to every request and response type. These flow into the generated OpenAPI spec automatically. | -| Using `DateTime` for date/time properties | Use `DateTimeOffset` instead — it preserves UTC offset, avoids timezone ambiguity, and serializes correctly in JSON. | -| Serializing enums as integers | Configure `JsonStringEnumConverter` so enums serialize as strings by default. Only use integer serialization if the user explicitly requests it. | - -## More Info - -- [ASP.NET Core Web API overview](https://learn.microsoft.com/en-us/aspnet/core/web-api/) — fundamental concepts for building Web APIs -- [OpenAPI in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview) — built-in OpenAPI support in .NET 9+ -- [OpenAPI from XML comments](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments) — how XML doc comments flow into the OpenAPI spec -- [Minimal APIs overview](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview) — routing, parameter binding, and response types -- [Handle errors in ASP.NET Core APIs](https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors) — Problem Details and exception handling -- [DateTimeOffset](https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset) — preferred type for date/time values in APIs diff --git a/plugins/dotnet-aspnetcore/skills/filter-and-select/SKILL.md b/plugins/dotnet-aspnetcore/skills/filter-and-select/SKILL.md new file mode 100644 index 0000000000..5a4ce2c1d4 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/filter-and-select/SKILL.md @@ -0,0 +1,155 @@ +--- +name: filter-and-select +description: > + Add safe, bounded filtering and field selection (sparse fieldsets) to a collection endpoint - the + small, opt-in subset of OData-style $filter/$select, not a general query language. + USE FOR: letting clients narrow a list by field values (status=active, price[gte]=100) and choose which + fields come back (select=id,name) so responses are smaller; translating those query parameters into a + safe EF Core Where + Select over an allow-list; rejecting unknown fields/operators; keeping the query + database-side and the ordering/paging stable when only some fields are selected. + DO NOT USE FOR: forward pagination and the Link header (use controller-data-access / minimal-api-data-access); + incremental change tracking / delta (use change-tracking-delta); a full query language with OR, nesting, + joins, or arbitrary expressions; request-body validation (use model-payloads / MVC model binding). +license: MIT +--- + +# Filtering and Field Selection + +Clients want two bounded query capabilities over a collection: **filter** it to the rows they care about, and **select** only the fields they need so responses are small. Both are easy to do dangerously - a dynamic-LINQ string, an `IQueryable` built from raw client input, or in-memory filtering after loading the whole table. The rule for both is the same: **opt-in allow-list**. Only fields you explicitly register are filterable, sortable, or selectable; anything else is a `400`. This bounds the SQL the client can cause, prevents column probing and injection, and keeps the surface a *feature*, not a query engine. + +Filtering itself is usually the easy half; the parts that get dropped are **field selection bounded by the allow-list and projected server-side**, **keeping paging stable when the client selects a subset**, and **guarding against expensive queries**. Get those right. + +## Declare the allow-list + +One registry per resource decides what is filterable/selectable and, per field, its type and permitted operators. Everything downstream reads from it; nothing touches a field that is not here. + +```csharp +enum Op { Eq, Ne, Gt, Ge, Lt, Le, Contains, StartsWith } + +sealed record FilterField( + string Name, + Op[] Ops, + Func>>> Build); // coercion returns 400, never throws + +static class ProductQuery +{ + // The only field names the client can use; the database projection is bounded to this set. + public static readonly string[] Selectable = ["id", "name", "category", "price", "createdAt"]; + + public static readonly Dictionary Filterable = new(StringComparer.OrdinalIgnoreCase) + { + ["status"] = new("status", [Op.Eq, Op.Ne], EnumField(p => p.Status)), + ["price"] = new("price", [Op.Eq, Op.Ne, Op.Gt, Op.Ge, Op.Lt, Op.Le], NumberField(p => p.Price)), + ["category"] = new("category", [Op.Eq, Op.StartsWith], TextField(p => p.Category)), + // Contains is deliberately excluded from category - see the amplification guardrails below. + }; + + // Coercion returns a Result: an operand that will not parse to the field's type is a 400, never an exception. + static Func>>> NumberField( + Expression> selector) + { + return (op, raw) => + { + if (!decimal.TryParse(raw, out var value)) + { + return Result.BadRequest($"'{raw}' is not a valid number."); + } + return Result.Ok(Compare(selector, value, op)); // Compare builds the typed >, >=, == expression + }; + } + // EnumField and TextField follow the same shape: TryParse the operand, then return BadRequest or the built expression. +} +``` + +Each entry builds a **strongly-typed** `Expression>` - EF Core translates it to SQL. There is no `System.Linq.Dynamic`, no string-concatenated predicate, and no `IQueryable` assembled from the raw field name. The coercion (`decimal.TryParse` and its enum/string peers) is the **type check**: a value that will not parse to the field's type comes back as a `400` before any query runs, never a runtime exception mid-query. + +## Parse the query string against the allow-list + +Read `field=value` as equality and `field[op]=value` (Stripe-style bracket operators) as a comparison. Reject unknown fields and unsupported operators before touching the database. + +```csharp +static Result>>> ParseFilters(IQueryCollection q) +{ + var predicates = new List>>(); + foreach (var (rawKey, values) in q) + { + if (rawKey is "select" or "top" or "cursor") + { + continue; // reserved query keys, handled elsewhere + } + + var (name, op) = SplitBracket(rawKey); // "price[gte]" -> ("price","gte") + if (!ProductQuery.Filterable.TryGetValue(name, out var field)) + { + return Result.BadRequest($"Unknown filter field '{name}'."); + } + if (!TryParseOp(op, out var parsedOp) || !field.Ops.Contains(parsedOp)) + { + return Result.BadRequest($"Operator '{op}' is not allowed on '{name}'."); + } + if (predicates.Count >= MaxPredicates) // amplification cap + { + return Result.BadRequest($"At most {MaxPredicates} filters are allowed."); + } + + var built = field.Build(parsedOp, values[^1]!); // coercion returns a Result, never throws + if (!built.Ok) + { + return Result.BadRequest(built.Error); + } + predicates.Add(built.Value); + } + return Result.Ok(predicates); +} +``` + +## Apply filter, then order, then page, then project + +The pipeline order is fixed, and **field selection must not remove the ordering key from the query**: + +```csharp +IQueryable query = db.Products.AsNoTracking(); +foreach (var predicate in filters) +{ + query = query.Where(predicate); // AND-combined, all DB-side +} + +query = query.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id); // stable total order +query = ApplyKeyset(query, cursor).Take(pageSize); // bounded page (see data-access skill) + +// Selection: project SERVER-SIDE to the selectable columns, ALWAYS including id + the sort key, +// so the keyset cursor can still be built even if the client did not select createdAt/id. +var rows = await query + .Select(p => new ProductRow(p.Id, p.Name, p.Category, p.Price, p.CreatedAt)) + .ToListAsync(ct); + +// Shape to the client's requested subset for the wire (id/createdAt retained internally for the cursor). +var selected = ParseSelect(http.Request.Query["select"]); // validated allow-list subset +var body = rows.Select(r => Shape(r, selected)).ToList(); +``` + +`ParseSelect` splits the comma list and rejects any name not in `Selectable`, compared case-insensitively (`OrdinalIgnoreCase`), with a `400`. The query projects the bounded **selectable** column set server-side - never the whole entity graph - and `Shape` trims each row to the requested subset for the wire; `Id`/`CreatedAt` stay in the query regardless of `select`, so the next-page cursor is always computable. To push the exact per-request subset down to SQL so unselected columns are not even read, build the `Select` as a member-init expression from the requested fields plus the forced key columns; the fixed projection here is the simpler default and reads only the allow-listed columns either way. + +## Guard against amplification + +An allow-list bounds *which* columns are touched; these bound *how hard* a request can hit the database: + +- **Cap the number of predicates** (`MaxPredicates`, e.g. 8) and the **page size** - a filtered result is still a bounded page, never an unbounded `ToList()`. +- **Gate expensive operators.** `StartsWith` is sargable (uses an index on the column); `Contains` compiles to a leading-wildcard `LIKE '%x%'` that **cannot use an index** and scans the table - only allow `Contains` on a field you are willing to full-scan, and prefer `StartsWith`. The registry above allows `StartsWith` but not `Contains` on `category` for exactly this reason. +- **Index every filterable/sortable field**, or a `price[gte]` filter degrades to a scan under load. +- **Coerce and validate operands** to the field's CLR type up front, so a bad value is a `400`, not a query-time failure or an accidental full scan. + +## Standards basis + +This is the constrained subset of common conventions: bracket operators on a field (`price[gte]=`, as Stripe uses), a flat `select=a,b` sparse fieldset (OData `$select` without the `$`, Google's `fields`). It is deliberately **not** a query language: AND-only (no `OR`), scalar fields only (no nesting, joins, or related-entity paths), one value per operator. Keep it here; graduating to arbitrary expressions reintroduces the injection and cost problems the allow-list exists to prevent. + +## Verify + +- Filtering and selection are driven by an **explicit allow-list**; an unknown field or a disallowed operator returns `400`, never a silent ignore or a blind pass-through to the query. +- Predicates are **strongly-typed `Expression`s translated to SQL** (EF `Where`), with operands coerced to the field's type - no dynamic-LINQ, no string-built queries, no in-memory filtering of the full table. +- `select` is validated against the allow-list (unknown fields rejected); the query projects the bounded selectable column set server-side (not the whole entity), and each row is trimmed to the requested subset for the response. +- The **ordering/keyset key stays in the query even when not selected**, so paging remains stable and the cursor is still computable. +- Amplification is bounded: predicate count and page size are capped, `Contains`/leading-wildcard is gated in favor of `StartsWith`, and filterable columns are indexed. + +❌ `db.Products.Where("Category == \"" + input + "\"")` (dynamic string), or loading all rows then filtering/selecting in memory, or dropping `id`/`createdAt` from the projection so the next-page cursor breaks. +✅ An allow-list of typed, operator-scoped predicates translated to SQL, a validated server-side `select` projection that retains the sort key, and capped predicate/result sizes. diff --git a/plugins/dotnet-aspnetcore/skills/long-running-operations/SKILL.md b/plugins/dotnet-aspnetcore/skills/long-running-operations/SKILL.md new file mode 100644 index 0000000000..48c81deec4 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/long-running-operations/SKILL.md @@ -0,0 +1,86 @@ +--- +name: long-running-operations +description: > + Model slow API work as an asynchronous operation: accept the request, return 202 with a pollable + operation-status resource, and let the client poll until a terminal state, instead of blocking the + request until the work finishes. + USE FOR: an endpoint whose work takes too long to finish within a request (provisioning, generating a + report, a large import); returning 202 Accepted with a Location to an operation resource; modeling an + operation with its own id and a status lifecycle (running, succeeded, failed); a status endpoint the + client polls; Retry-After hints; pointing at the finished resource on success. + DO NOT USE FOR: fast synchronous endpoints (return the result directly); background jobs with no client + waiting on them; streaming responses; endpoint result types in general (use author-controller-endpoints). +license: MIT +--- + +# Long-Running Operations + +When an operation cannot finish quickly within the request, do not hold the connection until it is done. Accept the request, start the work, and return **202 Accepted** with a pointer to a separate **operation** resource the client polls until the operation reaches a terminal state. The slow work becomes a first-class resource with its own identity and lifecycle, not a blocked HTTP call. + +## Accept the request and return 202 + +```csharp +[HttpPost] +[ProducesResponseType(StatusCodes.Status202Accepted)] +public async Task StartExport(CreateExportRequest request, CancellationToken ct) +{ + var operation = new Operation + { + Id = Guid.NewGuid(), + Status = OperationStatus.Running, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Operations.Add(operation); + await db.SaveChangesAsync(ct); + + await queue.EnqueueAsync(operation.Id, request, ct); // hand the work to a background worker + + Response.Headers.Location = Url.Link(nameof(GetOperation), new { id = operation.Id }); + Response.Headers.RetryAfter = "5"; // suggested poll interval, in seconds + return Accepted(); +} +``` + +The work runs outside the request (for example a `BackgroundService` draining a queue), which updates the operation's status to `Succeeded` or `Failed` when it finishes. + +## Expose a distinct operation-status resource + +The client polls this resource, which is separate from the resource being produced. + +```csharp +[HttpGet("/operations/{id:guid}", Name = nameof(GetOperation))] +[ProducesResponseType(StatusCodes.Status200OK)] +[ProducesResponseType(StatusCodes.Status404NotFound)] +public async Task> GetOperation(Guid id, CancellationToken ct) +{ + var operation = await db.Operations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct); + if (operation is null) + { + return NotFound(); + } + + if (operation.Status == OperationStatus.Succeeded) + { + Response.Headers.Location = operation.ResultUrl; // where the finished resource lives + } + + return Ok(operation.ToDto()); // carries Status: Running / Succeeded / Failed (+ error on failure) +} +``` + +## Model the states + +An operation has its own id and a status with a clear **terminal** set: `Running` while in progress, then `Succeeded` or `Failed` (with an error). The client polls until the status is terminal and then stops; on success it follows the pointer to the finished resource. Keep the operation record after completion so a late poll still returns the outcome. + +## Verify + +- A slow create returns **202 Accepted** promptly with a `Location` to an operation resource, rather than doing all the work in the request and returning 200/201 at the end. +- A distinct operation-status endpoint exists and is what the client polls, separate from the resource being produced. +- The status distinguishes `Running` from the terminal `Succeeded`/`Failed`, so the client knows when to stop polling. +- On success the operation points at the finished resource; a `Retry-After` hints the poll interval. + +❌ Doing the whole slow job inside the request handler and returning 200 once it eventually finishes. +✅ Return 202, run the work in the background, and expose an operation resource to poll. + +❌ Reporting progress only through fields on the target resource, with no operation to poll. +✅ A dedicated operation resource with its own id and a running/succeeded/failed lifecycle. diff --git a/plugins/dotnet-aspnetcore/skills/minimal-api-concurrency/SKILL.md b/plugins/dotnet-aspnetcore/skills/minimal-api-concurrency/SKILL.md new file mode 100644 index 0000000000..2815840d88 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/minimal-api-concurrency/SKILL.md @@ -0,0 +1,177 @@ +--- +name: minimal-api-concurrency +description: > + Add optimistic concurrency and HTTP conditional requests to ASP.NET Core minimal API endpoints, using + both validators a resource can offer. + USE FOR: protecting updates against concurrent edits and lost updates; exposing an ETag and a + Last-Modified header from a resource; honoring If-Match and If-None-Match (the content/ETag validator) + and If-Modified-Since and If-Unmodified-Since (the time/Last-Modified validator); returning 304, 412, + or 428; handling DbUpdateConcurrencyException; conditional GET and conditional update handlers. + DO NOT USE FOR: basic endpoint result types and status codes (use author-minimal-api-endpoints); + controller-based APIs (use controller-concurrency); general EF Core querying or pagination (use the + data-access skills); service-layer structure and the Result pattern (use structure-api-business-logic). +license: MIT +--- + +# Minimal API Concurrency and Conditional Requests + +A resource can carry two independent validators, and a complete implementation offers both: an **ETag** for content and a **Last-Modified** for time. Emit both on reads and honor both on writes. + +- **ETag** comes from the concurrency token (a database rowversion mapped with `[Timestamp]`, or an app-managed version). It changes only when the resource's content changes. It drives `If-Match` (write precondition: stale write to 412) and `If-None-Match` (read: unchanged to 304; `*` means create-only). +- **Last-Modified** comes from the resource's last-modified timestamp. It is the time-based validator. It drives `If-Modified-Since` (read: not changed since to 304) and `If-Unmodified-Since` (write precondition: changed since to 412). + +A resource that has a last-modified timestamp must offer `Last-Modified`, not only an `ETag`. The two answer different questions ("is it the exact same version" versus "has it changed since this time") and clients rely on each. + +## Give the entity a concurrency token + +On a relational database use a rowversion; on a provider without one (for example the in-memory provider) use an application-managed token that you change on every update. + +```csharp +public class Resource +{ + // ... + public DateTimeOffset LastModifiedAt { get; set; } + [Timestamp] public byte[] RowVersion { get; set; } = []; // relational rowversion + // Provider without rowversion: [ConcurrencyCheck] public Guid Version { get; set; } (assign Guid.NewGuid() on each update) +} +``` + +## One helper that reads both validators off the entity + +```csharp +public static class ConditionalRequest +{ + public static string ETag(byte[] rowVersion) + { + return $"\"{Convert.ToBase64String(rowVersion)}\""; + } + + public static string LastModified(DateTimeOffset lastModifiedAt) + { + return lastModifiedAt.ToString("R"); + } + + public static void WriteValidators(HttpResponse response, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + response.Headers.ETag = ETag(rowVersion); + response.Headers.LastModified = LastModified(lastModifiedAt); + } + + public static bool IsNotModified(HttpRequest request, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + var ifNoneMatch = request.Headers.IfNoneMatch.ToString(); + if (!string.IsNullOrEmpty(ifNoneMatch)) + { + return string.Equals(ifNoneMatch, ETag(rowVersion), StringComparison.Ordinal); + } + + if (DateTimeOffset.TryParse(request.Headers.IfModifiedSince, out var since)) + { + // HTTP-date has one-second resolution. + return lastModifiedAt <= since.AddSeconds(1); + } + + return false; + } + + public static bool PreconditionFailed(HttpRequest request, byte[] rowVersion, DateTimeOffset lastModifiedAt) + { + var ifMatch = request.Headers.IfMatch.ToString(); + if (!string.IsNullOrEmpty(ifMatch) && !string.Equals(ifMatch, "*", StringComparison.Ordinal)) + { + return !string.Equals(ifMatch, ETag(rowVersion), StringComparison.Ordinal); + } + + if (DateTimeOffset.TryParse(request.Headers.IfUnmodifiedSince, out var limit)) + { + return lastModifiedAt > limit.AddSeconds(1); + } + + return false; + } +} +``` + +Compare ETag and header tokens with `StringComparison.Ordinal`, never the culture-sensitive default. + +## Conditional GET + +```csharp +resources.MapGet("/{id:int}", async Task, NotFound, StatusCodeHttpResult>> ( + int id, HttpContext http, AppDbContext db) => +{ + var resource = await db.Resources.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id); + if (resource is null) + { + return TypedResults.NotFound(); + } + + if (ConditionalRequest.IsNotModified(http.Request, resource.RowVersion, resource.LastModifiedAt)) + { + return TypedResults.StatusCode(StatusCodes.Status304NotModified); + } + + ConditionalRequest.WriteValidators(http.Response, resource.RowVersion, resource.LastModifiedAt); + return TypedResults.Ok(resource.ToDto()); +}); +``` + +## Conditional update + +```csharp +resources.MapPut("/{id:int}", async Task, NotFound, StatusCodeHttpResult>> ( + int id, UpdateResourceRequest req, HttpContext http, AppDbContext db) => +{ + var resource = await db.Resources.FindAsync(id); + if (resource is null) + { + return TypedResults.NotFound(); + } + + // An endpoint that requires a precondition refuses a blind write. + if (string.IsNullOrEmpty(http.Request.Headers.IfMatch) && string.IsNullOrEmpty(http.Request.Headers.IfUnmodifiedSince)) + { + return TypedResults.StatusCode(StatusCodes.Status428PreconditionRequired); + } + + if (ConditionalRequest.PreconditionFailed(http.Request, resource.RowVersion, resource.LastModifiedAt)) + { + return TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed); + } + + Apply(resource, req); + resource.LastModifiedAt = DateTimeOffset.UtcNow; // bump the time validator on every content change + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + // Two writers raced past the header check; the rowversion in the UPDATE caught it. + return TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed); + } + + ConditionalRequest.WriteValidators(http.Response, resource.RowVersion, resource.LastModifiedAt); + return TypedResults.Ok(resource.ToDto()); +}); +``` + +The header check rejects the obvious stale write; the tracked entity's rowversion still backstops the race at `SaveChanges`, raising `DbUpdateConcurrencyException`. Catch it and map it to 412 (or 409 for an endpoint with no conditional header); never let it surface as a 500. + +## Verify + +- A read emits **both** `ETag` and `Last-Modified`; `If-None-Match` returns 304 and `If-Modified-Since` returns 304, each independently. +- A write honors **both** `If-Match` (412 when stale) and `If-Unmodified-Since` (412 when changed since); a write that requires a precondition but receives none returns 428 Precondition Required. +- A 304 response has no body. +- The `ETag` is quoted and reflects the rowversion (content); `Last-Modified` reflects the timestamp; ETag comparisons are ordinal. +- A `SaveChanges` race is caught as `DbUpdateConcurrencyException` and returned as 412 or 409. + +❌ Offering only an `ETag` when the resource also carries a last-modified timestamp. +✅ Emit and honor both the `ETag` and `Last-Modified` validators. + +❌ Comparing ETags or header values with the culture-sensitive default `==`. +✅ `string.Equals(..., StringComparison.Ordinal)`. + +❌ Checking the precondition header but not handling the `SaveChanges` race. +✅ Catch `DbUpdateConcurrencyException` as well. diff --git a/plugins/dotnet-aspnetcore/skills/minimal-api-data-access/SKILL.md b/plugins/dotnet-aspnetcore/skills/minimal-api-data-access/SKILL.md new file mode 100644 index 0000000000..cf2d704a91 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/minimal-api-data-access/SKILL.md @@ -0,0 +1,86 @@ +--- +name: minimal-api-data-access +description: > + Read collections efficiently from minimal API handlers: keyset pagination over a deterministic total + order, the next page delivered as an RFC 8288 Link header, reading without tracking, and projecting in + the query. + USE FOR: implementing a minimal API route handler that lists or queries a collection backed by EF Core; + paging a large or frequently-changing collection correctly; ordering by a total order with a unique + tiebreaker; keyset/cursor paging instead of Skip/OFFSET; delivering the next-page link via the Link + header; reading with AsNoTracking and projecting to a DTO; computing counts in the query. + DO NOT USE FOR: controller actions (use controller-data-access); endpoint result types and status codes + (use author-minimal-api-endpoints); DTO shape design (use the model-payloads skills); optimistic + concurrency or ETags (use minimal-api-concurrency); incremental change tracking / delta (use the + change-tracking skill). +license: MIT +--- + +# Minimal API Data Access + +A collection endpoint must return a bounded page, ordered by a deterministic total order, and hand the client the next page rather than make the client compute offsets. Returning the whole table grows unbounded, and offset paging silently breaks when the collection changes between requests. + +## Page with a total order, a keyset cursor, and a Link header + +- **Total order:** order by a key whose final component is unique (the primary key). If the sort column has ties, the database may break them differently between queries, so `Skip`/`Take` can repeat or skip rows across pages. End every `OrderBy` with `ThenBy(x => x.Id)`. +- **Keyset, not offset:** seek past the last item of the previous page with a `WHERE (sortKey, id) > (lastSortKey, lastId)` comparison instead of `Skip`/`OFFSET`. A value boundary does not shift when earlier rows are inserted or deleted, so pages do not duplicate or skip; it also seeks on the index instead of scanning `offset + limit` rows. +- **Immutable seek key:** sort and seek on an immutable key (the id, or an insertion-ordered column) so a row is not re-emitted if a mutable sort field changes after it was returned. +- **Link header (RFC 8288):** return the collection itself as the body and put the next page in the `Link` header with `rel="next"`; the client follows it opaquely. Omit the header on the last page. + +```csharp +queues.MapGet("/", async Task>> ( + string namespaceName, HttpContext http, AppDbContext db, CancellationToken ct, + string? cursor = null, int limit = 20) => +{ + limit = Math.Clamp(limit, 1, 100); // bound the page size + + var source = db.Queues + .AsNoTracking() + .Where(q => q.Namespace.Name == namespaceName && !q.IsDeleted); + + if (Cursor.TryDecode(cursor, out var lastName, out var lastId)) + { + // Seek past the previous page: (Name, Id) > (lastName, lastId). + source = source.Where(q => + string.Compare(q.Name, lastName) > 0 || (q.Name == lastName && q.Id > lastId)); + } + + var rows = await source + .OrderBy(q => q.Name).ThenBy(q => q.Id) // total order ending in the unique key + .Take(limit + 1) // fetch one extra to detect a next page + .Select(q => new QueueDto(q.Id, q.Name, q.Status)) + .ToListAsync(ct); + + var hasNext = rows.Count > limit; + var items = hasNext ? rows.Take(limit).ToList() : rows; + + if (hasNext) + { + var last = items[^1]; + var next = Cursor.Encode(last.Name, last.Id); + http.Response.Headers["Link"] = $"; rel=\"next\""; + } + + return TypedResults.Ok>(items); // the body is the bare collection +}); +``` + +`Cursor` is a small helper that encodes the last row's `(Name, Id)` into an opaque token (for example base64url) and decodes it back; the client treats the token as a black box. + +## Read without tracking, project, and count in the query + +- Read with `AsNoTracking` and project to the DTO inside the query, so only the needed columns are fetched. +- When a scalar summary is needed (for example a count of children), compute it in the query with `CountAsync` or a projection; do not load a collection into memory just to count it. +- If clients need a total count, run a separate `CountAsync` and return it in an `X-Total-Count` header rather than forcing it into the body. + +## Verify + +- Results are ordered by a total order whose final key is unique (`ThenBy` the id), so paging cannot repeat or skip rows on ties. +- Paging is keyset (`(sortKey, id) > (lastSortKey, lastId)`) applied in the query, not `Skip`/`OFFSET`, and the page size is bounded. +- The next page is delivered as an RFC 8288 `Link` header with `rel="next"`, and the body is the collection itself. +- Reads use `AsNoTracking` and project to the response shape in the query; counts are computed in the query. + +❌ `OrderBy(q => q.Name)` alone, then `Skip`/`Take` — a non-unique sort plus offset repeats or skips rows when the data changes. +✅ `OrderBy(q => q.Name).ThenBy(q => q.Id)` with a keyset seek and a `Link` header. + +❌ Returning a page-number/offset envelope the client must assemble into the next request. +✅ Hand the client an opaque `next` link in the `Link` header. diff --git a/plugins/dotnet-aspnetcore/skills/minimal-api-endpoint-filters/SKILL.md b/plugins/dotnet-aspnetcore/skills/minimal-api-endpoint-filters/SKILL.md new file mode 100644 index 0000000000..075676d775 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/minimal-api-endpoint-filters/SKILL.md @@ -0,0 +1,120 @@ +--- +name: minimal-api-endpoint-filters +description: > + Use ASP.NET Core minimal API endpoint filters for cross-cutting concerns that need the bound (already + validated) request arguments or the handler's result, and route everything else to the right built-in + mechanism instead. + USE FOR: rewriting or normalizing a bound argument before the handler runs; transforming or shaping the + result uniformly across a route group (response envelopes, field selection); short-circuiting based on + the bound arguments; choosing group-level versus per-endpoint filters; filter ordering. + DO NOT USE FOR: input/model validation (use built-in minimal API validation, AddValidation with + DataAnnotations / IValidatableObject); concerns that need neither the bound arguments nor the typed + result, such as logging, CORS, or header forwarding (use middleware); authentication/authorization (use + authorize-api-endpoints); turning exceptions into ProblemDetails (use IExceptionHandler); caching a + response by key (use output caching); the MVC/controller filter pipeline. +license: MIT +--- + +# Minimal API Endpoint Filters + +An endpoint filter runs after model binding and validation and wraps the handler, so it can read and **rewrite the bound, already-validated arguments** before the handler runs, and inspect and **change the result** the handler returned. That access is the whole reason to use one. Reach for a filter only when both are true: + +1. **The concern needs the bound arguments or the result.** A concern that touches neither (logging, CORS, a raw header) belongs in middleware, not a filter. +2. **No built-in mechanism already covers the concern with the same access.** Input validation, for example, *does* need the bound arguments, yet built-in minimal API validation (`AddValidation()` with DataAnnotations / `IValidatableObject`) already covers it thoroughly, so a validation filter only duplicates it. + +When a concern clears both tests, the filter is doing something nothing else in the pipeline can. The two examples below qualify because each needs argument or result access that no built-in mechanism provides; see [When not to use a filter](#when-not-to-use-a-filter) for the common cases that fail one of the tests. + +## Rewrite a bound argument before the handler + +Because the filter runs after binding and validation, it receives the strongly typed, valid arguments and can replace one before the handler sees it. This is the place to canonicalize an input across every endpoint in a group. + +```csharp +var articles = app.MapGroup("/articles"); + +articles.AddEndpointFilter(async (context, next) => +{ + if (context.GetArgument(0) is { } request) + { + // Canonicalize the bound argument; the handler and everything after it see the normalized value. + context.Arguments[0] = request with { Slug = request.Slug.Trim().ToLowerInvariant() }; + } + + return await next(context); +}); +``` + +`context.Arguments` is the mutable list of bound arguments; `context.GetArgument(index)` reads one by position. Returning a result instead of calling `next(context)` short-circuits the request. + +## Change the result the handler returned + +A filter can call `next`, then act on what the handler produced. Here a field-selection filter uses the request's `fields` value together with the returned object to send back only the requested properties, so every endpoint in the group supports partial responses without each handler knowing about it. + +```csharp +public sealed class FieldSelectionFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + var fields = context.HttpContext.Request.Query["fields"].ToString(); + if (string.IsNullOrEmpty(fields) || result is IResult) + { + return result; // nothing requested, or a status/problem result: leave it untouched + } + + var selected = fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (JsonSerializer.SerializeToNode(result) is not JsonObject shaped) + { + return result; + } + + foreach (var name in shaped.Select(property => property.Key).ToList()) + { + if (!selected.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + shaped.Remove(name); + } + } + + return shaped; + } +} + +articles.AddEndpointFilter(); +``` + +The handler returns its domain value; the filter reshapes it. Middleware cannot do this, because it sees the serialized byte stream rather than the typed result the handler returned. + +## When not to use a filter + +Each concern below fails one of the two tests: it either does not need the bound arguments or the result (so it belongs in middleware), or it needs them but a built-in mechanism already covers it with the same access (so a filter would only duplicate that mechanism). Validation is the one to watch, because it *looks* like a filter job (it needs the bound arguments) yet built-in validation already has the same access and does it thoroughly. + +| Concern | Fails test | Use instead of a filter | +| --- | --- | --- | +| Input / model validation (required fields, ranges, formats) | 2: needs the args, but already covered with equal access | Built-in minimal API validation: `AddValidation()` with DataAnnotations or `IValidatableObject` returns a 400 `ValidationProblem` automatically | +| Logging, CORS, header forwarding, response compression, anything on the raw request/response | 1: needs neither the bound arguments nor the typed result | Middleware | +| Requiring a signed-in user, roles, policies, resource rules | 2: already covered by the authorization system | `RequireAuthorization` / policies (see the authorization skill) | +| Turning an exception into a consistent error response | 2: already covered | `IExceptionHandler` + `AddProblemDetails` | +| Returning a stored response for a repeated request | 2: already covered | Output caching (`AddOutputCache`) | + +## Order is intentional + +Filters run in the order they are added, outermost first: the first `AddEndpointFilter` wraps the second, which wraps the handler, and they unwind in reverse on the way out. Put a filter that rewrites arguments before one that depends on the rewritten value; put result-shaping filters where they see the final result. + +## Group versus endpoint + +Attach a filter to a `MapGroup` to cover every endpoint in the group; attach it to a single `Map` call to scope it to one endpoint. A group filter is what keeps a cross-cutting concern in one place instead of copied into each handler. + +## Verify + +- The filter genuinely needs the bound arguments or the result; a concern that needs neither uses middleware, and input validation uses built-in minimal API validation rather than a filter. +- An argument-rewriting filter mutates `context.Arguments` (or returns a short-circuit result) before calling `next`; the handler observes the rewritten value. +- A result-shaping filter calls `next`, then inspects or replaces the returned value, and leaves problem/status results untouched. +- Cross-cutting filters are attached at the group level, not duplicated inside each handler. +- Filter order is deliberate when one filter depends on another's effect. + +❌ A hand-written filter that checks required fields and returns 400. +✅ Built-in validation (`AddValidation()` + DataAnnotations / `IValidatableObject`). + +❌ A filter that only logs the request or rewrites a raw header and never touches the bound arguments or the result. +✅ Middleware for concerns that need neither the bound arguments nor the typed result. diff --git a/plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md b/plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md deleted file mode 100644 index 6ec5fc805d..0000000000 --- a/plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md +++ /dev/null @@ -1,235 +0,0 @@ ---- -name: minimal-api-file-upload -description: File upload endpoints in ASP.NET minimal APIs (.NET 8+) -license: MIT ---- - -# Implementing File Uploads in ASP.NET Core Minimal APIs - -## When to Use -- File upload endpoints in ASP.NET Core minimal APIs (.NET 8+) -- Handling IFormFile or IFormFileCollection parameters -- When you need size limits, content type validation, or streaming large files - -## When Not to Use -- MVC controllers → `[FromForm] IFormFile` works directly with attributes -- Simple JSON body → no file upload needed -- Very large files (> 1GB) → use streaming with `MultipartReader` instead - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| File parameter(s) | Yes | IFormFile or IFormFileCollection | -| Size limits | Yes | Max file/request size | -| Allowed types | No | Content type or extension restrictions | - -## Workflow - -### Step 1: CRITICAL — Understand IFormFile Binding in Minimal APIs - -```csharp -// In .NET 8+ minimal APIs, IFormFile binds automatically from multipart/form-data -// when it is the only complex parameter. -app.MapPost("/upload", (IFormFile file) => ...); - -// CRITICAL: When you mix files with other form fields, use [FromForm] on all -// form-bound parameters (or group them into a single [FromForm] DTO). -app.MapPost("/upload-with-metadata", - ([FromForm] IFormFile file, [FromForm] string description) => -{ - return Results.Ok(new { file.FileName, Description = description }); -}); - -// Multiple files: IFormFileCollection also binds automatically from multipart/form-data. -// You only need [FromForm] if you mix it with other form fields, as shown above. -app.MapPost("/upload-multiple", (IFormFileCollection files) => -{ - return Results.Ok(files.Select(f => new { f.FileName, f.Length })); -}); -``` - -### Step 2: CRITICAL — File Size Limits Are Separate from Request Size Limits - -```csharp -// CRITICAL: There are TWO different size limits and you need to configure BOTH - -// 1. Request body size limit (Kestrel level) — default is 30MB -builder.WebHost.ConfigureKestrel(options => -{ - options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB -}); - -// 2. Form options — multipart body length limit — default is 128MB -builder.Services.Configure(options => -{ - options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB - options.ValueLengthLimit = 1024 * 1024; // 1 MB for form values - options.MultipartHeadersLengthLimit = 16384; // 16 KB for section headers -}); - -// COMMON MISTAKE: Only increasing Kestrel MaxRequestBodySize -// upload still fails because FormOptions.MultipartBodyLengthLimit is exceeded - -// COMMON MISTAKE: Only increasing FormOptions -// upload fails with "Request body too large" from Kestrel before reaching form parsing - -// CRITICAL: Per-endpoint override with RequestSizeLimit attribute -app.MapPost("/upload-large", [RequestSizeLimit(200_000_000)] (IFormFile file) => -{ - return Results.Ok(new { file.FileName, file.Length }); -}); - -// CRITICAL: To disable the limit entirely (for streaming): -app.MapPost("/upload-unlimited", [DisableRequestSizeLimit] async (HttpContext context) => -{ - // Handle manually -}); -``` - -### Step 3: CRITICAL — Anti-Forgery Auto-Validates Form Uploads in .NET 8+ - -```csharp -// CRITICAL: In .NET 8+ with UseAntiforgery(), ALL form-bound endpoints -// automatically validate anti-forgery tokens, INCLUDING file uploads - -builder.Services.AddAntiforgery(); -var app = builder.Build(); -app.UseAntiforgery(); - -// This endpoint now REQUIRES an anti-forgery token: -app.MapPost("/upload", (IFormFile file) => Results.Ok(file.FileName)); -// Without the token → 400 Bad Request - -// CRITICAL: For API-only file uploads (no anti-forgery needed), opt out: -app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName)) - .DisableAntiforgery(); // CRITICAL: Must explicitly opt out - -// COMMON MISTAKE: Getting 400 errors on file uploads and not realizing -// it's because UseAntiforgery() is in the pipeline - -// WARNING: DisableAntiforgery() is safe for unauthenticated endpoints and -// endpoints using JWT bearer authentication. However, for endpoints -// authenticated with cookies, disabling antiforgery removes CSRF protection -// and exposes the endpoint to cross-site request forgery attacks. -// For cookie-authenticated endpoints, include a valid antiforgery token instead. -``` - -### Step 4: CRITICAL — Validate File Content, Not Just Extension - -```csharp -app.MapPost("/upload", async (IFormFile file) => -{ - // CRITICAL: Check content type AND file signature (magic bytes) - // NEVER trust file extension alone — it can be spoofed - - // Allow only JPEG/PNG by default. To support more (e.g., GIF), - // add the MIME type here AND validate its magic bytes below. - var allowedTypes = new[] { "image/jpeg", "image/png" }; - if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase)) - return Results.BadRequest("File type not allowed"); - - // CRITICAL: Check magic bytes for file type verification - using var stream = file.OpenReadStream(); - var header = new byte[8]; - var bytesRead = await stream.ReadAsync(header, 0, header.Length); - if (bytesRead < 4) - return Results.BadRequest("File content is too short or invalid"); - - // JPEG: FF D8 FF - // PNG: 89 50 4E 47 - var isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF; - var isPng = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; - - // Determine the actual content type from magic bytes - string? detectedContentType = isJpeg ? "image/jpeg" : isPng ? "image/png" : null; - if (detectedContentType is null) - return Results.BadRequest("File content is not a supported image format (only JPEG and PNG are allowed)."); - - // Ensure the declared Content-Type matches what the magic bytes detected - if (!string.Equals(file.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase)) - return Results.BadRequest("File content type does not match the declared ContentType header."); - - // CRITICAL: Never use the user-provided filename directly for the save path — it can - // contain path traversal characters (e.g., "../../../etc/passwd"). - // Generate a safe filename; derive the extension from validated content, not user input. - var extension = detectedContentType == "image/jpeg" ? ".jpg" : ".png"; - var safeFileName = $"{Guid.NewGuid()}{extension}"; - // NEVER: var path = Path.Combine("uploads", file.FileName); // Path traversal! - - var filePath = Path.Combine("uploads", safeFileName); - Directory.CreateDirectory("uploads"); - stream.Position = 0; - using var fileStream = File.Create(filePath); - await stream.CopyToAsync(fileStream); - - return Results.Ok(new { FileName = safeFileName, file.Length }); -}); -``` - -### Step 5: CRITICAL — Streaming Large Files Without Buffering - -```csharp -// CRITICAL: IFormFile relies on multipart form parsing that buffers content in memory -// (up to a threshold) then spills to temp files on disk. For very large uploads, -// this overhead is unnecessary if you can process the data in chunks. -// Use MultipartReader to stream directly — e.g., to a final storage location — -// without buffering the entire file first. - -app.MapPost("/upload-stream", - [DisableRequestSizeLimit] - async (HttpContext context) => -{ - // Extract the multipart boundary from the Content-Type header - var contentType = context.Request.ContentType; - if (contentType == null) - return Results.BadRequest("Missing Content-Type"); - - // Safely parse the Content-Type header to avoid FormatException from MediaTypeHeaderValue.Parse - if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType)) - return Results.BadRequest("Invalid Content-Type"); - - var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value; - if (string.IsNullOrWhiteSpace(boundary)) - return Results.BadRequest("Not a multipart request"); - - var reader = new MultipartReader(boundary, context.Request.Body); - - // CRITICAL: ReadNextSectionAsync returns null when there are no more sections - while (await reader.ReadNextSectionAsync() is { } section) - { - // Parse Content-Disposition to identify file sections - if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition)) - continue; - - if (contentDisposition.DispositionType.Equals("form-data") - && !string.IsNullOrEmpty(contentDisposition.FileName.Value)) - { - // Sanitize the user-provided filename to prevent path traversal - var originalFileName = contentDisposition.FileName.Value ?? string.Empty; - var sanitizedFileName = Path.GetFileName(originalFileName.Trim('"')); - var safeFile = $"{Guid.NewGuid()}"; - - // CRITICAL: Stream directly to disk — avoids buffering in memory - Directory.CreateDirectory("uploads"); - using var fileStream = File.Create(Path.Combine("uploads", safeFile)); - await section.Body.CopyToAsync(fileStream); - } - } - - return Results.Ok("Uploaded"); -}).DisableAntiforgery(); - -// COMMON MISTAKE: Using IFormFile for very large files -// Multipart form parsing can buffer large uploads and consume memory/disk. -// Use MultipartReader for streaming directly to storage. -``` - -## Common Mistakes - -1. **Only configuring one size limit**: Must configure BOTH Kestrel `MaxRequestBodySize` AND `FormOptions.MultipartBodyLengthLimit`. -2. **400 errors from anti-forgery**: In .NET 8+, `UseAntiforgery()` auto-validates form uploads. Use `.DisableAntiforgery()` for API endpoints (safe for JWT/unauthenticated; do NOT disable for cookie-authenticated endpoints). -3. **Trusting file.FileName**: User-provided filename can contain path traversal. Generate a safe filename with `Guid.NewGuid()` and derive the extension from validated content. -4. **Trusting Content-Type only**: Content type is client-spoofable. Always check magic bytes for actual file type verification. -5. **Using IFormFile for very large files**: Multipart form parsing buffers with a memory threshold and spills to temp files. Use `MultipartReader` to stream data in chunks directly to storage without buffering the entire file. -6. **Deriving file extension from user input**: Prefer deriving the extension from the validated content type or magic bytes rather than `Path.GetExtension(file.FileName)`. If the original extension must be preserved, validate it against the detected content type. diff --git a/plugins/dotnet-aspnetcore/skills/minimal-api-parameter-binding/SKILL.md b/plugins/dotnet-aspnetcore/skills/minimal-api-parameter-binding/SKILL.md new file mode 100644 index 0000000000..2b3e2c695f --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/minimal-api-parameter-binding/SKILL.md @@ -0,0 +1,90 @@ +--- +name: minimal-api-parameter-binding +description: > + Bind custom and value types directly from the route or query in ASP.NET Core minimal API handlers, so + parsing and validation happen at the boundary and invalid input becomes a 400 before the handler runs. + USE FOR: taking a strongly typed value (an id wrapper, a code, a date range, coordinates) as a minimal + API handler parameter instead of a raw string; adding a static TryParse for a single route/query value; + adding a static BindAsync for a value that spans several inputs or needs HttpContext; rejecting an + invalid value with 400 at binding time. + DO NOT USE FOR: controller model binding and [FromQuery]/[FromRoute] (controllers bind differently); + request body DTO shapes (use the model-payloads skills); cross-cutting validation across many endpoints + (use minimal-api-endpoint-filters); endpoint result types in general (use author-minimal-api-endpoints). +license: MIT +--- + +# Minimal API Parameter Binding + +Give a custom or value type its own parser so a minimal API binds it straight from the route or query. Parsing and validation then live on the type and run at the boundary: the handler receives a ready, valid value, and bad input becomes a 400 before the handler body executes. + +## A single route or query value: static TryParse + +When the value comes from one string (a route segment or a single query value), add a static `TryParse`. Minimal APIs discover it and bind automatically; a value that fails to parse is rejected as 400 without reaching the handler. + +```csharp +public readonly record struct Sku +{ + private Sku(string value) => Value = value; + + public string Value { get; } + + public static bool TryParse(string? value, IFormatProvider? provider, out Sku result) + { + if (!string.IsNullOrWhiteSpace(value) && Regex.IsMatch(value, "^[A-Z]{3}-[0-9]{4}$")) + { + result = new Sku(value); + return true; + } + + result = default; + return false; + } +} + +catalog.MapGet("/items/{sku}", (Sku sku, CatalogDb db) => /* sku is always valid here */); +// GET /items/not-a-sku -> 400, the handler never runs. +``` + +Implement the `(string?, IFormatProvider?, out T)` overload (the `IParsable` shape); minimal APIs also accept the simpler `(string?, out T)`. + +## A value spanning several inputs: static BindAsync + +When the value needs more than one string (several query keys, a header, or `HttpContext`), add a static `BindAsync`. + +```csharp +public readonly record struct DateRange(DateOnly Start, DateOnly End) +{ + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + var query = context.Request.Query; + if (DateOnly.TryParse(query["from"], out var from) + && DateOnly.TryParse(query["to"], out var to) + && from <= to) + { + return ValueTask.FromResult(new DateRange(from, to)); + } + + return ValueTask.FromResult(null); + } +} + +reports.MapGet("/sales", (DateRange range, CatalogDb db) => /* range is valid */); +``` + +With the non-nullable parameter above, returning `null` from `BindAsync` is automatically a 400 (the value counts as not provided) and the handler does not run. Declare the parameter nullable (`DateRange? range`) only when you want to handle the missing-or-invalid case yourself, then check for `null` and return 400; or throw `BadHttpRequestException` from `BindAsync` to force a 400 regardless of nullability. + +## Keep parsing on the type + +The handler signature names the typed value; the parse-and-validate rule lives on the type's `TryParse`/`BindAsync`, written once and reused by every endpoint that takes the type. The handler never pokes at a raw string. + +## Verify + +- The custom type appears directly in the handler signature; the handler does not re-parse a raw string. +- An invalid value yields a 400 before the handler runs (`TryParse` returns `false`, or `BindAsync` returns `null` / throws `BadHttpRequestException`). +- `TryParse` is used for a single route/query value; `BindAsync` when the value spans several inputs or needs `HttpContext`. + +❌ Taking `string sku` and validating it with a regex inside every handler. +✅ A `Sku` type with a static `TryParse`, bound at the boundary. + +❌ Returning 500, or silently treating an unparseable value as an empty filter. +✅ An invalid value becomes a 400 at binding time. diff --git a/plugins/dotnet-aspnetcore/skills/patch-partial-updates/SKILL.md b/plugins/dotnet-aspnetcore/skills/patch-partial-updates/SKILL.md new file mode 100644 index 0000000000..4a0a9a4f4e --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/patch-partial-updates/SKILL.md @@ -0,0 +1,76 @@ +--- +name: patch-partial-updates +description: > + Implement HTTP PATCH partial updates in ASP.NET Core that change only the fields the client sends and + distinguish an explicitly-cleared field (null) from an omitted one. + USE FOR: adding a PATCH endpoint that updates a subset of a resource's fields; distinguishing "set this + field to null / clear it" from "leave this field unchanged"; applying JSON Merge Patch semantics over + JsonElement/JsonNode, a JsonPatchDocument, or a tri-state Optional wrapper; validating only the provided + fields. + DO NOT USE FOR: full replacement (PUT) or creation (POST); optimistic concurrency on the update (use the + concurrency skills); DTO shape design in general (use the model-payloads skills). +license: MIT +--- + +# PATCH: Partial Updates + +A PATCH changes only the fields the client actually sends. The trap is that a plain DTO makes an **omitted field** and an **explicit null** look identical - both deserialize to `null` - so you cannot tell "leave it alone" from "clear it." Pick a mechanism that preserves that distinction, and apply only what was provided. + +## Why a plain DTO fails + +```csharp +public record UpdateContact(string? Name, string? Email); // Name == null: omitted, or cleared? +``` + +Copying `contact.Name = dto.Name` writes `null` over `Name` even when the client only meant to change `Email`. That is a full replace (PUT), not a PATCH. + +## Preferred: JSON Merge Patch over the raw JSON + +Bind the body as `JsonElement` (or `JsonNode`) and apply only the keys that are **present**; a key present with `null` clears the field, an absent key is left untouched. This is JSON Merge Patch (RFC 7386) semantics. + +```csharp +[HttpPatch("{id:int}")] +public async Task> Patch(int id, [FromBody] JsonElement patch, CancellationToken ct) +{ + var contact = await db.Contacts.FindAsync([id], ct); + if (contact is null) + { + return NotFound(); + } + + if (patch.TryGetProperty("name", out var name)) + { + contact.Name = name.ValueKind == JsonValueKind.Null ? null : name.GetString(); + } + + if (patch.TryGetProperty("email", out var email)) + { + contact.Email = email.ValueKind == JsonValueKind.Null ? null : email.GetString(); + } + + // Keys the client did not send are left exactly as they were. + await db.SaveChangesAsync(ct); + return Ok(contact.ToDto()); +} +``` + +`TryGetProperty` true means "apply"; false means "leave unchanged"; present-and-null means "clear." That is the whole distinction a plain DTO loses. + +## Alternatives + +- **`JsonPatchDocument` (RFC 6902):** explicit operations (`replace`, `remove`, `add`) with paths, applied via `patch.ApplyTo(...)`. Requires `Microsoft.AspNetCore.JsonPatch` (with the Newtonsoft input formatter). Use when clients want explicit, scriptable edits or array operations. `remove` is the explicit clear. +- **Tri-state `Optional` DTO:** a wrapper whose value carries whether it was set, so `absent` / `null` / `value` stay distinct in a typed model. Use when you want a strongly-typed request instead of raw JSON. + +## Validate only what was provided + +Run validation against the fields the request actually included (a missing field is not "invalid," it is unchanged). Return the updated resource (`200`) or `204`. + +## Verify + +- Only the fields the client supplies are changed; omitted fields are left as they were, not overwritten. +- An explicit `null` (clear) produces a different result from an omitted field (leave unchanged). +- The mechanism preserves that distinction - merge patch over `JsonElement`/`JsonNode`, a `JsonPatchDocument`, or a tri-state `Optional` wrapper - not a plain DTO where absent and null collapse to the same value. +- The endpoint uses `PATCH`, and validation applies to the provided fields only. + +❌ Bind a plain `UpdateContact` DTO and assign every property - the client clearing nothing still nulls the fields it omitted. +✅ Apply only the keys present in the JSON (present-and-null clears; absent leaves unchanged). diff --git a/plugins/dotnet-aspnetcore/skills/rate-limiting/SKILL.md b/plugins/dotnet-aspnetcore/skills/rate-limiting/SKILL.md new file mode 100644 index 0000000000..e340c619ff --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/rate-limiting/SKILL.md @@ -0,0 +1,91 @@ +--- +name: rate-limiting +description: > + Protect an ASP.NET Core API from being overwhelmed using the built-in rate limiter, partitioned per + caller, rather than a hand-rolled counter. + USE FOR: throttling requests so one user, tenant, or API key cannot starve the rest; registering + AddRateLimiter with a partitioned policy keyed on a claim/API key/IP; choosing a limiter algorithm + (fixed window, sliding window, token bucket, concurrency); returning 429 with Retry-After; applying a + limiter globally or per route with RequireRateLimiting. + DO NOT USE FOR: authentication/authorization (use authorize-api-endpoints); output/response caching + (use output caching); retrying outbound calls (that is client resilience); general middleware ordering. +license: MIT +--- + +# Rate Limiting + +Throttle with the framework's built-in rate limiter, and partition the limit by the caller so one caller cannot consume everyone else's capacity. A hand-rolled counter in custom middleware is not thread-safe across the algorithms you actually want, misses queuing and replenishment, and does not integrate with the pipeline. + +## Register a partitioned limiter + +`AddRateLimiter` with a policy that partitions on the authenticated caller, so each caller gets its own bucket. Choose an algorithm deliberately. + +```csharp +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy("per-tenant", httpContext => + { + var tenant = httpContext.User.FindFirstValue("tid") ?? "anonymous"; + return RateLimitPartition.GetTokenBucketLimiter(tenant, _ => new TokenBucketRateLimiterOptions + { + TokenLimit = 100, + TokensPerPeriod = 100, + ReplenishmentPeriod = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + }); + + options.OnRejected = async (context, cancellationToken) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + } + + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync(new { error = "Too many requests." }, cancellationToken); + }; +}); + +var app = builder.Build(); + +app.UseRateLimiter(); // after routing, before the endpoints run +``` + +## Partition on who is being limited + +Key the partition on the identity you are protecting the service from: the authenticated user or tenant claim, an API key, or the client IP as a fallback for anonymous traffic. A single un-partitioned limit throttles the whole service together, so one heavy caller still starves the others. + +## Apply the policy + +Apply per route or group, or set a global limiter. + +```csharp +app.MapGroup("/orders").RequireRateLimiting("per-tenant"); +// or, for everything: options.GlobalLimiter = PartitionedRateLimiter.Create(...); +``` + +## Choose the algorithm deliberately + +| Algorithm | Use when | +| --- | --- | +| Token bucket | A per-caller quota that tolerates short bursts up to a cap, then refills steadily. A good default. | +| Fixed window | Simplest; accept the burst at window boundaries. | +| Sliding window | Smoother than fixed window near boundaries. | +| Concurrency | Limit simultaneous in-flight requests rather than the arrival rate. | + +## Verify + +- Throttling uses `AddRateLimiter` + `UseRateLimiter`, not a hand-rolled counter or bespoke middleware. +- The limit is partitioned per caller (a claim, API key, or IP), not one global bucket for everyone. +- Exceeding the limit returns `429 Too Many Requests`, ideally with a `Retry-After` header. +- `UseRateLimiter` is registered after routing, and the policy is applied to the endpoints (globally or via `RequireRateLimiting`). + +❌ A `static ConcurrentDictionary` request counter in custom middleware. +✅ `AddRateLimiter` with a partitioned limiter. + +❌ One limit for the whole service, so a single tenant exhausts it for everyone. +✅ Partition the limiter on the caller's identity. diff --git a/plugins/dotnet-aspnetcore/skills/structure-api-business-logic/SKILL.md b/plugins/dotnet-aspnetcore/skills/structure-api-business-logic/SKILL.md new file mode 100644 index 0000000000..1c24f0aa96 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/structure-api-business-logic/SKILL.md @@ -0,0 +1,95 @@ +--- +name: structure-api-business-logic +description: "Structure ASP.NET Core API business logic: move operation logic into a service layer and report outcomes with a hand-rolled Result type that the endpoint translates to HTTP. USE FOR: extracting route-handler or controller-action logic into a service / application class; deciding how a service reports success versus failure; introducing a Result with a semantic error kind instead of throwing exceptions or scattering error returns; mapping a service outcome to HTTP status codes; keeping DbContext and business rules out of endpoints; reusing one operation across several endpoints. DO NOT USE FOR: the HTTP mechanics of a single endpoint's return type (use author-minimal-api-endpoints or author-controller-endpoints); DTO shapes and entity mapping details (use the model-payloads skills); EF Core query or pagination specifics (use the data-access skills); deciding which operations or resources to expose (use design-api-operations)." +license: MIT +--- + +# Structure API Business Logic + +Keep route handlers thin: move an operation's logic into a service that returns a result, and let the endpoint translate that result into HTTP. + +## Move the operation into a service + +When an endpoint does more than a trivial single-entity read or write (it validates input, touches several entities, enforces a business rule, or shares logic with another endpoint), put that logic in a service or application class. The endpoint binds the request, calls the service, and translates the outcome; it does not use `DbContext` directly inside a route-handler lambda or controller action once a service exists. + +Trivial CRUD over a single entity can stay inline. The threshold is real work: validation, multiple entities, rules, or reuse. + +## Report outcomes with a hand-rolled Result + +The service returns a `Result` that is either a success carrying the value or a failure carrying a semantic `ErrorType` (for example `Validation`, `NotFound`, `Conflict`). Do not throw exceptions for these expected outcomes, do not return bare tuples, and never return `IResult`, `Results<...>`, or `TypedResults` from the service — those belong to the endpoint. + +Ship a minimal, dependency-free result type: + +```csharp +public enum ErrorType { Validation, NotFound, Conflict } + +public sealed record Error(ErrorType Type, string Code, string Message); + +public readonly struct Result +{ + public bool IsSuccess { get; } + public T? Value { get; } + public Error? Error { get; } + + private Result(T value) { IsSuccess = true; Value = value; Error = null; } + private Result(Error error) { IsSuccess = false; Value = default; Error = error; } + + public static Result Success(T value) => new(value); + public static Result Failure(Error error) => new(error); +} +``` + +The service receives a request DTO or command (never an Entity Framework entity) and maps it to entities with hand-written code: + +```csharp +public sealed class OrderService(StoreDbContext db) +{ + public async Task> PlaceOrderAsync(PlaceOrderCommand cmd, CancellationToken ct) + { + if (cmd.Items.Count == 0) + return Result.Failure(new(ErrorType.Validation, "items.empty", "At least one item is required.")); + + if (!await db.Customers.AnyAsync(c => c.Id == cmd.CustomerId, ct)) + return Result.Failure(new(ErrorType.NotFound, "customer.notFound", "Customer not found.")); + + var order = new Order { CustomerId = cmd.CustomerId, CreatedAt = DateTimeOffset.UtcNow, Status = OrderStatus.Pending }; + // ...validate products, capture server-side prices, build line items... + db.Orders.Add(order); + await db.SaveChangesAsync(ct); + return Result.Success(order); + } +} +``` + +## Translate the Result at the endpoint + +The endpoint is the only place that knows HTTP. It maps each `ErrorType` to the matching status and the success to the right 2xx. + +```csharp +orders.MapPost("/", async Task, NotFound, Conflict, ValidationProblem>> ( + PlaceOrderRequest req, OrderService service, CancellationToken ct) => +{ + var result = await service.PlaceOrderAsync(req.ToCommand(), ct); + return result.IsSuccess + ? TypedResults.CreatedAtRoute(result.Value!, "GetOrder", new { id = result.Value!.Id }) + : result.Error!.Type switch + { + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Conflict => TypedResults.Conflict(), + _ => TypedResults.ValidationProblem(new Dictionary { [result.Error.Code] = [result.Error.Message] }), + }; +}); +``` + +Map `Validation` to 400 ProblemDetails, `NotFound` to 404, `Conflict` to 409. A missing customer or product is `NotFound` (404), not a validation error (400). + +## Reuse the operation, don't duplicate it + +Logic needed by more than one endpoint lives once in the service. Each endpoint calls the same method and translates its `Result`; never copy validation or creation rules into two handlers. + +## Verify + +- No `DbContext` usage inside route-handler lambdas or controller actions for non-trivial operations. +- The service returns `Result` (not tuples, exceptions, or ASP.NET result types) and takes a DTO/command, not an entity. +- Each `ErrorType` maps to a distinct status: `Validation` to 400, `NotFound` to 404, `Conflict` to 409. +- `dotnet build` succeeds (run `dotnet restore` first if needed). diff --git a/plugins/dotnet-aspnetcore/skills/structured-logging/SKILL.md b/plugins/dotnet-aspnetcore/skills/structured-logging/SKILL.md new file mode 100644 index 0000000000..a6ac9a62d6 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/structured-logging/SKILL.md @@ -0,0 +1,63 @@ +--- +name: structured-logging +description: > + Write diagnosable structured logs in ASP.NET Core: message templates with named placeholders, a scope + to correlate an operation's lines, appropriate levels, and no sensitive data. + USE FOR: adding logging to an operation so it can be searched and correlated in production; using + ILogger message templates with named properties instead of interpolated strings; correlating log lines + for one request/operation with BeginScope or a correlation id; choosing log levels; logging exceptions; + keeping secrets and PII out of logs; LoggerMessage source generation on hot paths. + DO NOT USE FOR: distributed tracing/metrics wiring (that is OpenTelemetry); global exception-to-response + mapping (use IExceptionHandler); audit persistence in the database. +license: MIT +--- + +# Structured Logging + +Log so the values you will search on are captured as **named properties**, and so every line from one operation can be pulled together. A log line is data, not a sentence. + +## Message templates with named placeholders, not interpolation + +Pass the values as arguments to a template. Each `{Name}` becomes a structured property the log store can index and query; interpolation throws that away and leaves only flat text. + +```csharp +logger.LogInformation("Created order {OrderId} for customer {CustomerId}", order.Id, customerId); +``` + +❌ `logger.LogInformation($"Created order {order.Id} for customer {customerId}");` - interpolated: no `OrderId`/`CustomerId` properties, just a string you cannot filter on. + +## Correlate an operation's lines with a scope + +A scope attaches the same properties to every entry written inside it, so all lines for one request, order, or tenant can be retrieved together. + +```csharp +using (logger.BeginScope(new Dictionary +{ + ["OrderId"] = order.Id, + ["TenantId"] = tenantId +})) +{ + logger.LogInformation("Validating order"); + logger.LogInformation("Charging payment"); + // both lines carry OrderId and TenantId +} +``` + +The framework already attaches request-level correlation (the trace identifier) to logs; add a scope for the identifiers that matter to *your* operation. + +## Levels, exceptions, and content + +- Use levels by severity: `Information` for normal flow, `Warning` for recoverable problems, `Error` for failures. Do not log everything at one level. +- Log an exception by passing it as the **first argument**, not by string-formatting it: `logger.LogError(ex, "Failed to create order {OrderId}", order.Id);`. +- Never log secrets, tokens, full PII, or request/response bodies. Log identifiers and outcomes, not payloads. +- On hot paths, use the `LoggerMessage` source generator (a `partial` method annotated `[LoggerMessage(Level = ..., Message = "...")]`) to avoid boxing/allocation and check the template at compile time. + +## Verify + +- Log calls use message templates with named placeholders; the searchable values (ids, tenant, operation) are structured properties, not interpolated or concatenated into the message text. +- Lines belonging to one operation are correlated with a scope (`BeginScope`) or a correlation identifier. +- Levels are used by severity; exceptions are passed as the exception argument, not formatted into the message. +- No secrets, tokens, PII, or bodies are written to the log. + +❌ `logger.LogInformation("Order " + order.Id + " failed: " + ex.Message);` - a flat string, wrong level, and the exception is stringified. +✅ `logger.LogError(ex, "Order {OrderId} failed", order.Id);` inside a scope carrying the operation's identifiers. diff --git a/plugins/dotnet-aspnetcore/skills/test-apis-with-webapplicationfactory/SKILL.md b/plugins/dotnet-aspnetcore/skills/test-apis-with-webapplicationfactory/SKILL.md new file mode 100644 index 0000000000..fec0736bd8 --- /dev/null +++ b/plugins/dotnet-aspnetcore/skills/test-apis-with-webapplicationfactory/SKILL.md @@ -0,0 +1,85 @@ +--- +name: test-apis-with-webapplicationfactory +description: > + Write end-to-end integration tests for an ASP.NET Core API with WebApplicationFactory. + USE FOR: adding integration tests that drive endpoints over HTTP through an in-memory test host; + setting up WebApplicationFactory and an HttpClient; making a minimal API's Program reachable + from a test project; replacing the app's DbContext with an isolated test database; seeding per-test + data; asserting HTTP status codes and JSON payloads. + DO NOT USE FOR: pure unit tests of a service or handler in isolation (no HTTP host needed); authoring + the endpoints themselves (use author-minimal-api-endpoints or author-controller-endpoints); load or + performance testing; service-layer structure (use structure-api-business-logic). +license: MIT +--- + +# Test APIs with WebApplicationFactory + +Drive the real HTTP pipeline in memory, against an isolated database, and assert both the status code and the payload. + +## Host the app and get a client + +Add `Microsoft.AspNetCore.Mvc.Testing` to the test project (`dotnet add package Microsoft.AspNetCore.Mvc.Testing`) and a reference to the API project. A minimal API built from top-level statements has an inaccessible generated `Program`, so expose it: add `public partial class Program;` at the end of `Program.cs` (or use `[assembly: InternalsVisibleTo("YourTests")]` in the API project). + +Give each test its own factory rather than sharing one across the class. xUnit constructs a fresh instance of the test class for every test method, so a factory created in the constructor (and disposed after) yields a separate in-memory database per test, and tests cannot see each other's writes. Reach for `IClassFixture` only when the tests in a class are read-only or seed uniquely-keyed data, since a shared factory means a shared database. + +```csharp +public class MessagingApiFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.ConfigureTestServices(services => + { + services.RemoveAll>(); + services.AddDbContext(o => o.UseInMemoryDatabase($"test-{Guid.NewGuid()}")); + }); +} + +public class NamespacesTests : IDisposable +{ + private readonly MessagingApiFactory _factory = new(); + private readonly HttpClient _client; + + public NamespacesTests() => _client = _factory.CreateClient(); + + public void Dispose() => _factory.Dispose(); +} +``` + +## Replace the database with an isolated one + +`RemoveAll>()` is the key step: adding a second `AddDbContext` without removing the first leaves the app's original provider in place. The `Guid.NewGuid()` database name gives each factory its own store. With a per-test factory this means a clean database for every test, so tests never share state with each other or with the app. + +## Seed exactly what each test asserts + +A test must not depend on data seeded at app startup or by another test. Before acting, seed the specific rows this test needs through a scope from the factory's services; then call the endpoint and assert. + +```csharp +using (var scope = _factory.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Namespaces.Add(new MessagingNamespace { Name = "ns-test", Location = "eastus" }); + await db.SaveChangesAsync(); +} +``` + +❌ A test that expects 201 or 200 but relies on data the app seeded at startup or that another test created. +✅ Seed the row this test depends on first, so it passes in any order and on a fresh database. + +## Assert status and payload + +Assert the status code and the deserialized body, not merely that the call did not throw. + +```csharp +var response = await _client.PostAsJsonAsync("/namespaces", new { name = "ns-1", location = "eastus", sku = "Standard" }); +Assert.Equal(HttpStatusCode.Created, response.StatusCode); +var created = await response.Content.ReadFromJsonAsync(); +Assert.Equal("ns-1", created!.Name); +``` + +❌ Asserting only `response.IsSuccessStatusCode`. +✅ Assert the exact status code and the values in the deserialized payload. + +## Verify + +- Tests obtain an `HttpClient` from `WebApplicationFactory`, and `Program` is reachable from the test project. +- The `DbContext` is replaced with an isolated per-factory database, with the app's original registration removed first. +- Each test seeds the data it asserts on through a factory-scoped `DbContext`, runs in any order, and asserts both the status code and the payload. diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/eval.yaml b/tests/dotnet-aspnetcore/author-controller-endpoints/eval.yaml new file mode 100644 index 0000000000..f3f4c87ce7 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/eval.yaml @@ -0,0 +1,60 @@ +name: author-controller-endpoints +description: Evaluates the dotnet-aspnetcore/author-controller-endpoints skill (endpoint-authoring scope) +type: capability +config: + timeout: 16m +stimuli: + - name: A Web API for messaging namespaces + prompt: | + Add a Web API to the MessagingApi project for managing messaging namespaces, backed by MessagingDbContext (the project already uses controllers). Clients need to create a namespace, update an existing one, get a namespace by its name, list all namespaces, change a namespace's tags, and delete a namespace. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - A single controller owns the namespace resource and its operations, rather than the resource being split across unrelated controllers or one controller per database table + - Creating a namespace returns 201 with a Location header pointing at the new resource, and updating an existing one returns 200 or 204; the namespace is addressed by its name in the route + - Changing tags is exposed as a focused partial update with its own route, returning the right status code, rather than requiring a full-resource replacement + - Actions return ActionResult typed results with correct status codes, returning 404 when a namespace is absent and the right success codes otherwise + - Each additional non-success status an action can produce is declared with ProducesResponseType + - name: A Web API for the queues in a namespace + prompt: | + Add a Web API to the MessagingApi project for the queues that live in a messaging namespace, backed by MessagingDbContext. Clients need to create a queue, update it, get a queue, list the queues in a namespace, and delete a queue. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The queue endpoints are reached through their owning namespace, with the namespace as part of the route and the queue identified within it, rather than a flat top-level queues resource + - A request for a queue under a namespace that does not exist returns 404 + - Actions return typed results with correct status codes, with create returning 201 and a Location header, update returning 200 or 204, and 404 when the queue is absent + - name: Recovering deleted namespaces + prompt: | + Namespaces in the MessagingApi project sometimes get deleted by mistake. Add the ability for clients to see the namespaces that have been deleted, bring a deleted namespace back, and permanently get rid of a deleted namespace they no longer want. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - Listing deleted namespaces is exposed as a distinct endpoint, separate from the normal list endpoint + - Restore is a distinct operation that returns a success status when it brings a namespace back and 404 when the named namespace is not in a recoverable state + - Permanently removing a namespace is a distinct operation that returns 204 on success and 404 when it does not apply + - Restore and permanent-removal are exposed as explicit operations with their own routes, distinct from the ordinary create, update, and delete actions diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-controller-endpoints/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/eval.yaml b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/eval.yaml new file mode 100644 index 0000000000..6fd27d4867 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/eval.yaml @@ -0,0 +1,41 @@ +name: author-minimal-api-endpoints +description: Evaluates the dotnet-aspnetcore/author-minimal-api-endpoints skill (endpoint-authoring scope) +type: capability +config: + timeout: 16m +stimuli: + - name: A minimal API for messaging namespaces + prompt: | + Add a minimal API to the MessagingApi project for managing messaging namespaces, backed by MessagingDbContext (the project uses minimal APIs). Clients need to create a namespace, update an existing one, get a namespace by its name, list all namespaces, change a namespace's tags, and delete a namespace. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - The namespace endpoints are grouped together (for example with MapGroup) rather than scattered, and each handler declares a strongly typed Results union so the produced statuses are explicit + - Creating a namespace returns 201 with a Location header pointing at the new resource, and updating an existing one returns 200 or 204; the namespace is addressed by its name in the route + - Changing tags is exposed as a focused partial update with its own route, returning the right status code, rather than requiring a full-resource replacement + - Handlers return TypedResults with correct status codes, returning 404 when a namespace is absent and the right success codes otherwise, and invalid input is rejected with a 400 problem response + - name: A minimal API for the queues in a namespace + prompt: | + Add a minimal API to the MessagingApi project for the queues that live in a messaging namespace, backed by MessagingDbContext. Clients need to create a queue, update it, get a queue, list the queues in a namespace, and delete a queue. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: MapGroup + - type: prompt + rubric: + - The queue endpoints are reached through their owning namespace, with the namespace as part of the route and the queue identified within it, rather than a flat top-level queues resource + - A request for a queue under a namespace that does not exist returns 404 + - Each handler declares a strongly typed Results union covering the statuses it returns, with create returning 201 and a Location header, update returning 200 or 204, and 404 when the queue is absent diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..7e2de7d0ae --- /dev/null +++ b/tests/dotnet-aspnetcore/author-minimal-api-endpoints/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); + +var app = builder.Build(); + +var namespaces = app.MapGroup("/namespaces"); + +namespaces.MapGet("/", async (MessagingDbContext db) => + await db.Namespaces.AsNoTracking().Where(n => !n.IsDeleted).ToListAsync()); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/eval.yaml b/tests/dotnet-aspnetcore/authorize-api-endpoints/eval.yaml new file mode 100644 index 0000000000..81523526d4 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/eval.yaml @@ -0,0 +1,63 @@ +name: authorize-api-endpoints +description: Evaluates the dotnet-aspnetcore/authorize-api-endpoints skill +type: capability +config: + timeout: 16m +stimuli: + - name: Tenant and owner rules on namespaces + prompt: | + In the MessagingApi project (controllers, with authentication already configured), add endpoints to get, update, and delete a namespace. Each namespace belongs to a tenant (its TenantId) and has an owner (its OwnerId). The signed-in user carries a tenant in a "tid" claim and may also be an admin (role "admin"). Enforce two rules: a user may only work with namespaces that belong to their own tenant; and a namespace may only be deleted by its owner or by an admin. A request that breaks a rule must be refused. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Authoriz + - type: prompt + rubric: + - The tenant rule, which can be decided from the request and the user's claims without loading the entity, is enforced through the authorization system (a policy and an authorization requirement/handler) rather than ad-hoc inline if-statements duplicated in each action + - The owner rule, which depends on the namespace's stored OwnerId that is only known after loading the row, is enforced imperatively by calling IAuthorizationService.AuthorizeAsync with the loaded namespace as the resource, not attempted declaratively before the entity exists + - The two rules are treated differently according to where the decision data lives (request/claims versus loaded entity state), rather than forcing both into the same mechanism + - A failed tenant or owner check returns 403 Forbidden when the user is authenticated but not allowed, and the delete path distinguishes 404 (no such namespace) from 403 (not permitted) + - Authorization relies on the authenticated user's claims, not a client-supplied tenant or user id from the body or query + - name: A reusable membership rule across endpoints + prompt: | + In the MessagingApi project (controllers, with authentication already configured), several endpoints (get, update, delete, and change-tags on a namespace) must all enforce the same rule: the signed-in user may only act on a namespace whose tenant matches the user's "tid" claim, unless the user is an admin (role "admin"). Implement this once and apply it to all of those endpoints rather than repeating the check. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Requirement + - type: prompt + rubric: + - The rule is expressed as an authorization requirement (a type implementing IAuthorizationRequirement) with a matching AuthorizationHandler, rather than copy-pasted inline checks in each action + - The handler grants access by calling context.Succeed for the requirement when the rule is met, and simply returns without calling context.Fail when it is not, so an admin allowance or another handler can still satisfy it (OR semantics) + - The requirement is exposed as a named policy and the endpoints opt in through that policy, so the single rule is reused across all the listed endpoints + - The authorization handler is registered in DI with an appropriate lifetime so it is discovered + - The decision relies on the authenticated user's claims rather than client-supplied values + - name: Lock down the namespaces API + prompt: | + In the MessagingApi project (controllers), the namespaces API is currently open. Lock it down: every endpoint must require a signed-in user, reading is allowed for any signed-in user, and the destructive operations (delete and permanently purge) may only be performed by administrators (users with the role "admin"). Wire up whatever is needed so this works end to end. Use MessagingDbContext. + environment: + files: + - src: ./fixture-nosetup/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Authoriz + - type: prompt + rubric: + - Authentication is set up (an authentication scheme is registered) and authorization services are added + - Admin-only access is expressed as a named authorization policy (for example requiring the "admin" role) rather than inline role-string checks scattered in the actions + - The default expectation that every endpoint requires a signed-in user is enforced centrally (a fallback policy, or applying authorization to the controller) rather than annotating each action by hand and risking an unprotected one + - The destructive endpoints (delete and purge) require the admin policy while reads require only authentication + - The authentication and authorization middleware are added in the correct order, with UseAuthentication before UseAuthorization, after routing diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..9afbf0e929 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/MessagingApi.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..53b8fb9215 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public string TenantId { get; set; } = ""; + public string OwnerId { get; set; } = ""; + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Program.cs new file mode 100644 index 0000000000..fb3ccd93b0 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture-nosetup/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..9afbf0e929 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..53b8fb9215 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public string TenantId { get; set; } = ""; + public string OwnerId { get; set; } = ""; + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..b4fa4ccf4c --- /dev/null +++ b/tests/dotnet-aspnetcore/authorize-api-endpoints/fixture/MessagingApi/Program.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +// Authentication is already configured: the current user arrives as a JWT bearer token. +// Claims available on User include the subject (sub / NameIdentifier), tenant ("tid"), and role. +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/eval.yaml b/tests/dotnet-aspnetcore/change-tracking-delta/eval.yaml new file mode 100644 index 0000000000..2db200574a --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/eval.yaml @@ -0,0 +1,45 @@ +name: change-tracking-delta +description: Evaluates the dotnet-aspnetcore/change-tracking-delta skill +type: capability +config: + timeout: 18m +stimuli: + - name: Keep a client mirror of the queues current + prompt: | + In the MessagingApi project (controllers), some clients keep a local mirror of the queues in a namespace and need to keep it current without re-downloading the whole collection each time. After an initial sync, a client should be able to fetch just what has changed since its last sync: queues that were added, queues that were updated, and queues that were removed - and it must learn about the removals, not just silently stop seeing them. When nothing has changed since last time, convey that as well. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - Changes are found with a monotonic per-entity change marker and only entities past the client's watermark are returned (not a full rescan); when the queue's rowversion is used as the marker it is made comparable so the range and ordering are valid (mapped to an order-preserving number, or a raw SQL range predicate), not compared as raw unordered bytes + - Deletions are reported to the client as tombstones it can act on (for example body-less 204 No Content parts, or 410 Gone, or an explicit removed marker), which requires including soft-deleted rows and relying on soft-delete; removals are not hard-deleted or simply omitted + - The change response keeps entity payloads unchanged - added/updated entities carry their normal representation - for example a multipart/mixed of application/http sub-responses (200 for present, 204 or 410 for removed) rather than wrapping every entity in an envelope or adding a removed field to it + - The client is handed an opaque change-tracking link to return with, delivered via the Link header (for example rel=deltaLink), plus a next link when the delta spans multiple pages + - The delta is ordered by (marker, id) with a unique tiebreaker and the watermark advances deterministically, and the boundary hazard is considered (rowversion assigned-before-committed, e.g. MIN_ACTIVE_ROWVERSION) + - A no-changes result is conveyed as an empty delta plus a refreshed change-tracking link, and a stale or unreadable token returns 410 Gone so the client does a full resync + - name: Change tracking without a rowversion + prompt: | + In the MessagingApi project (controllers), add the same kind of incremental sync for queues (a client fetches only what changed - added, updated, removed - since its last sync, and learns about removals), but assume this deployment runs on a store that does not provide a database rowversion, so you cannot rely on one. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - With no rowversion available, an application-maintained last-modified timestamp is stamped on every create, update, AND soft-delete and used as the change marker, so no change path is missed + - The timestamp's weakness is handled: the query overlaps (uses a slightly-earlier-or-equal watermark rather than a strict greater-than) and the client is expected to dedup by id, with a (timestamp, id) tiebreaker so boundary ties are neither split across a page nor skipped + - Deletions are reported as tombstones (for example body-less 204 No Content parts, or 410 Gone, or an explicit removed marker) via soft-delete, not omitted + - Entity payloads are kept unchanged (for example multipart 200 present / 204 removed sub-responses) and the client is handed an opaque change-tracking link via the Link header + - No-changes is conveyed as an empty delta plus a refreshed link, and a stale or unreadable token returns 410 Gone diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/change-tracking-delta/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/configuring-opentelemetry-dotnet/eval.yaml b/tests/dotnet-aspnetcore/configuring-opentelemetry-dotnet/eval.yaml deleted file mode 100644 index e279e50268..0000000000 --- a/tests/dotnet-aspnetcore/configuring-opentelemetry-dotnet/eval.yaml +++ /dev/null @@ -1,91 +0,0 @@ -name: configuring-opentelemetry-dotnet -description: Evaluates the dotnet-aspnetcore/configuring-opentelemetry-dotnet skill -type: capability -config: - timeout: 3m -stimuli: - - name: Set up OpenTelemetry tracing and metrics with custom spans in ASP.NET Core - prompt: | - I'm adding OpenTelemetry to my ASP.NET Core 8 API. I need: - 1. Distributed tracing with OTLP export to a collector - 2. A custom span around my OrderService.ProcessOrder method - 3. A custom counter metric for orders processed - Show me the complete Program.cs setup and the service class. Don't create files. - graders: - - type: output-matches - config: - pattern: (OpenTelemetry\.Extensions\.Hosting|AddOpenTelemetry) - - type: output-matches - config: - pattern: (ActivitySource|StartActivity) - - type: output-matches - config: - pattern: (AddOtlpExporter|UseOtlpExporter|OtlpExporter) - - type: output-not-contains - config: - substring: OpenTelemetry.Exporter.Console - - type: prompt - rubric: - - Included OpenTelemetry.Extensions.Hosting and OpenTelemetry.Instrumentation.Http in the required NuGet packages - - Configured both .WithTracing() and .WithMetrics() in the same AddOpenTelemetry() call - - Registered custom ActivitySource names via AddSource() and showed that the ActivitySource name in the service - class must match exactly - - Used IMeterFactory via dependency injection to create the Meter instead of a static Meter constructor - - Did not include gratuitous custom activities, logs, or metrics beyond what was specifically asked for - - Used a service name appropriate to the application, not a generic placeholder like MyOrderService - constraints: - reject_tools: - - bash - - edit - - name: Configure all three OpenTelemetry signals with correct OTLP export - prompt: | - I need to set up all three OpenTelemetry signals (traces, metrics, logs) in my - ASP.NET Core 8 app, all exporting via OTLP to my collector. - What NuGet packages do I need, and show me the complete Program.cs configuration? - Don't create files. - graders: - - type: output-matches - config: - pattern: (AddOpenTelemetry|WithTracing|WithMetrics) - - type: output-matches - config: - pattern: (AddOtlpExporter|UseOtlpExporter|OtlpExporter) - - type: output-matches - config: - pattern: (Logging|WithLogging|AddOpenTelemetry|TraceId|SpanId) - - type: output-not-contains - config: - substring: OpenTelemetry.Exporter.Console - - type: prompt - rubric: - - "Configured all three signals: tracing (WithTracing), metrics (WithMetrics), and logging" - - Used a unified OTLP exporter configuration rather than repeating exporter setup per signal - - Ensured logs carry the same service identity as traces and metrics - - Listed the complete set of required NuGet packages including the OTLP exporter package - constraints: - reject_tools: - - bash - - edit - - name: Propagate trace context across a message queue - prompt: | - I have two ASP.NET Core 8 services communicating via RabbitMQ. Service A publishes - an order event and Service B consumes it. Traces show up fine within each service - but I can't see the end-to-end distributed trace across the queue boundary. - How do I propagate the trace context through message headers? Don't create files. - graders: - - type: output-matches - config: - pattern: (Propagat|TextMapPropagator|Inject|Extract) - - type: output-matches - config: - pattern: (ActivitySource|StartActivity) - - type: prompt - rubric: - - Showed how to inject trace context into message headers on the sending side using a TextMapPropagator - - Showed how to extract trace context from message headers on the receiving side and link it as a parent - - Used the extracted context as the parent when starting a new Activity on the consumer so spans connect into one - distributed trace - constraints: - reject_tools: - - bash - - edit diff --git a/tests/dotnet-aspnetcore/controller-concurrency/eval.yaml b/tests/dotnet-aspnetcore/controller-concurrency/eval.yaml new file mode 100644 index 0000000000..c7d69f5fc5 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/eval.yaml @@ -0,0 +1,43 @@ +name: controller-concurrency +description: Evaluates the dotnet-aspnetcore/controller-concurrency skill +type: capability +config: + timeout: 16m +stimuli: + - name: Make namespace updates safe under concurrent edits + prompt: | + In the MessagingApi project (which uses controllers), two clients sometimes edit the same namespace at the same time and one silently overwrites the other's change. Make updates to a namespace safe so that a client working from an out-of-date copy is rejected instead of clobbering a newer change. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - A stale update is rejected with 412 Precondition Failed rather than applied + - The namespace's row version is exposed to clients as an ETag and the update honors the If-Match request header to detect a stale write + - The namespace's LastModifiedAt timestamp is also offered as a validator (Last-Modified) and the update honors If-Unmodified-Since for the same protection, in addition to the ETag, not only one of the two + - The ETag is derived from the content/row version (it changes when the entity changes) while Last-Modified comes from the LastModifiedAt timestamp; the two are kept distinct + - A concurrent save that passes the header check but still races is caught (DbUpdateConcurrencyException) and surfaced as a 412 or 409, never an unhandled 500 + - name: Cheap revalidation when fetching a namespace + prompt: | + In the MessagingApi project, clients fetch namespaces frequently and most of the time the namespace has not changed since they last read it. Make the get-namespace endpoint let a client check cheaply whether its copy is still current and avoid re-downloading the namespace when it has not changed. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The 200 response carries an ETag built from the namespace's row version and the endpoint returns 304 Not Modified when the client sends a matching If-None-Match + - The 200 response also carries a Last-Modified header built from the namespace's LastModifiedAt timestamp, and the endpoint returns 304 when the client sends an If-Modified-Since that is not older than the last modification, not only the ETag path + - A 304 response has no body + - The ETag value is quoted per the header format and reflects the content/row version, distinct from the time-based Last-Modified validator diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-concurrency/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/controller-data-access/eval.yaml b/tests/dotnet-aspnetcore/controller-data-access/eval.yaml new file mode 100644 index 0000000000..e25b268b15 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/eval.yaml @@ -0,0 +1,42 @@ +name: controller-data-access +description: Evaluates the dotnet-aspnetcore/controller-data-access skill +type: capability +config: + timeout: 16m +stimuli: + - name: An efficient, robust list of queues + prompt: | + In the MessagingApi project (which uses controllers), add an endpoint to page through the queues in a namespace, backed by MessagingDbContext. A namespace can hold a very large number of queues, and queues are constantly being created and deleted while clients page through them. Make paging efficient and keep it correct even though the collection changes between requests: a client walking the pages should not miss a queue or see the same queue twice because of concurrent inserts and deletes. Also give clients a straightforward way to fetch the next page, and return only what a client needs. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The read query does not track entities (AsNoTracking) and projects to the fields the client needs (a Select projection), running on the database with no client-side evaluation or N+1 + - Results are ordered by a deterministic total order whose final key is a unique column (for example ThenBy the id), so ties in the sort field cannot cause rows to repeat or be skipped across pages + - Paging uses a keyset/cursor comparison against the last item's key (such as (sortKey, id) greater than (lastSortKey, lastId)) rather than Skip/OFFSET, so inserts and deletes between requests do not shift a positional window and cause duplicates or skips + - The ordering/cursor key is an immutable or monotonic column, so a row is not re-emitted if a mutable sort field changes after it was returned + - The next-page link is delivered via the standard Link header (RFC 8288) with rel=next and the body is the collection itself, and the page size is bounded, rather than an ad-hoc page-number or offset scheme the client must assemble + - name: Fetching a single namespace with its summary + prompt: | + In the MessagingApi project, add an endpoint that gets a single namespace by name along with a count of how many queues and topics it has, backed by MessagingDbContext. It should be efficient and not load data it does not need. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The read uses no-tracking and does not load the full queue and topic collections into memory just to count them (the counts are computed in the query, for example via a projection that uses Count) + - The lookup returns 404 when the namespace is absent rather than throwing + - The query avoids fetching the namespace's owned collections when only counts and scalar fields are needed diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/controller-data-access/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/convert-blazor-server-to-webapp/eval.yaml b/tests/dotnet-aspnetcore/convert-blazor-server-to-webapp/eval.yaml deleted file mode 100644 index 47d4382359..0000000000 --- a/tests/dotnet-aspnetcore/convert-blazor-server-to-webapp/eval.yaml +++ /dev/null @@ -1,124 +0,0 @@ -name: convert-blazor-server-to-webapp -description: Evaluates the dotnet-aspnetcore/convert-blazor-server-to-webapp skill -type: capability -config: - timeout: 10m -stimuli: - - name: Blazor Server app with CascadingAuthenticationState - prompt: | - Convert this .NET 7 Blazor Server app to a .NET 8 Blazor Web App. The app uses - authentication with CascadingAuthenticationState. Provide the complete updated files. - The app should no longer use AddServerSideBlazor or MapBlazorHub. - - Program.cs: - ```csharp - using BlazorAuthApp; - - var builder = WebApplication.CreateBuilder(args); - - builder.Services.AddRazorPages(); - builder.Services.AddServerSideBlazor(); - - var app = builder.Build(); - - if (!app.Environment.IsDevelopment()) - { - app.UseExceptionHandler("/Error"); - app.UseHsts(); - } - - app.UseHttpsRedirection(); - app.UseStaticFiles(); - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.MapControllers(); - app.MapBlazorHub(); - app.MapFallbackToPage("/_Host"); - - app.Run(); - ``` - - App.razor: - ```razor - - - - - - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
-
- ``` - - Pages/_Host.cshtml: - ```html - @page "/" - @using Microsoft.AspNetCore.Components.Web - @namespace BlazorAuthApp.Pages - @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - - - - - - - - - - - - - -
- - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - Reload - 🗙 -
- - - - - ``` - graders: - - type: output-contains - config: - substring: AddCascadingAuthenticationState - - type: output-contains - config: - substring: AddRazorComponents - - type: output-contains - config: - substring: MapRazorComponents - - type: output-contains - config: - substring: UseAntiforgery - - type: output-not-matches - config: - pattern: 'AddServerSideBlazor\s*\(' - - type: output-not-matches - config: - pattern: 'MapBlazorHub\s*\(' - - type: prompt - rubric: - - Removes the CascadingAuthenticationState component wrapper from App.razor/Routes.razor - - Adds builder.Services.AddCascadingAuthenticationState() to Program.cs - - Places UseAntiforgery after UseAuthentication and UseAuthorization in the middleware pipeline - - Keeps AuthorizeRouteView in the Routes component - - Does NOT switch to static rendering — preserves interactive server rendering diff --git a/tests/dotnet-aspnetcore/design-collection-api/eval.yaml b/tests/dotnet-aspnetcore/design-collection-api/eval.yaml new file mode 100644 index 0000000000..5da220f763 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/eval.yaml @@ -0,0 +1,26 @@ +name: design-collection-api +description: Probe — greenfield collection API completeness and data-model reconciliation +type: capability +config: + timeout: 16m +stimuli: + - name: Build the queues collection API to a production standard + prompt: | + In the MessagingApi project (which uses controllers, backed by MessagingDbContext), implement the public REST API for the queues that live inside a namespace. Clients need to browse the queues in a namespace and read an individual queue. Build this to a production standard so it holds up as the service grows and its customers integrate against it over the long term. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The list endpoint returns results in a deterministic, stable total order whose final key is a unique column (for example ThenBy on the id / primary key), rather than relying on an arbitrary or unspecified database order + - The collection is not returned unbounded - results are paged with a bounded page size and a forward-navigation mechanism - so an ever-growing namespace never returns an unbounded payload + - The design gives clients an efficient way to obtain only the queues that were added, changed, or removed since a prior read (incremental change tracking), or it explicitly identifies this as a requirement and the data-model support it needs, rather than assuming clients must re-fetch the whole collection (the Queue entity already carries RowVersion, IsDeleted/DeletedAt, and CreatedAt/LastModifiedAt, which a strong design leverages) + - Clients can narrow the collection through an opt-in, server-side (database-translated) filtering mechanism over known fields, rather than offering no filtering or building queries from arbitrary client input + - The operations use correct HTTP semantics and status codes (for example 200 for a read, 404 for a missing queue) and reads project to a response shape rather than leaking the EF entity and its navigation graph + - The implementation explicitly reconciles these capabilities with the data model - using or adding the indexed ordering key, the row-version/timestamp watermark, and soft-delete needed to support stable paging and change tracking - rather than ignoring what the persistence layer must provide diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/design-collection-api/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/dotnet-webapi/eval.yaml b/tests/dotnet-aspnetcore/dotnet-webapi/eval.yaml deleted file mode 100644 index 18a9362fea..0000000000 --- a/tests/dotnet-aspnetcore/dotnet-webapi/eval.yaml +++ /dev/null @@ -1,162 +0,0 @@ -name: dotnet-webapi -description: Evaluates the dotnet-aspnetcore/dotnet-webapi skill -type: capability -config: - timeout: 15m -stimuli: - - name: Create a CRUD Web API with minimal APIs, OpenAPI, and proper HTTP semantics - prompt: | - Create an ASP.NET Core Web API (.NET 10) project for managing products. - Each product has a name, price, category (an enum: Electronics, Clothing, - Food, Books), and a createdAt timestamp. - - The API should have these endpoints: - - GET /api/products (list all) - - GET /api/products/{id} (get by ID) - - POST /api/products (create a product) - - Use an in-memory service behind an interface for data storage. - Add OpenAPI documentation with rich metadata on each endpoint. - graders: - - type: output-matches - config: - pattern: (MapGet|MapPost) - - type: output-matches - config: - pattern: (TypedResults|Results<) - - type: output-matches - config: - pattern: sealed - - type: output-contains - config: - substring: DateTimeOffset - - type: output-matches - config: - pattern: '/// ' - - type: output-contains - config: - substring: AddOpenApi - - type: output-contains - config: - substring: MapOpenApi - - type: output-matches - config: - pattern: (WithName|WithSummary|WithDescription) - - type: output-not-contains - config: - substring: Swashbuckle - - type: output-not-matches - config: - pattern: (UseSwagger|UseSwaggerUI) - - type: output-contains - config: - substring: JsonStringEnumConverter - - type: output-contains - config: - substring: CancellationToken - - type: prompt - rubric: - - Uses minimal APIs (MapGet, MapPost, etc.) rather than controllers for new projects - - POST endpoint returns 201 Created with a Location header, not 200 OK - - Uses TypedResults with explicit Results return types for compile-time type safety - - DTOs are sealed records, not mutable classes - - Uses a service layer with interfaces instead of injecting a data store directly into endpoints - - CancellationToken is accepted in endpoint signatures and forwarded through all async calls - - Date/time properties use DateTimeOffset, not DateTime - - All request and response DTOs include XML doc summary comments - - Enum properties serialize as strings by default using JsonStringEnumConverter - - Uses builder.Services.AddOpenApi() and app.MapOpenApi() (built-in .NET 9+ support) - - Does NOT add any Swashbuckle NuGet packages - - Chains .WithName(), .WithSummary(), and .WithDescription() on endpoints for OpenAPI metadata - - - name: Add error handling with ProblemDetails and IExceptionHandler - prompt: | - I have an existing ASP.NET Core minimal API project. I need to add - global error handling so that: - - All error responses use RFC 7807 Problem Details format - - KeyNotFoundException maps to 404 - - ArgumentException maps to 400 - - InvalidOperationException maps to 409 - - Unhandled exceptions return 500 with no sensitive details - - Show me how to implement this without adding try-catch blocks in - every endpoint. - graders: - - type: output-contains - config: - substring: IExceptionHandler - - type: output-contains - config: - substring: ProblemDetails - - type: output-contains - config: - substring: AddProblemDetails - - type: output-contains - config: - substring: UseExceptionHandler - - type: prompt - rubric: - - Implements IExceptionHandler (the modern .NET 8+ approach) rather than convention-based middleware - - Returns ProblemDetails for all error responses (RFC 7807) - - Maps exception types to appropriate HTTP status codes (404, 400, 409) - - Does not expose internal exception details in production error responses - - Registers the handler with AddExceptionHandler() and AddProblemDetails() - - Exception handler class is sealed - - Exception handler is placed in a Middleware/ folder - - - name: Add a new API endpoint to an existing controller-based project - prompt: | - I have an existing ASP.NET Core Web API that uses controllers. Here is - one of the existing controllers: - - ```csharp - [ApiController] - [Route("api/[controller]")] - public class CustomersController : ControllerBase - { - private readonly ICustomerService _service; - - public CustomersController(ICustomerService service) - { - _service = service; - } - - [HttpGet] - public async Task>> GetAll() - { - return Ok(await _service.GetAllAsync()); - } - } - ``` - - Add a new OrdersController with a GET endpoint to list orders and a - POST endpoint to create an order. Each order has a customer ID, a - placedAt timestamp, a total amount, and an OrderStatus (Pending, - Processing, Shipped, Delivered, Cancelled). - graders: - - type: output-contains - config: - substring: ControllerBase - - type: output-contains - config: - substring: '[ApiController]' - - type: output-contains - config: - substring: CancellationToken - - type: output-not-matches - config: - pattern: 'app\.(MapGet|MapPost|MapPut|MapDelete|MapPatch)|\b(MapGet|MapPost|MapPut|MapDelete|MapPatch)\b' - - type: output-contains - config: - substring: DateTimeOffset - - type: output-matches - config: - pattern: '/// ' - - type: prompt - rubric: - - Continues with the controller pattern since the existing project uses controllers (does not mix minimal APIs) - - Includes CancellationToken in all endpoint signatures - - POST create returns CreatedAtAction with 201 status, not Ok with 200 - - Uses sealed record DTOs with proper naming (CreateOrderRequest, OrderResponse) - - Date/time properties use DateTimeOffset, not DateTime - - All request and response DTOs include XML doc summary comments diff --git a/tests/dotnet-aspnetcore/filter-and-select/eval.yaml b/tests/dotnet-aspnetcore/filter-and-select/eval.yaml new file mode 100644 index 0000000000..f0c25f6a6f --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/eval.yaml @@ -0,0 +1,25 @@ +name: filter-and-select +description: Probe — bounded, opt-in filtering and field selection over a collection +type: capability +config: + timeout: 16m +stimuli: + - name: Let clients narrow the queues list and pick fields + prompt: | + In the MessagingApi project (which uses controllers, backed by MessagingDbContext), the endpoint that lists the queues in a namespace currently returns every queue with all of its fields. Let clients narrow the list down to just the queues they care about (for example by status or by name), and let them choose which fields come back so responses can be smaller when they only need a few. Keep it robust for a public, high-traffic API. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - Filtering is restricted to an explicit, opt-in allow-list of filterable fields and operators - requests referencing an unknown field or an unsupported operator are rejected (for example with 400) rather than silently ignored or blindly applied + - Filter conditions are translated into server-side database predicates (EF Core Where) executed by the database, rather than loading the whole collection into memory and filtering there, and the predicates are not built by concatenating or evaluating raw client-supplied strings (no dynamic-LINQ / string-to-query injection surface) + - Field selection returns only the requested fields via a server-side projection (EF Core Select), and requests naming unknown fields are validated/rejected rather than ignored + - Field selection does not break paging or ordering - the ordering/key fields are still applied server-side even when the client did not ask for them - so results stay stably ordered and pageable + - Operand values are converted to each field's type (invalid values rejected) and abuse is bounded - expensive operators such as a leading-wildcard contains and the number of predicates or the result size are constrained to avoid unindexed full scans diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/filter-and-select/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/long-running-operations/eval.yaml b/tests/dotnet-aspnetcore/long-running-operations/eval.yaml new file mode 100644 index 0000000000..a9e0ecdd7c --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/eval.yaml @@ -0,0 +1,25 @@ +name: long-running-operations +description: Probe — long-running operation (async request-reply) pattern +type: capability +config: + timeout: 16m +stimuli: + - name: Provisioning a namespace that takes time + prompt: | + In the MessagingApi project (controllers), provisioning a new namespace is slow: it can take a while before the namespace is fully ready to use. Right now the create endpoint blocks the caller until provisioning finishes. Change the design so a client can request a namespace and not be left waiting on the connection, and can find out later whether provisioning has finished, failed, or is still going. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - The create request is accepted without blocking until provisioning finishes, returning promptly with a way to track progress (for example a 202 Accepted with a Location or operation id) rather than holding the connection until the work is done + - There is a distinct operation-status resource the client can poll to learn whether provisioning succeeded, failed, or is still running, separate from the namespace resource itself + - The status distinguishes terminal outcomes (succeeded, failed) from the in-progress state, and a client can tell when to stop polling + - The response points the client at where to check status (a Location header or an operation URL), and optionally advises a poll interval (Retry-After) + - The namespace's own provisioning state is not simply reported as immediately succeeded synchronously; the slow work is modeled as an operation with its own lifecycle diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/long-running-operations/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/eval.yaml b/tests/dotnet-aspnetcore/minimal-api-concurrency/eval.yaml new file mode 100644 index 0000000000..560850cfdc --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/eval.yaml @@ -0,0 +1,43 @@ +name: minimal-api-concurrency +description: Evaluates the dotnet-aspnetcore/minimal-api-concurrency skill +type: capability +config: + timeout: 16m +stimuli: + - name: Make namespace updates safe under concurrent edits + prompt: | + In the MessagingApi project (which uses minimal APIs), two clients sometimes edit the same namespace at the same time and one silently overwrites the other's change. Make updates to a namespace safe so that a client working from an out-of-date copy is rejected instead of clobbering a newer change. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - A stale update is rejected with 412 Precondition Failed rather than applied + - The namespace's row version is exposed to clients as an ETag and the update honors the If-Match request header to detect a stale write + - The namespace's LastModifiedAt timestamp is also offered as a validator (Last-Modified) and the update honors If-Unmodified-Since for the same protection, in addition to the ETag, not only one of the two + - The ETag is derived from the content/row version (it changes when the entity changes) while Last-Modified comes from the LastModifiedAt timestamp; the two are kept distinct + - A concurrent save that passes the header check but still races is caught (DbUpdateConcurrencyException) and surfaced as a 412 or 409, never an unhandled 500 + - name: Cheap revalidation when fetching a namespace + prompt: | + In the MessagingApi project (which uses minimal APIs), clients fetch namespaces frequently and most of the time the namespace has not changed since they last read it. Make the get-namespace endpoint let a client check cheaply whether its copy is still current and avoid re-downloading the namespace when it has not changed. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: MapGet + - type: prompt + rubric: + - The 200 response carries an ETag built from the namespace's row version and the endpoint returns 304 Not Modified when the client sends a matching If-None-Match + - The 200 response also carries a Last-Modified header built from the namespace's LastModifiedAt timestamp, and the endpoint returns 304 when the client sends an If-Modified-Since that is not older than the last modification, not only the ETag path + - A 304 response has no body + - The ETag value is quoted per the header format and reflects the content/row version, distinct from the time-based Last-Modified validator diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..7e2de7d0ae --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-concurrency/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); + +var app = builder.Build(); + +var namespaces = app.MapGroup("/namespaces"); + +namespaces.MapGet("/", async (MessagingDbContext db) => + await db.Namespaces.AsNoTracking().Where(n => !n.IsDeleted).ToListAsync()); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/eval.yaml b/tests/dotnet-aspnetcore/minimal-api-data-access/eval.yaml new file mode 100644 index 0000000000..0748e5394f --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/eval.yaml @@ -0,0 +1,42 @@ +name: minimal-api-data-access +description: Evaluates the dotnet-aspnetcore/minimal-api-data-access skill +type: capability +config: + timeout: 16m +stimuli: + - name: An efficient, robust list of queues + prompt: | + In the MessagingApi project (which uses minimal APIs), add an endpoint to page through the queues in a namespace, backed by MessagingDbContext. A namespace can hold a very large number of queues, and queues are constantly being created and deleted while clients page through them. Make paging efficient and keep it correct even though the collection changes between requests: a client walking the pages should not miss a queue or see the same queue twice because of concurrent inserts and deletes. Also give clients a straightforward way to fetch the next page, and return only what a client needs. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - The read query does not track entities (AsNoTracking) and projects to the fields the client needs (a Select projection), running on the database with no client-side evaluation or N+1 + - Results are ordered by a deterministic total order whose final key is a unique column (for example ThenBy the id), so ties in the sort field cannot cause rows to repeat or be skipped across pages + - Paging uses a keyset/cursor comparison against the last item's key (such as (sortKey, id) greater than (lastSortKey, lastId)) rather than Skip/OFFSET, so inserts and deletes between requests do not shift a positional window and cause duplicates or skips + - The ordering/cursor key is an immutable or monotonic column, so a row is not re-emitted if a mutable sort field changes after it was returned + - The next-page link is delivered via the standard Link header (RFC 8288) with rel=next and the body is the collection itself, and the page size is bounded, rather than an ad-hoc page-number or offset scheme the client must assemble + - name: Fetching a single namespace with its summary + prompt: | + In the MessagingApi project, add an endpoint that gets a single namespace by name along with a count of how many queues and topics it has, backed by MessagingDbContext. It should be efficient and not load data it does not need. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - The read uses no-tracking and does not load the full queue and topic collections into memory just to count them (the counts are computed in the query, for example via a projection that uses Count) + - The lookup returns 404 when the namespace is absent rather than throwing + - The query avoids fetching the namespace's owned collections when only counts and scalar fields are needed diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..7e2de7d0ae --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-data-access/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); + +var app = builder.Build(); + +var namespaces = app.MapGroup("/namespaces"); + +namespaces.MapGet("/", async (MessagingDbContext db) => + await db.Namespaces.AsNoTracking().Where(n => !n.IsDeleted).ToListAsync()); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/eval.yaml b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/eval.yaml new file mode 100644 index 0000000000..25b68b3d3f --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/eval.yaml @@ -0,0 +1,26 @@ +name: minimal-api-endpoint-filters +description: Evaluates the dotnet-aspnetcore/minimal-api-endpoint-filters skill +type: capability +config: + timeout: 16m +stimuli: + - name: Normalize on write and field-select on read + prompt: | + In the MessagingApi project (minimal APIs), the namespace endpoints need two behaviors applied across all of them. First, the namespace name sent on create or update is normalized (trimmed and lowercased) before it is stored, so the persisted name is always canonical. Second, on the read endpoints (get and list) a client can ask for only certain fields with a "fields" query value (for example ?fields=name,location) and get back just those fields. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - Name normalization is applied in one shared place across the write endpoints by rewriting the bound request argument (an endpoint filter that mutates the bound arguments before the handler), rather than the trim/lowercase being copied into each handler + - The normalization happens before the value is persisted, so the stored namespace name is trimmed and lowercased + - Field selection is implemented by transforming the result after the handler runs (an endpoint filter that reshapes the returned value using the requested fields), applied across the read endpoints rather than coded into each handler + - A request without a fields value returns the full representation, and a problem or not-found result is left unshaped rather than being mangled + - The behaviors use endpoint filters, which can see the bound arguments and the handler result, rather than middleware (which cannot access the bound arguments or the typed result) or duplicated inline logic + - Input validation, if any is added, is not hand-rolled as a filter when the built-in minimal API validation would cover it diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..7e2de7d0ae --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-endpoint-filters/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); + +var app = builder.Build(); + +var namespaces = app.MapGroup("/namespaces"); + +namespaces.MapGet("/", async (MessagingDbContext db) => + await db.Namespaces.AsNoTracking().Where(n => !n.IsDeleted).ToListAsync()); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/minimal-api-file-upload/eval.yaml b/tests/dotnet-aspnetcore/minimal-api-file-upload/eval.yaml deleted file mode 100644 index 7b8691a7c4..0000000000 --- a/tests/dotnet-aspnetcore/minimal-api-file-upload/eval.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: minimal-api-file-upload -description: Evaluates the dotnet-aspnetcore/minimal-api-file-upload skill -type: capability -config: - timeout: 3m -stimuli: - - name: Implement secure file upload in ASP.NET Core 8 minimal API - prompt: | - I need to implement a file upload endpoint in my ASP.NET Core 8 minimal API. - The endpoint should accept image files (JPEG and PNG only), reject files over 10MB, - and save them to an "uploads" folder. My app already has UseAntiforgery() in the pipeline. - Show me the complete implementation including size limits configuration and the endpoint. - graders: - - type: output-matches - config: - pattern: (IFormFile|IFormFileCollection) - - type: output-matches - config: - pattern: (Guid\.NewGuid|Path\.GetRandomFileName) - - type: prompt - rubric: - - Configures both the Kestrel request body size limit and the form multipart body length limit — not just one of - them - - Handles the antiforgery middleware that would otherwise reject the upload with a 400 error - - Does not save to disk using the original user-provided filename — generates a safe name to prevent path traversal - - Verifies the actual file content (e.g., magic bytes or file signatures), not just the Content-Type header which - can be spoofed - - name: Upload multiple files with metadata in minimal API - prompt: | - Show me how to add a file upload endpoint to an ASP.NET Core 8 minimal API - that accepts multiple image files along with a text description field from - the same form submission. The total size can be up to 50MB. Just show me - the code — I don't need a full project. - graders: - - type: output-matches - config: - pattern: (IFormFileCollection|IFormFile|List) - - type: output-matches - config: - pattern: (MaxRequestBodySize|MultipartBodyLengthLimit) - - type: prompt - rubric: - - Configures both the Kestrel request body size limit and the form multipart body length limit for the 50MB - requirement - - Uses [FromForm] correctly when mixing file parameters with non-file form fields like description - - Does not save uploaded files using the original user-provided filenames - - name: Stream very large file uploads without buffering - prompt: | - I need to accept uploads of video files up to 2GB in my ASP.NET Core 8 minimal API. - Using IFormFile crashes with out-of-memory errors for files over 500MB. - How do I stream large uploads directly to disk without buffering the whole file? - graders: - - type: output-matches - config: - pattern: (MultipartReader|ReadNextSectionAsync) - - type: output-matches - config: - pattern: (DisableRequestSizeLimit|MaxRequestBodySize) - - type: prompt - rubric: - - Uses MultipartReader to stream upload sections directly to disk instead of buffering via IFormFile - - Disables or increases the request size limit to accommodate 2GB files - - Does not use the user-provided filename directly when saving streamed sections to disk diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/eval.yaml b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/eval.yaml new file mode 100644 index 0000000000..47b3805133 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/eval.yaml @@ -0,0 +1,24 @@ +name: minimal-api-parameter-binding +description: Probe — custom minimal API parameter binding (TryParse / BindAsync) +type: capability +config: + timeout: 16m +stimuli: + - name: A strongly typed location bound at the boundary + prompt: | + In the MessagingApi project (minimal APIs), add a list endpoint that lets clients filter namespaces by an Azure-style region passed in the query string (for example ?region=eastus). A value that is not a valid region should be rejected with a 400 rather than treated as a real filter or causing a server error. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Map + - type: prompt + rubric: + - The region is modeled as a custom type that participates in minimal API model binding by implementing a static TryParse (for a query/route string value) or a static BindAsync, rather than taking a raw string and parsing it inside the handler + - The handler signature accepts the strongly typed region value directly, so binding and validation happen at the boundary + - An invalid region results in a 400-level outcome (for example a binding failure surfaced as 400, or the handler returning a validation problem) rather than a 500 or silently proceeding + - The parsing/validation logic lives on the type (or its binding method), not duplicated in the handler diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..7e2de7d0ae --- /dev/null +++ b/tests/dotnet-aspnetcore/minimal-api-parameter-binding/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); + +var app = builder.Build(); + +var namespaces = app.MapGroup("/namespaces"); + +namespaces.MapGet("/", async (MessagingDbContext db) => + await db.Namespaces.AsNoTracking().Where(n => !n.IsDeleted).ToListAsync()); + +app.Run(); \ No newline at end of file diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/eval.yaml b/tests/dotnet-aspnetcore/patch-partial-updates/eval.yaml new file mode 100644 index 0000000000..a21204fbc0 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/eval.yaml @@ -0,0 +1,25 @@ +name: patch-partial-updates +description: Probe — partial update semantics (null vs absent) +type: capability +config: + timeout: 16m +stimuli: + - name: Update only the fields the client sends + prompt: | + In the MessagingApi project (controllers), clients want to change just one or two things about an existing namespace without resending the whole resource: for example only its SKU, or updating or removing individual tags. Add an endpoint that supports these partial updates. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - Only the fields the client actually supplies are changed; fields the client omits are left exactly as they were, rather than the operation overwriting the whole resource like a full replace (PUT) + - A field the client explicitly sets to null (to clear it) is distinguished from a field the client simply did not include, and the two produce different results (clear versus leave unchanged) + - The update is exposed with PATCH semantics (for example HTTP PATCH), not by requiring the client to send the entire resource + - The mechanism used to tell "present but null" from "absent" is deliberate (for example JSON Merge Patch semantics, a JsonPatchDocument, optional/Optional-style wrappers, or inspecting the raw JSON), not a plain DTO where a missing field and an explicit null are indistinguishable + - Validation still applies to the fields that are provided, and the response reflects the updated resource or an appropriate status diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/patch-partial-updates/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/rate-limiting/eval.yaml b/tests/dotnet-aspnetcore/rate-limiting/eval.yaml new file mode 100644 index 0000000000..4be1418a22 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/eval.yaml @@ -0,0 +1,25 @@ +name: rate-limiting +description: Probe — protecting the API from being overwhelmed by a single caller +type: capability +config: + timeout: 16m +stimuli: + - name: Stop one tenant from starving the others + prompt: | + In the MessagingApi project (controllers), a few heavy callers sometimes flood the API with requests and degrade it for everyone else. Each request is made by an authenticated caller that belongs to a tenant (a "tid" claim). Make it so no single tenant can overwhelm the service: a tenant that sends too many requests in a short window should be told to slow down, while other tenants are unaffected. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Controller + - type: prompt + rubric: + - Requests are throttled using the framework's built-in rate limiting (AddRateLimiter and the rate limiting middleware) rather than a hand-rolled counter or custom middleware + - The limit is partitioned per tenant (keyed on the tid claim) so one tenant's traffic does not consume another tenant's allowance, rather than a single global limit for the whole service + - A caller that exceeds the limit receives 429 Too Many Requests (ideally with a Retry-After) instead of being served or silently dropped + - A specific limiter algorithm is chosen deliberately (fixed window, sliding window, token bucket, or concurrency) with sensible parameters, not left unbounded + - The limiter is applied to the API endpoints (globally or via a policy on the routes) so it actually takes effect diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/rate-limiting/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/eval.yaml b/tests/dotnet-aspnetcore/structure-api-business-logic/eval.yaml new file mode 100644 index 0000000000..03837c5bb8 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/eval.yaml @@ -0,0 +1,43 @@ +name: structure-api-business-logic +description: Evaluates the dotnet-aspnetcore/structure-api-business-logic skill +type: capability +config: + timeout: 16m +stimuli: + - name: Create a queue with the rules that govern it + prompt: | + In the MessagingApi project, add the ability to create a queue inside a namespace. A queue can only be created when its parent namespace exists and is not deleted, the queue name must be unique within that namespace, and a namespace is capped at 100 queues. When one of those conditions is not met the caller needs to be able to tell which one failed and get the right HTTP status; on success they get the created queue. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Queue + - type: prompt + rubric: + - The creation logic (parent-exists, not-deleted, unique-name, and quota checks) lives in a dedicated service or application class rather than inline in the route handler or controller action + - Each distinct failure (missing or deleted namespace, duplicate name, quota exceeded) is reported as a distinct semantic outcome that the endpoint maps to the correct status code (for example 404 vs 409 vs 400/422), not collapsed into one generic error + - The operation reports success-or-failure through a returned result value carrying an error kind, not by throwing exceptions for these expected business outcomes and not by returning bare tuples + - The service is HTTP-agnostic: it does not return IResult/IActionResult/TypedResults or set status codes itself; the endpoint translates the outcome to HTTP + - The endpoint stays thin and does not use MessagingDbContext directly to perform the rule checks once the service exists + - name: Decommission a namespace and everything it owns + prompt: | + In the MessagingApi project, add an operation that decommissions a namespace: it should soft-delete the namespace together with all the queues and topics it owns in one consistent step, refuse to run when the namespace is already deleted, and let the caller distinguish "no such namespace" from "already decommissioned". Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Namespace + - type: prompt + rubric: + - The multi-entity decommission logic (soft-deleting the namespace and cascading to its child queues and topics) lives in a service or application class, not inline in the endpoint + - A missing namespace and an already-decommissioned namespace are reported as two distinct outcomes that map to different HTTP responses (for example 404 vs 409), rather than a single ambiguous result + - The outcome is returned as a result value with an error kind rather than signalled by throwing exceptions for these expected cases or by returning bare tuples + - The service does not depend on HTTP types; the endpoint is responsible for turning the outcome into a status code diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/structure-api-business-logic/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/structured-logging/eval.yaml b/tests/dotnet-aspnetcore/structured-logging/eval.yaml new file mode 100644 index 0000000000..477cd9a37d --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/eval.yaml @@ -0,0 +1,25 @@ +name: structured-logging +description: Probe — diagnosable structured logging +type: capability +config: + timeout: 16m +stimuli: + - name: Diagnose production issues by namespace, tenant, operation + prompt: | + In the MessagingApi project (controllers), add logging to the namespace create, update, and delete operations that will genuinely help an on-call engineer diagnose problems in production. Use MessagingDbContext. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: MessagingApi/**/*.cs + value: Logger + - type: prompt + rubric: + - Log calls use structured message templates with named placeholders (for example logger.LogInformation("... {NamespaceId} {TenantId}", id, tenant)) so the values are captured as queryable properties, not string-interpolated or concatenated into the message text + - Values needed for searching (namespace id, tenant, operation) are emitted as named log properties rather than baked into free-form text + - Log lines belonging to the same request/operation are correlated, for example via a logging scope (ILogger.BeginScope) or a correlation identifier attached to the entries + - Log levels are used appropriately (information for normal operations, warning/error for failures) rather than everything at one level + - Sensitive or irrelevant data is not dumped into logs; the logged fields are the ones needed to diagnose diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..cd2e0886a1 --- /dev/null +++ b/tests/dotnet-aspnetcore/structured-logging/fixture/MessagingApi/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/eval.yaml b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/eval.yaml new file mode 100644 index 0000000000..1584a09450 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/eval.yaml @@ -0,0 +1,26 @@ +name: test-apis-with-webapplicationfactory +description: Evaluates the dotnet-aspnetcore/test-apis-with-webapplicationfactory skill +type: capability +config: + timeout: 18m +stimuli: + - name: Integration tests for the namespaces API + prompt: | + The MessagingApi project has a working Web API for messaging namespaces (a NamespacesController backed by MessagingDbContext, with create, get-by-name, list, update, and delete). Add automated tests that exercise this API end to end over HTTP, covering creating a namespace and reading it back, getting one that does not exist, listing, and deleting. + environment: + files: + - src: ./fixture/MessagingApi + dest: MessagingApi + graders: + - type: file-contains + config: + path: "**/*.cs" + value: WebApplicationFactory + - type: prompt + rubric: + - The tests drive the API through WebApplicationFactory (an in-memory test host) and a real HttpClient that sends HTTP requests, rather than instantiating the controller and calling its methods directly or mocking the pipeline + - The application's registered DbContext registration is replaced for the tests with an isolated test database, so the tests do not hit the app's configured database and do not share state with it + - The tests are isolated from one another, with seeding and database state arranged so one test's writes do not make another test pass or fail (a fresh or uniquely-named database per factory or per test) + - Each test asserts both the HTTP status code and the deserialized response body where there is one, not just the status code + - The create path is verified by reading the resource back through the API (a round trip), and the missing-resource path asserts 404 + - The test project is set up to make the app's Program reachable to WebApplicationFactory and references the testing host package (Microsoft.AspNetCore.Mvc.Testing) diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Controllers/NamespacesController.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Controllers/NamespacesController.cs new file mode 100644 index 0000000000..a87383d60d --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Controllers/NamespacesController.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; +using MessagingApi.Dtos; +using MessagingApi.Models; + +namespace MessagingApi.Controllers; + +[ApiController] +[Route("namespaces")] +public class NamespacesController(MessagingDbContext db) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var items = await db.Namespaces + .AsNoTracking() + .Where(n => !n.IsDeleted) + .ToListAsync(ct); + + return Ok(items.Select(ToResponse)); + } + + [HttpGet("{name}")] + public async Task> Get(string name, CancellationToken ct) + { + var ns = await db.Namespaces + .AsNoTracking() + .FirstOrDefaultAsync(n => n.Name == name && !n.IsDeleted, ct); + + if (ns is null) + { + return NotFound(); + } + + return Ok(ToResponse(ns)); + } + + [HttpPost] + public async Task> Create(CreateNamespaceRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ValidationProblem("Name is required."); + } + + var exists = await db.Namespaces.AnyAsync(n => n.Name == request.Name && !n.IsDeleted, ct); + if (exists) + { + return Conflict(); + } + + var ns = new MessagingNamespace + { + Id = Guid.NewGuid(), + Name = request.Name, + Location = request.Location, + Sku = Enum.TryParse(request.Sku, out var sku) ? sku : NamespaceSku.Basic, + Tags = request.Tags ?? new(), + ProvisioningState = ProvisioningState.Succeeded, + CreatedAt = DateTimeOffset.UtcNow, + LastModifiedAt = DateTimeOffset.UtcNow + }; + + db.Namespaces.Add(ns); + await db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(Get), new { name = ns.Name }, ToResponse(ns)); + } + + [HttpPut("{name}")] + public async Task> Update(string name, UpdateNamespaceRequest request, CancellationToken ct) + { + var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.Name == name && !n.IsDeleted, ct); + if (ns is null) + { + return NotFound(); + } + + ns.Location = request.Location; + if (Enum.TryParse(request.Sku, out var sku)) + { + ns.Sku = sku; + } + + if (request.Tags is not null) + { + ns.Tags = request.Tags; + } + + ns.LastModifiedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return Ok(ToResponse(ns)); + } + + [HttpDelete("{name}")] + public async Task Delete(string name, CancellationToken ct) + { + var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.Name == name && !n.IsDeleted, ct); + if (ns is null) + { + return NotFound(); + } + + ns.IsDeleted = true; + ns.DeletedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return NoContent(); + } + + private static NamespaceResponse ToResponse(MessagingNamespace ns) => + new(ns.Name, ns.Location, ns.Sku.ToString(), ns.Tags); +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Data/MessagingDbContext.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Data/MessagingDbContext.cs new file mode 100644 index 0000000000..11315b9c55 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Data/MessagingDbContext.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MessagingApi.Models; + +namespace MessagingApi.Data; + +public class MessagingDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Namespaces => Set(); + public DbSet Queues => Set(); + public DbSet Topics => Set(); + public DbSet Subscriptions => Set(); + public DbSet AuthorizationRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var ns = modelBuilder.Entity(); + ns.HasIndex(n => n.Name).IsUnique(); + ns.HasMany(n => n.Queues).WithOne(q => q.Namespace!).HasForeignKey(q => q.NamespaceId); + ns.HasMany(n => n.Topics).WithOne(t => t.Namespace!).HasForeignKey(t => t.NamespaceId); + ns.HasMany(n => n.AuthorizationRules).WithOne(a => a.Namespace!).HasForeignKey(a => a.NamespaceId); + + var tagsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new()); + var tagsComparer = new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new()); + ns.Property(n => n.Tags).HasConversion(tagsConverter, tagsComparer); + + modelBuilder.Entity() + .HasMany(t => t.Subscriptions).WithOne(s => s.Topic!).HasForeignKey(s => s.TopicId); + + modelBuilder.Entity().Property(q => q.LockDuration).HasConversion(new TimeSpanToTicksConverter()); + } +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Dtos/NamespaceDtos.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Dtos/NamespaceDtos.cs new file mode 100644 index 0000000000..6c3afce80e --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Dtos/NamespaceDtos.cs @@ -0,0 +1,7 @@ +namespace MessagingApi.Dtos; + +public record NamespaceResponse(string Name, string Location, string Sku, IReadOnlyDictionary Tags); + +public record CreateNamespaceRequest(string Name, string Location, string Sku, Dictionary? Tags); + +public record UpdateNamespaceRequest(string Location, string Sku, Dictionary? Tags); diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/MessagingApi.csproj b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/MessagingApi.csproj new file mode 100644 index 0000000000..3e5d4a2472 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/MessagingApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/AuthorizationRule.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/AuthorizationRule.cs new file mode 100644 index 0000000000..b7100cc6f0 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/AuthorizationRule.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace: an access policy with regenerable keys. +public class AuthorizationRule +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public AccessRight Rights { get; set; } + public string PrimaryKey { get; set; } = ""; + public string SecondaryKey { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Enums.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Enums.cs new file mode 100644 index 0000000000..d99e472190 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Enums.cs @@ -0,0 +1,10 @@ +namespace MessagingApi.Models; + +public enum NamespaceSku { Basic, Standard, Premium } + +public enum ProvisioningState { Creating, Updating, Succeeded, Deleting, Failed } + +public enum EntityStatus { Active, Disabled } + +[Flags] +public enum AccessRight { None = 0, Listen = 1, Send = 2, Manage = 4 } diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/MessagingNamespace.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/MessagingNamespace.cs new file mode 100644 index 0000000000..1e4643ef05 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/MessagingNamespace.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Aggregate root: a messaging namespace owns its queues, topics, and authorization rules. +public class MessagingNamespace +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Location { get; set; } + public NamespaceSku Sku { get; set; } + public Dictionary Tags { get; set; } = new(); + public ProvisioningState ProvisioningState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + // Soft delete. + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + // Concurrency token (drives the content ETag). + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Queues { get; set; } = new(); + public List Topics { get; set; } = new(); + public List AuthorizationRules { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Queue.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Queue.cs new file mode 100644 index 0000000000..fdcb989a83 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Queue.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace. +public class Queue +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Subscription.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Subscription.cs new file mode 100644 index 0000000000..88aeaadcf3 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Subscription.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned grandchild: a subscription belongs to a topic. +public class Subscription +{ + public Guid Id { get; set; } + public Guid TopicId { get; set; } + public Topic? Topic { get; set; } + + public required string Name { get; set; } + public int MaxDeliveryCount { get; set; } = 10; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Topic.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Topic.cs new file mode 100644 index 0000000000..ca546ebcdb --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Models/Topic.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace MessagingApi.Models; + +// Owned child of a namespace; itself owns subscriptions. +public class Topic +{ + public Guid Id { get; set; } + public Guid NamespaceId { get; set; } + public MessagingNamespace? Namespace { get; set; } + + public required string Name { get; set; } + public int MaxSizeInMegabytes { get; set; } = 1024; + public EntityStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastModifiedAt { get; set; } + + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + [Timestamp] public byte[] RowVersion { get; set; } = []; + + public List Subscriptions { get; set; } = new(); +} diff --git a/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Program.cs b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Program.cs new file mode 100644 index 0000000000..5f4e8787d8 --- /dev/null +++ b/tests/dotnet-aspnetcore/test-apis-with-webapplicationfactory/fixture/MessagingApi/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MessagingApi.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("MessagingDb")); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); + +public partial class Program { }