@drunkcoding/dknet-implementation-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +261 -0
  5. package/agents/dknet-architect.md +49 -0
  6. package/agents/dknet-bdd-engineer.md +62 -0
  7. package/agents/dknet-implementer.md +75 -0
  8. package/package.json +52 -0
  9. package/plugin.json +26 -0
  10. package/skills/README.md +47 -0
  11. package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
  12. package/skills/dknet-bdd-tests/SKILL.md +355 -0
  13. package/skills/dknet-bdd-tests/checklist.md +39 -0
  14. package/skills/dknet-crud/SKILL.md +483 -0
  15. package/skills/dknet-ddd-principles/SKILL.md +87 -0
  16. package/skills/dknet-docs/SKILL.md +296 -0
  17. package/skills/dknet-docs/checklist.md +58 -0
  18. package/skills/dknet-docs/templates/README-template.md +68 -0
  19. package/skills/dknet-docs/templates/api-reference-template.md +275 -0
  20. package/skills/dknet-docs/templates/architecture-template.md +166 -0
  21. package/skills/dknet-docs/templates/data-model-template.md +99 -0
  22. package/skills/dknet-docs/templates/events-template.md +155 -0
  23. package/skills/dknet-dto-mapping/SKILL.md +278 -0
  24. package/skills/dknet-efcore-config/SKILL.md +379 -0
  25. package/skills/dknet-endpoint/SKILL.md +458 -0
  26. package/skills/dknet-entity/SKILL.md +483 -0
  27. package/skills/dknet-feature/SKILL.md +139 -0
  28. package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
  29. package/skills/dknet-feature-remove/SKILL.md +131 -0
  30. package/skills/dknet-messaging-events/SKILL.md +395 -0
  31. package/skills/dknet-package-adoption/SKILL.md +252 -0
  32. package/skills/dknet-platform-config/SKILL.md +342 -0
  33. package/skills/dknet-project-structure/SKILL.md +148 -0
  34. package/skills/dknet-queries-specs/SKILL.md +330 -0
  35. package/skills/dknet-scaffold/SKILL.md +209 -0
  36. package/skills/dknet-unit-tests/SKILL.md +382 -0
@@ -0,0 +1,458 @@
1
+ ---
2
+ name: dknet-endpoint
3
+ description: Create Minimal API endpoint configurations using this project's IEndpointConfig pattern — raw minimal-API routes mapped by hand, a single generated Map<Entity>Crud() call, or the underlying DKNet.AspCore.Extensions generic route helpers called directly. Use after AppServices actions (or CrudCreate/CrudUpdate/CrudAction entity attributes) are ready, to expose a feature over HTTP. Invoke as `/dknet-endpoint <Feature> <Entity> [mode=manual|auto] [routePrefix] [version=V1]` to scaffold it for a feature.
4
+ metadata:
5
+ kind: workflow
6
+ arguments: "<Feature> <Entity> [mode=manual|auto] [routePrefix] [version=V1]"
7
+ allowed-tools: Read, Grep, Glob, Edit, Write, Bash, Agent
8
+ ---
9
+
10
+ Usage: `/dknet-endpoint <Feature> <Entity> [mode=manual|auto] [routePrefix] [version=V1]`
11
+
12
+ # Skill: Endpoint Configuration
13
+
14
+ Wires AppServices actions/queries — or an entity's `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`
15
+ declarations — to HTTP routes via `IEndpointConfig`. Three ways to map a route: pick the one matching
16
+ how the feature's actions were built (`mode=manual` or `mode=auto`), and reach for the third only when
17
+ neither fits one particular route.
18
+
19
+ ## 1. The `IEndpointConfig` contract
20
+
21
+ ```csharp
22
+ internal sealed class {Entity}V1Endpoint : IEndpointConfig
23
+ {
24
+ public int Version => 1; // -> /v{version:apiVersion}/... ; defaults to 1
25
+ public string GroupEndpoint => "/{kebab-case-plural}";
26
+ public void Map(RouteGroupBuilder group) { /* register routes */ }
27
+ }
28
+ ```
29
+
30
+ - **Discovery**: every non-abstract `IEndpointConfig` in the API assembly is found by
31
+ `UseEndpointConfigs(...)`, called once from `Minimal.Api/Program.cs`. Never register a route group
32
+ by hand.
33
+ - **Versioning**: `EnableVersioning` (`FeatureManagement`, default `true`) turns `GroupEndpoint` into
34
+ `/v{version}/{route}` — `"/products"` at `Version => 1` becomes `/v1/products`. Off, the group
35
+ registers with no version segment.
36
+ - **Optional members**: `AuthPolicy` (policy required for the group; `null` means "authenticated, no
37
+ specific policy") and `Tag` (OpenAPI grouping tag, defaulting to `GroupEndpoint` with slashes turned
38
+ to dashes). Neither shipped sample overrides either.
39
+ - **`internal sealed`, always.** `Architecture/ApiTests.cs` enforces it two ways —
40
+ `AllApiClassesShouldBeInternal` and `AllEndpointClassesShouldBeInternalAndSealed_ExceptAbstractClasses`
41
+ (every concrete class under an `ApiEndpoints` namespace) — so a `public` or non-`sealed` endpoint
42
+ class fails the build.
43
+ - **Cross-cutting wiring happens once, not per endpoint.** `Program.cs` passes `ConfigureGroup =
44
+ (group, _) => group.AddFluentValidationAutoValidation()` to `UseEndpointConfigs`, and
45
+ `.AddContextualRequestPopulation()` (the `[FromClaim]` populator) is registered the line before.
46
+ Every group gets both automatically.
47
+ - **`.WithDescription(...)`/`.Produces<T>(...)`** are ordinary `RouteHandlerBuilder` calls for OpenAPI
48
+ documentation; every hand-mapped route in both samples sets at least `.WithDescription`.
49
+
50
+ ## 2. Option A — raw minimal API (hand-mapped)
51
+
52
+ Mirror `PurchaseOrderV1Endpoint` (`ManualSample/PurchaseOrder`) when the feature's actions are
53
+ hand-written `AppServices` requests/queries. Every route is a literal `group.MapPost/MapGet/MapPut/
54
+ MapDelete(...)` call, dispatching through `IMessageBus` by hand:
55
+
56
+ ```csharp
57
+ using DKNet.AspCore.Extensions.Responses;
58
+ using DKNet.AspCore.Idempotency;
59
+ using Minimal.AppServices.ManualSample.V1.Actions;
60
+ using Minimal.AppServices.ManualSample.V1.Queries;
61
+ using PurchaseOrderDto = Minimal.AppServices.ManualSample.V1.PurchaseOrderDto;
62
+
63
+ namespace Minimal.Api.ApiEndpoints.ManualSample;
64
+
65
+ internal sealed class PurchaseOrderV1Endpoint : IEndpointConfig
66
+ {
67
+ public int Version => 1;
68
+ public string GroupEndpoint => "/purchase-orders";
69
+
70
+ public void Map(RouteGroupBuilder group)
71
+ {
72
+ group.MapPost("/", async (CreatePurchaseOrderRequest req, IMessageBus bus, CancellationToken ct) =>
73
+ {
74
+ var result = await bus.Send(req, cancellationToken: ct);
75
+ return result.Response(isCreated: true);
76
+ })
77
+ .RequiredIdempotentKey()
78
+ .Produces<PurchaseOrderDto>(StatusCodes.Status201Created)
79
+ .WithDescription(
80
+ "Create purchase order. <br/><br/> Note: Idempotency key is required in the header. <br/>" +
81
+ "X-Idempotency-Key: {IdempotencyKey} <br/>");
82
+
83
+ group.MapGet("/", async ([AsParameters] ListPurchaseOrdersQuery query, IMessageBus bus, CancellationToken ct) =>
84
+ Results.Ok(await bus.Send(query, cancellationToken: ct)))
85
+ .WithDescription("Get purchase orders (paged, optionally filtered by customer name).");
86
+
87
+ group.MapGet("{id:guid}", async (Guid id, IMessageBus bus, CancellationToken ct) =>
88
+ {
89
+ var dto = await bus.Send(new GetPurchaseOrderByIdQuery { Id = id }, cancellationToken: ct);
90
+ return dto is null ? Results.NotFound() : Results.Ok(dto);
91
+ })
92
+ .Produces<PurchaseOrderDto>()
93
+ .Produces(StatusCodes.Status404NotFound)
94
+ .WithDescription("Get purchase order by id");
95
+
96
+ group.MapPut("{id:guid}", async (Guid id, UpdatePurchaseOrderRequest req, IMessageBus bus, CancellationToken ct) =>
97
+ {
98
+ var result = await bus.Send(req with { Id = id }, cancellationToken: ct);
99
+ return result.Response();
100
+ })
101
+ .WithDescription("Update purchase order amount");
102
+
103
+ group.MapPost("{id:guid}/cancel", async ([AsParameters] CancelPurchaseOrderRequest req, IMessageBus bus, CancellationToken ct) =>
104
+ {
105
+ var result = await bus.Send(req, cancellationToken: ct);
106
+ return result.Response();
107
+ })
108
+ .WithDescription("Cancel purchase order");
109
+
110
+ group.MapDelete("{id:guid}", async ([AsParameters] DeletePurchaseOrderRequest req, IMessageBus bus, CancellationToken ct) =>
111
+ {
112
+ var result = await bus.Send(req, cancellationToken: ct);
113
+ return result.Response();
114
+ })
115
+ .WithDescription("Delete purchase order");
116
+ }
117
+ }
118
+ ```
119
+
120
+ Rules this exemplar carries:
121
+
122
+ - **`IMessageBus.Send(...)`** dispatches every request/query straight to its handler; the delegate
123
+ never touches `CoreDbContext` or a repository.
124
+ - **`result.Response(isCreated: true)`** (`DKNet.AspCore.Extensions.Responses`) turns a
125
+ `FluentResults` result into `201`/`200`/an error response; plain `.Response()` picks `200`/`204`.
126
+ Don't hand-write the success/failure branching yourself.
127
+ - **`[AsParameters]`** binds a request from the route + query string as one object, and is the *only*
128
+ way a `[FromClaim]`-carrying request (`Cancel`, `Delete` above) gets populated — the population
129
+ filter only inspects an endpoint delegate's bound parameters. Constructing the same request type
130
+ inside the lambda body instead skips population silently.
131
+ - **`req with { Id = id }`** is how a hand-mapped `PUT {id}` merges a route-bound id into a request
132
+ record built from the body — the id is never part of the body's own DTO shape.
133
+ - **`Results.NotFound()` for a `null` query result** — `Get{Entity}ByIdQuery`'s handler returns
134
+ `TDto?`; a `null` means "not found" and the endpoint delegate turns that into the `404`, not the
135
+ handler.
136
+ - **`.RequiredIdempotentKey()`** (`DKNet.AspCore.Idempotency`) is opt-in per route — see
137
+ [Cross-cutting behavior](#5-cross-cutting-behavior).
138
+
139
+ ## 3. Option B — generated composite `Map<Entity>Crud()`
140
+
141
+ Mirror `ProductV1Endpoint` (`AutomatedSample/Product`) when the entity carries
142
+ `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` (see `/dknet-entity`/`/dknet-crud`). One call
143
+ registers the entire generated CRUD surface; hand-mapped routes for what the generator cannot express
144
+ go below it.
145
+
146
+ ```csharp
147
+ using Minimal.AppServices.AutomatedSample.V1;
148
+ using Minimal.AppServices.AutomatedSample.V1.Actions;
149
+ using Minimal.AppServices.AutomatedSample.V1.Queries;
150
+ using Minimal.AppServices.Crud;
151
+
152
+ namespace Minimal.Api.ApiEndpoints.AutomatedSample;
153
+
154
+ internal sealed class ProductV1Endpoint : IEndpointConfig
155
+ {
156
+ public int Version => 1;
157
+ public string GroupEndpoint => "/products";
158
+
159
+ public void Map(RouteGroupBuilder group)
160
+ {
161
+ var requireAuthorization = ((IEndpointRouteBuilder)group).ServiceProvider
162
+ .GetRequiredService<IOptions<FeatureOptions>>().Value.RequireAuthorization;
163
+
164
+ group.MapProductCrud(o =>
165
+ {
166
+ o.Exclude("Discontinue"); // dropped, hand-written below
167
+
168
+ if (!requireAuthorization) return;
169
+
170
+ o.Configure(CrudOp.GetById, rb => rb.RequireAuthorization(ProductScopes.Read));
171
+ o.Configure(CrudOp.GetList, rb => rb.RequireAuthorization(ProductScopes.Read));
172
+ o.Configure(CrudOp.Create, rb => rb.RequireAuthorization(ProductScopes.Write));
173
+ o.Configure(CrudOp.Update, rb => rb.RequireAuthorization(ProductScopes.Write));
174
+ o.Configure(CrudOp.Delete, rb => rb.RequireAuthorization(ProductScopes.Write));
175
+ o.Configure("Approve", rb => rb.RequireAuthorization(ProductScopes.Write));
176
+ o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
177
+ });
178
+
179
+ // Writes two aggregates (this product + its replacement) in one transaction — the generator
180
+ // cannot express that, so it is excluded by name above and hand-written here instead.
181
+ var discontinue = group.MapPut("{id:guid}/discontinue", async (Guid id, DiscontinueProductCommand req, IMessageBus bus, CancellationToken ct) =>
182
+ {
183
+ var result = await bus.Send(req with { Id = id }, cancellationToken: ct);
184
+ return result.Response();
185
+ })
186
+ .Produces<ProductDto>()
187
+ .WithDescription("Discontinue a product and create its named replacement in the same transaction.");
188
+ if (requireAuthorization) discontinue.RequireAuthorization(ProductScopes.Discontinue);
189
+
190
+ // No generated shape produces an aggregate summary — hand-written.
191
+ var summary = group.MapGet("summary", async (IMessageBus bus, CancellationToken ct) =>
192
+ Results.Ok(await bus.Send(new ProductPriceSummaryQuery(), cancellationToken: ct)))
193
+ .Produces<ProductPriceSummaryDto>()
194
+ .WithDescription("Product count and average price across every product the caller can see.");
195
+ if (requireAuthorization) summary.RequireAuthorization(ProductScopes.Read);
196
+ }
197
+ }
198
+ ```
199
+
200
+ `ProductScopes` (`Minimal.Api/ApiEndpoints/AutomatedSample/ProductScopes.cs`) is a plain
201
+ `internal static class` of policy-name constants — one per authorization scope the routes above
202
+ require, registered as authorization policies by `AddAuthConfig()` only when `RequireAuthorization`
203
+ is on:
204
+
205
+ ```csharp
206
+ internal static class ProductScopes
207
+ {
208
+ public const string Read = "products.read";
209
+ public const string Write = "products.write";
210
+ public const string Supplier = "products.supplier";
211
+ public const string Discontinue = "products.discontinue";
212
+ }
213
+ ```
214
+
215
+ ### What `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` produce, per attribute
216
+
217
+ | Entity declaration | Generated route |
218
+ |---|---|
219
+ | `[CrudCreate]` on a constructor | `POST /` → `201` + DTO |
220
+ | (always) | `GET {id}` → `200` + DTO, `404` if missing |
221
+ | (always) | `GET /` → paged, filterable, searchable list — see the `dknet-queries-specs` skill |
222
+ | `[CrudUpdate]` on a method | `PUT {id}` → `200` + DTO, one route per `[CrudUpdate]` method |
223
+ | (always, unless a request implements `IWithKey<TKey>` only) | `DELETE {id}` → `204`/`404` |
224
+ | `[CrudAction("segment", Verb = ...)]` on a method | `POST`\|`PUT`\|`PATCH {id}/segment` → `200` + DTO (verb `POST` by default; segment defaults to the method name kebab-cased) |
225
+
226
+ The generated extension carries a doc comment naming this exactly: *"Maps GET {id}, GET /, POST /,
227
+ PUT {id} (per update request), DELETE {id} and each generated domain-action endpoint"*.
228
+
229
+ ### Where the generated code lives
230
+
231
+ `DKNet.SlimBus.Generators` emits, per entity, under
232
+ `ApiEndpoints/Minimal.AppServices/obj/Generated/DKNet.SlimBus.Generators/.../` — not committed, so
233
+ build once (`dotnet build`) and read it there for the exact shape:
234
+
235
+ - `{Entity}CrudRequests.g.cs` — one `sealed partial record` per route (`Create{Entity}Request`,
236
+ `Change{Member}{Entity}Request`, `{Method}{Entity}Request` per action, `Delete{Entity}Request`),
237
+ with the declaring member's `DataAnnotations` attributes forwarded onto matching properties.
238
+ - `{Entity}CrudHandlers.g.cs` — one `internal sealed` handler per request, implementing
239
+ `Fluents.Requests.IHandler<TRequest, TDto>`. Every non-create handler loads the entity through a
240
+ private `{Entity}ByIdCrudSpec : Specification<TEntity>`, answers `NotFoundError` if missing, then
241
+ calls the entity method and returns `LazyMapper.LazyMapExtensions.ResultOf<TDto>(...)`.
242
+ - `{Entity}CrudEndpointExtensions.g.cs` — the `Map{Entity}Crud()` extension itself.
243
+
244
+ **A generated handler can be replaced**: write your own class implementing the same
245
+ `IHandler<TRequest, TDto>` for that request type and it takes over.
246
+
247
+ ### `CrudMapOptions` — excluding and configuring generated routes
248
+
249
+ ```csharp
250
+ group.Map{Entity}Crud(o => o
251
+ .Exclude(CrudOp.Delete) // a whole operation kind
252
+ .Exclude("Discontinue") // one route, by member name
253
+ .Configure(CrudOp.GetById, b => b.RequireAuthorization("products.read")) // every route of a kind
254
+ .Configure("ChangePrice", b => b.RequireAuthorization("products.write"))); // one named route
255
+ ```
256
+
257
+ - **`CrudOp`** has six members: `GetById`, `GetList`, `Create`, `Update`, `Delete`, `Action`.
258
+ - **`Exclude(params CrudOp[])`** drops every route of that kind. **`Exclude(params string[])`** drops
259
+ one route by name, leaving the entity's other routes of the same kind published.
260
+ - **`Configure(CrudOp, Action<RouteHandlerBuilder>)`** / **`Configure(string, Action<RouteHandlerBuilder>)`**
261
+ are additive: several calls for the same operation or name all run, in call order — operation-kind
262
+ settings before name settings, for any one route.
263
+ - **Route names** are `GetById`, `GetList`, `Create`, `Delete` for the four fixed operations, and the
264
+ verbatim C# member name for each `[CrudUpdate]`/`[CrudAction]` (`ChangePrice`, `Approve`,
265
+ `Discontinue`, `AssignSupplierReference` above) — never the kebab-cased URL segment, never the
266
+ generated request type's name.
267
+ - **A typo fails the build, not the request.** The generated extension calls
268
+ `options.ValidateRouteNames("{Entity}", "GetById", "GetList", ..., "ChangePrice", "Approve", ...)`
269
+ at start-up, throwing `ArgumentException` if an `Exclude`/`Configure` call names a route the entity
270
+ doesn't have — so an unprotected route from a misspelled scope name is caught at startup, not
271
+ shipped silently.
272
+ - Nothing is excluded by default.
273
+
274
+ ## 4. Option C — the package's generic helpers, called directly
275
+
276
+ `DKNet.AspCore.Extensions.Endpoints` is the library the generator's own `Map{Entity}Crud()` calls
277
+ into. You can call these same extensions yourself for an entity that has **no** `[CrudCreate]`/
278
+ `[CrudUpdate]` attributes — for example a read-mostly reference entity that only needs a list route
279
+ and a delete route, with everything else hand-written. This is exactly what the generated file calls;
280
+ copy the shape, not the generator:
281
+
282
+ ```csharp
283
+ group.MapGetById<TEntity, TKey, TDto>("{id:guid}"); // GET {id} -> 200/404 + TDto
284
+ group.MapGetList<TEntity, TKey, TDto>("/"); // GET / -> paged PagedResponse<TDto>
285
+ group.MapPost<TRequest, TDto>("/"); // POST / -> 201 if TRequest's name contains "Create", else 200
286
+ group.MapPutById<TRequest, TKey, TDto>("{id}"); // PUT {id} -> 200 + TDto
287
+ group.MapDeleteById<TEntity, TKey, TRequest>(); // DELETE {id} -> 204/404, TRequest carries validation-only key binding
288
+ group.MapActionById<TRequest, TKey, TDto>("{id}/x", "POST"); // any verb, body-bound beyond the key
289
+ group.MapParameterlessActionById<TRequest, TKey, TDto>("{id}/x", "PUT"); // any verb, no request body at all
290
+ ```
291
+
292
+ - **`IWithKey<TKey>`** is the interface `MapPutById`, the by-request-type `MapDeleteById`,
293
+ `MapActionById`, and `MapParameterlessActionById` all require on `TRequest` — it's how the route's
294
+ `{id}` gets bound into the request before dispatch, for a request the caller also validates.
295
+ - **`MapGetById`/`MapGetList`/the plain `MapDeleteById<TEntity,TKey>()`** work straight off the
296
+ entity/DTO pair with no request type at all — they build their own internal
297
+ `EntityByIdSpecification`/`EntityListSpecification` and dispatch through `IRepositorySpec`, not
298
+ `IMessageBus`.
299
+ - **`MapPost`/`MapPut`/`MapPatch`/`MapDelete`/`MapGetPage`** (the `Fluents*` family, not `*ById`)
300
+ dispatch a `Fluents.Requests`/`Fluents.Queries` type through `IMessageBus`, exactly like the
301
+ hand-mapped Option A calls — these are the underlying primitives `bus.Send(...)` wraps.
302
+ - **Error handling and response shaping are built in** — `ProducesCommons()` plus an endpoint filter
303
+ that converts a dispatch exception into the same unhandled-error body `AddErrorResponses(...)`
304
+ produces elsewhere. `FluentValidationConfig.cs`'s own comment notes the generated CRUD routes
305
+ already resolve `ErrorResponseOptions` via `[FromServices]` — calling these helpers directly gets
306
+ the same behavior.
307
+
308
+ Reach for Option C only when neither A nor B fits one particular entity — most features should be
309
+ entirely A or entirely B.
310
+
311
+ ## 5. Cross-cutting behavior
312
+
313
+ **Idempotency (Option A only).** POST is never idempotent automatically. `.RequiredIdempotentKey()`
314
+ (`DKNet.AspCore.Idempotency`) enforces the `X-Idempotency-Key` request header on the one route it's
315
+ called on; a replayed key returns the original response instead of creating a duplicate. The
316
+ generated `Create` route in Option B has no equivalent — a duplicate submit or client retry against
317
+ `POST /v1/products` creates two rows. To add it, exclude `"Create"` from `Map{Entity}Crud` and
318
+ hand-map that one route with `.RequiredIdempotentKey()`.
319
+
320
+ **FluentValidation runs on every group, generated routes included.** `Program.cs` attaches
321
+ `AddFluentValidationAutoValidation()` to every group, so an `AbstractValidator<T>` for a *generated*
322
+ request (`CreateProductRequestValidator`, `DeleteProductRequestValidator`) runs before the generated
323
+ handler, can read stored data through `IRepositorySpec`, and can refuse with `409` by tagging its
324
+ failure's `Code` with the `precondition.` prefix. Unrelated to the next point.
325
+
326
+ **DataAnnotations on a generated request are forwarded but not enforced.** A `[Range]`/`[Required]`
327
+ on a `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` parameter is copied onto the generated request's
328
+ matching property, but the .NET 10 minimal-API validation source generator only recognizes a literal
329
+ `Map*(string, Delegate)` call it can see in the *compiling project's own source*. Option B's and
330
+ Option C's calls route through this package's generic `Map*<TRequest,TDto>` wrapper, which the
331
+ generator can't see through — so a negative price on `POST /v1/products` returns `201`, not `400`.
332
+ Only Option A's literal `group.MapPost("/", async (CreateXRequest req, ...) => ...)` gets the
333
+ attribute enforced. If a create/update rule must be enforced, either hand-map that one route (Option
334
+ A) or write it as a FluentValidation validator instead of a DataAnnotations attribute.
335
+
336
+ **Authorization scopes are conditional on the flag, always.** `RequireAuthorization(...)` needs
337
+ authorization middleware to evaluate — that middleware is only added when `FeatureManagement:
338
+ RequireAuthorization` is `true`. Calling `.RequireAuthorization(scope)` unconditionally would throw at
339
+ request time with the flag off (local development, both test suites). Every scope call in
340
+ `ProductV1Endpoint` is therefore gated behind reading the flag from `IOptions<FeatureOptions>`, as
341
+ shown in §3 — copy that guard, don't call `RequireAuthorization` bare.
342
+
343
+ **Status-counts helper.** `group.MapGetStatusCounts<TEntity>("status", new StatusPropertyInfo(nameof(X.Status), typeof(XStatus)))`
344
+ is a template-local extension (not part of the published package) that groups an entity's rows by an
345
+ enum-backed status property, backfilling every enum member with a zero count. No shipped endpoint
346
+ config calls it today — wire it into your own `Map(RouteGroupBuilder)` the same way any other route is
347
+ mapped, if a status breakdown is useful for your entity. Full contract: the `dknet-queries-specs`
348
+ skill.
349
+
350
+ **When a route must be hand-written**, regardless of mode:
351
+ - The operation writes more than one aggregate in one transaction (`Discontinue` above: it also
352
+ creates a replacement `Product` row).
353
+ - The response is a shape the generator has none for (`summary` above: an aggregate, not a per-row
354
+ DTO).
355
+
356
+ **When a route must NOT be hand-written**, even though it feels like it should be:
357
+ - A refusing precondition on create/update/delete — write it as a FluentValidation validator against
358
+ the generated request (`CreateProductRequestValidator`, `DeleteProductRequestValidator`); it runs on
359
+ the generated route with no hand-written endpoint, request, or handler needed.
360
+ - A derived response value on a generated DTO — write it as a Mapster `IRegister`
361
+ (`ProductDto.GrossMargin` is `Price - SupplierCostPrice`, mapped this way); it reaches the generated
362
+ route's response with no endpoint change at all.
363
+
364
+ ## 6. Step-by-step
365
+
366
+ ### `mode=manual`
367
+
368
+ 1. Confirm the feature's `AppServices` layer exposes hand-written request/query records (see the
369
+ `dknet-crud` skill).
370
+ 2. Create `ApiEndpoints/Minimal.Api/ApiEndpoints/{Feature}/{Entity}V1Endpoint.cs`, `internal sealed`,
371
+ implementing `IEndpointConfig`.
372
+ 3. Map every route as a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call per §2, dispatching
373
+ through `IMessageBus`.
374
+ 4. Add `.RequiredIdempotentKey()` to the create route if a duplicate submit must not create two rows.
375
+ 5. Add `.WithDescription(...)`/`.Produces<T>(...)` to every route.
376
+
377
+ ### `mode=auto`
378
+
379
+ 1. Confirm the entity declares `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` (see `/dknet-entity`)
380
+ and its DTO is `[GenerateDto(typeof(Entity))]`.
381
+ 2. Build once so the generated `Map{Entity}Crud()` extension exists.
382
+ 3. Create `ApiEndpoints/Minimal.Api/ApiEndpoints/{Feature}/{Entity}V1Endpoint.cs`, calling
383
+ `group.Map{Entity}Crud()` — bare, or with an `Action<CrudMapOptions>` per §3 for per-route scopes
384
+ or exclusions.
385
+ 4. For anything the generator can't express, hand-map it below the composite call, dropping the
386
+ generated route it replaces via `o.Exclude(...)` when one exists.
387
+
388
+ ## Verification
389
+
390
+ ```bash
391
+ dotnet build -c Release
392
+ dotnet run --project ApiEndpoints/Minimal.Api
393
+ ```
394
+
395
+ Then exercise the route via `/docs` (Scalar, when `EnableSwagger` is on) or curl:
396
+
397
+ ```bash
398
+ curl -X POST https://localhost:5001/v1/{route} \
399
+ -H "Content-Type: application/json" -H "X-Idempotency-Key: $(uuidgen)" -d '{...}'
400
+ curl https://localhost:5001/v1/{route}/{id}
401
+ ```
402
+
403
+ ## Common mistakes
404
+
405
+ | What you might expect | What actually happens | Why |
406
+ |---|---|---|
407
+ | A `[Range]`/`[Required]` on a `[CrudCreate]`/`[CrudUpdate]` parameter is enforced | A generated route accepts the out-of-range value and returns `201`/`200` | The .NET validation source generator can't see through the package's generic `Map*<TRequest,TDto>` wrapper — see §5 |
408
+ | `.RequiredIdempotentKey()` works the same on a generated create route | There's no route to call it on unless you exclude `"Create"` and hand-map it | The generated extension's calls are compiler output, not source you can chain onto |
409
+ | `RequireAuthorization(scope)` is safe to call unconditionally | It throws at request time when `RequireAuthorization` is off | No authorization middleware is added unless the flag is on — gate every call on `IOptions<FeatureOptions>` |
410
+ | A `[CrudAction]` method parameter named `byUser` is the acting-user stamp | It becomes a caller-settable, body-bound `required string ByUser` on the generated request | Generated requests carry no `[FromClaim]`; the automated sample's acting-user attribution goes through `DataOwnerHook`/`AddCurrentUserProvider` instead — never name a generated action parameter after the acting user |
411
+ | Excluding a route by a typo'd name is silently ignored | The host throws `ArgumentException` at start-up | `ValidateRouteNames` checks every `Exclude`/`Configure` name against the entity's real route names before the app can serve traffic |
412
+ | A hand-mapped endpoint can call the generic `MapPost<TRequest,TDto>` helper directly for convenience | It works, but bypasses the DataAnnotations-enforcement your Option A route would otherwise get | Only a literal `group.MapPost("/", async (...) => ...)` — not a call to the generic wrapper — is visible to the validation source generator |
413
+
414
+ ---
415
+
416
+ # Workflow: `/dknet-endpoint`
417
+
418
+ The procedure an agent follows when invoked with arguments. The reference sections above are the rules it applies.
419
+
420
+ You are wiring the **Api** layer for a feature whose AppServices CRUD already exists. Run `/dknet-crud` first if not.
421
+
422
+ ### Inputs
423
+
424
+ `$ARGUMENTS` — feature folder, entity, optional `mode=manual|auto`, optional kebab-case route prefix
425
+ (defaults to entity plural lowercased), optional version.
426
+
427
+ The mode must match the one `/dknet-crud` ran in. `mode=manual` uses the hand-mapped steps below;
428
+ `mode=auto` skips straight to **Alternative: generated CRUD route** at the bottom — its single
429
+ `Map<Entity>Crud()` call replaces every step in the Steps section, and `.RequiredIdempotentKey()` does
430
+ not apply to it. If `mode=` was not supplied, detect it: a `[CrudCreate]` on the entity means `auto`.
431
+
432
+ ### Required reading
433
+
434
+ 1. The reference sections above
435
+ 2. `ApiEndpoints/Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs` (exemplar — every route is a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call against the raw minimal-API surface, base route `/v1/purchase-orders`)
436
+ 3. The `dknet-feature-lifecycle` skill §1 — read the endpoint-registration and request-idempotency rows before choosing a mapping style
437
+
438
+ ### Steps (`mode=manual`)
439
+
440
+ 1. Use the `dknet-implementer` subagent to execute Step 6 of the implementer protocol:
441
+ - Create `<Feature>V<N>Endpoint : IEndpointConfig` with `Version` and `GroupEndpoint`.
442
+ - Map each route with a literal `group.MapPost(...)`/`MapGet(...)`/`MapPut(...)`/`MapDelete(...)` call, mirroring `PurchaseOrderV1Endpoint` (create, list, get-by-id, update, and any business action route such as `cancel`, plus delete).
443
+ - Call `.RequiredIdempotentKey()` on the `MapPost` chain that creates the resource — clients then send `X-Idempotency-Key: {Guid}`; a replayed key returns the original response instead of creating a duplicate.
444
+ - Add `.WithDescription(...)` on each route.
445
+ 2. Build the solution and confirm Scalar/OpenAPI lists the new endpoints (run the API briefly if practical).
446
+ 3. Report the mode used, files added, and the next command (`/dknet-unit-tests <Feature> <Entity> mode=<mode>` then `/dknet-bdd-tests <Feature>`).
447
+
448
+ ### Constraints
449
+
450
+ - Endpoint class MUST be `internal sealed` and implement `IEndpointConfig` — in **both** modes.
451
+ - The idempotency constraint below applies to `mode=manual` only; see the Alternative section for `auto`.
452
+ - Do NOT register the endpoint manually — `EndpointConfig.CreateGroup` discovers it.
453
+ - Do NOT add controllers or attribute routing — this is Minimal API only.
454
+ - `.RequiredIdempotentKey()` is required on the create route; without it, duplicate `X-Idempotency-Key` retries will not be deduped.
455
+
456
+ ### Alternative: generated CRUD route
457
+
458
+ If the entity is plain CRUD with `[CrudCreate]`/`[CrudUpdate]`/`[GenerateDto]` already in place (see `Product`), skip hand-mapping entirely — the generator emits a `Map<Entity>Crud()` extension (namespace `Minimal.AppServices.Crud`) that wires GetById/GetList/Create/Update/Delete in one call. `ProductV1Endpoint` is the exemplar: `group.MapProductCrud(o => …)` carrying the per-route scopes and one `Exclude("Discontinue")`, plus a `.WithDescription`, with the two routes the generator cannot express hand-mapped below it. This path does **not** get `.RequiredIdempotentKey()` and its DataAnnotations validation is not enforced (the .NET 10 validation source generator can't see through the generic `Map*<TRequest,TDto>` wrapper the generated route uses) — confirmed live: `POST /v1/products` with a negative price returns `201`. Only use this path when idempotency and enforced validation are not required.