DKNet.Templates

Automated Products — Endpoints

Part of Automated Products.

Base path /v1/products. Seven routes come from MapProductCrud(o => …); PUT /{id}/discontinue and GET /summary are hand-written below that call in ProductV1Endpoint. A scope in the table below is enforced only when FeatureManagement:RequireAuthorization is on — a stock Development run (RequireAuthorization: false) enforces none of it, though the built-in demonstration authenticator still attaches an identity with no role claims to every request (see ❓ Open questions on index.md).

🔌 Endpoints

Verb Path Purpose Auth
POST / Create a product products.write
GET / List products products.read
GET /{id} Get a product by id products.read
PUT /{id} Change price products.write
DELETE /{id} Delete a product products.write
POST /{id}/approval Approve a product products.write
PUT /{id}/supplier-reference Assign the supplier reference products.supplier
PUT /{id}/discontinue Discontinue and replace products.discontinue
GET /summary Product count and average price products.read

POST /

Creates a product. Generated from the [CrudCreate] constructor on Product.

Express a rule you need enforced as a validator, not as an attribute — the lesson both this route’s gaps and the two working preconditions below illustrate together.

GET /

Generic list (DKNet.AspCore.Extensions’s MapGetList) over ProductDto, with the uniform filter/search/orderBy/desc/pageNumber/pageSize contract. pageSize defaults to 1000 with a configurable ceiling of 1000 (confirmed live: an empty query returns "pageSize":1000). fromDate/toDate last-activity bounds which, left out, window an audited listing to the last three months — Product is audited. Full contract: Generic List Endpoint.

GET /{id}

Generic MapGetById.

PUT /{id}

Changes Price. Generated from Product.ChangePrice(decimal). Raises ProductPriceUpdatedEvent on save (see Integration).

DELETE /{id}

Generic MapDeleteById, bound to the generated DeleteProductRequest.

POST /{id}/approval

Domain action — [CrudAction("approval")] Approve(string byUser). Stamps UpdatedBy with the caller-supplied byUser.

PUT /{id}/supplier-reference

Domain action overriding both segment and verb — [CrudAction("supplier-reference", Verb = CrudActionVerb.Put)] AssignSupplierReference(string). Assigns the [SensitiveData] property after creation; it is not a create-request field.

PUT /{id}/discontinue

Hand-written. Excluded from the generated map by name (o.Exclude("Discontinue")) because discontinuing a product also creates its named replacement in the same transaction, and an operation that writes more than one aggregate cannot be generated. Raises ProductIsDiscontinuedUpdatedEvent on save for the discontinued product, and ProductCreatedEvent for its replacement (see Integration).

A caller discontinues a product; the handler loads it, discontinues it and inserts its replacement in one SaveChangesAsync call, with the already-discontinued and unknown-id refusals short-circuiting before any write.

This diagram cannot show the 500 a colliding replacement name produces — that path exists (confirmed live) but has no handler-level catch, so it is not a modeled branch of this request. See Domain actions with [CrudAction] for how this route would have looked had the generator been able to express it.

GET /summary

Hand-written — no generated shape exists for an aggregate query. { productCount, averagePrice } across every product the caller can see (discontinued included) — the handler’s own doc comment says “every product the caller can see,” which, because Product implements IOwnedBy and the global ownership filter scopes every read to the caller’s own rows, means every product that caller owns.

Sensitive data: what each caller sees

Product declares [SensitiveData("pricing")] SupplierCostPrice and [SensitiveData] SupplierReferenceCode; ProductDto.GrossMargin carries [SensitiveData("pricing")] too (see below). Minimal.Api opts the response serializer into role-aware filtering once at start-up — .AddSensitiveDataPrincipalAccessor() registers ISensitiveDataPrincipalAccessor, and op.SerializerOptions.UseRoleAwareSensitiveData(...) in ServiceConfigs wires it into JSON serialization — a caller in the pricing role gets supplierCostPrice/grossMargin; any other authenticated caller does not; an unauthenticated caller gets none of the three properties. Absent means the key is missing from the JSON object entirely, not null and not masked. Full decision rules: api-pipeline.md; declaring one of your own: crud-attributes.md.

Caller supplierReferenceCode supplierCostPrice / grossMargin
Unauthenticated absent absent
Authenticated, without the pricing role — what this template’s own Development run gives you, since its built-in demonstration authenticator issues every caller an identity with no role claims present absent
Authenticated, in the pricing role present present (grossMargin holds an explicit JSON null if supplierCostPrice was never disclosed)

Confirmed by ProductSensitiveDataDemoAuthenticatedTests (row 2) and ProductGrossMarginTests (row 3, including the explicit-null case). The opt-in is one serializer setting for the whole service, but it only acts on declared properties — the hand-written purchase-order sample declares nothing sensitive, so its responses are byte-for-byte unchanged by it. Reaching row 3 outside a test means wiring a real identity provider, which this template deliberately ships only as a placeholder — see template-usage.md.

A response value the convention cannot produce

ProductDto carries one property not mirrored from a Product column: GrossMargin (Price minus SupplierCostPrice). The DTO generator’s name-matching convention only mirrors an entity property under the same name, so a value derived from two columns is outside what it can emit. Three pieces put it on every response, and none of them is a route:

Piece What it does
The property [SensitiveData("pricing")] decimal? GrossMargin, hand-written into the generated partial record ProductDto.
The mapping A Mapster IRegister (ProductMappingRegister): config.ForType<Product, ProductDto>().Map(d => d.GrossMargin, s => s.Price - s.SupplierCostPrice). ForType merges onto the convention’s existing config; NewConfig would have replaced it, taking every convention-mapped property with it.
The wiring MapsToExtensions scans the assembly for every IRegister after the convention loop runs, so the merge survives with no per-DTO wiring.

Every other value on the response still comes from the convention untouched; only this one property is hand-mapped. Two structure tests pin that no route, request or handler was written or dropped for it: ProductV1Endpoint contains no reference to GrossMargin, and no *GrossMargin*Request/*Handler type exists anywhere in the assembly (ProductGrossMarginStructureTests). Needing a response the convention cannot produce is not a reason to hand-write the route.

Ordering or filtering by grossMargin answers 400 by construction, not by luck: the query surface requires the field on the stored record as well as the response, and GrossMargin exists only on the response (confirmed live, see GET / above). The rest of the query surface is unaffected — ?filter=price:GreaterThan:100 and ?search= behave as usual (ProductGrossMarginTests), with the margin reported on each surviving item for a caller in the pricing role; any other caller still gets no grossMargin key at all on those same items.

To add a derived value of your own: write the property on the DTO’s partial record, add an IRegister beside it that ForType-maps it, and stop — the scan wires it. Keep the mapping a plain property expression so it stays EF-translatable where the list route projects it over IQueryable<Product>.

⚠️ Errors & limits

Every error on this feature’s routes uses the service’s standard problem body — traceId plus, for a precondition, a machine-readable code starting with precondition. (the one signal the shared FluentValidationConfig error-response setting uses to tell a refused precondition 409 from ordinary refused input 400). Three shapes appear: a 404 with an empty body on GET /{id} and DELETE /{id}, but a structured errors[] body with a message on the other write routes’ 404s; 400/409 problem+json with errors[] and (for preconditions) code; and the list route’s 400 in RFC 9110 shape with a plain detail string. A 201 response carries Location: / rather than the created resource’s URL.

Paging: pageSize defaults to and caps at 1000; see Generic List Endpoint for the full contract. Rate limiting and ordering/consistency are the whole application’s concerns, not this feature’s — see Configuration.

What this feature deliberately does not do: