@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.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/agents/dknet-architect.md +49 -0
- package/agents/dknet-bdd-engineer.md +62 -0
- package/agents/dknet-implementer.md +75 -0
- package/package.json +52 -0
- package/plugin.json +26 -0
- package/skills/README.md +47 -0
- package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
- package/skills/dknet-bdd-tests/SKILL.md +355 -0
- package/skills/dknet-bdd-tests/checklist.md +39 -0
- package/skills/dknet-crud/SKILL.md +483 -0
- package/skills/dknet-ddd-principles/SKILL.md +87 -0
- package/skills/dknet-docs/SKILL.md +296 -0
- package/skills/dknet-docs/checklist.md +58 -0
- package/skills/dknet-docs/templates/README-template.md +68 -0
- package/skills/dknet-docs/templates/api-reference-template.md +275 -0
- package/skills/dknet-docs/templates/architecture-template.md +166 -0
- package/skills/dknet-docs/templates/data-model-template.md +99 -0
- package/skills/dknet-docs/templates/events-template.md +155 -0
- package/skills/dknet-dto-mapping/SKILL.md +278 -0
- package/skills/dknet-efcore-config/SKILL.md +379 -0
- package/skills/dknet-endpoint/SKILL.md +458 -0
- package/skills/dknet-entity/SKILL.md +483 -0
- package/skills/dknet-feature/SKILL.md +139 -0
- package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
- package/skills/dknet-feature-remove/SKILL.md +131 -0
- package/skills/dknet-messaging-events/SKILL.md +395 -0
- package/skills/dknet-package-adoption/SKILL.md +252 -0
- package/skills/dknet-platform-config/SKILL.md +342 -0
- package/skills/dknet-project-structure/SKILL.md +148 -0
- package/skills/dknet-queries-specs/SKILL.md +330 -0
- package/skills/dknet-scaffold/SKILL.md +209 -0
- 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.
|