Skip to main content

API Development

The Wayd API is an ASP.NET Core Web API using thin controllers that delegate to handlers via IDispatcher (backed by Wolverine).

Controller Pattern​

Controllers should be thin and contain no business logic. Inject IDispatcher and call Send. Controllers live in Wayd.Web.Api/Controllers/\{DomainArea\}/; from ProductsController:

[Route("api/product-management/products")]
[ApiVersionNeutral]
[ApiController]
[FeatureGate(FeatureFlags.Names.ProductManagement)]
[McpTools(McpToolset.Products, "wayd-products")]
public class ProductsController(IDispatcher dispatcher) : ControllerBase
{
private readonly IDispatcher _dispatcher = dispatcher;

[HttpGet("{idOrKey}")]
[MustHavePermission(ApplicationAction.View, ApplicationResource.Products)]
[EndpointSummary("Get one product in full …")]
[EndpointDescription("Accepts the product's UUID or its short key.")]
[McpTool("Products_GetProduct", "Get product")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetProduct(string idOrKey, CancellationToken cancellationToken)
{
var product = await _dispatcher.Send(new GetProductQuery(new IdOrKey(idOrKey)), cancellationToken);

return product is not null ? Ok(product) : NotFound();
}

[HttpPost]
[MustHavePermission(ApplicationAction.Create, ApplicationResource.Products)]
[EndpointSummary("Add a product to the catalog.")]
[McpTool("Products_Create", "Create a product", Destructive = false)]
[ApiConventionMethod(typeof(WaydApiConventions), nameof(WaydApiConventions.CreateReturn201IdAndKey))]
public async Task<ActionResult<ObjectIdAndKey>> Create(
[FromBody] CreateProductRequest request, CancellationToken cancellationToken)
{
var result = await _dispatcher.Send(request.ToCreateProductCommand(), cancellationToken);

return result.IsSuccess
? CreatedAtAction(nameof(GetProduct), new { idOrKey = result.Value.Id.ToString() }, result.Value)
: BadRequest(result.ToBadRequestObject(HttpContext));
}
}
  • A failed Result becomes a ProblemDetails body through ToBadRequestObject(HttpContext) (Extensions/ResultExtensions.cs), never a bare error string.
  • CreatedAtAction must supply every route parameter of the action it names, or the create succeeds and the caller gets a 500. CreatedAtActionRouteTests checks every call site by reflection.
  • Request and response models live in Wayd.Web.Api/Models/\{DomainArea\}/ and are named *Request / *Response; a request converts itself to its command (ToCreateProductCommand()). "Dto" means a type from an Application project's Dtos/ folder.
  • A route that takes a record's id or key binds it as string idOrKey and passes new IdOrKey(idOrKey); a value that is neither a Guid nor in the key's format names no record and answers 404 (IdOrKeyRouteTests).
  • [McpTool] and [McpTools] publish the action as a hosted MCP tool; see MCP Server.

Authentication and authorization​

Every controller requires an authenticated caller: controllers are mapped with RequireAuthorization() (Wayd.Infrastructure/ConfigureServices.cs), so an action needs no [Authorize] of its own. Use [AllowAnonymous] only for public endpoints.

Each action names the permission it needs with [MustHavePermission(ApplicationAction.X, ApplicationResource.Y)] (or [MustHaveAnyPermission]), which resolves to the Permissions.\{Resource\}.\{Action\} policy. A few areas add a check the attribute cannot make — PPM delivery leadership, sprint team membership, time away — and enforce it in the domain or the handler; see Permission-based vs membership-based authorization.

Feature-gated endpoints use [FeatureGate(FeatureFlags.Names.MyFlag)].

OpenAPI​

Microsoft.AspNetCore.OpenApi describes the API. A Debug build writes the document to Wayd.Web.Api/wwwroot/api/v1/specification.json (the GenerateOpenApiDocument target), then NSwag generates the TypeScript client, wayd-api.ts, from that file (nswag.json). The data-generation tool's C# client, the client's import templates and the @wayd/mcp import formats are generated from the same file, and the hosted MCP server builds its tools from the same document at runtime. The running API serves it at /openapi/v1.json, with the Scalar API reference at /scalar, while SwaggerSettings:Enable is on. A tool that knows neither path finds both through the RFC 9727 API catalog at /.well-known/api-catalog, and /openapi.json redirects to the document.

An action's [EndpointSummary] and [EndpointDescription] are its published summary and description, and the description of its MCP tool, so write them for an API consumer.

WaydOpenApiDocument registers the transformers in Wayd.Infrastructure/OpenApi/, which shape the document for the TypeScript client. The client code depends on what they decide, so a change to one renames or retypes generated code:

  • Operation ids are {Controller}_{Action}, and the client is generated with MultipleClientsFromOperationId, which names every client class and method from them.
  • Component names are the types' own: a generic type is named for its arguments (NavigationDtoOfGuidAndInteger), a derived class is allOf its base, and a [JsonPolymorphic] hierarchy is a base with a discriminator and its derived types under their own names.
  • A property is required when it cannot be null, or when it is declared required or [Required] even if it can, so the client types it as present; nullability comes from the declaration.
  • x-position and x-name set the order of a client method's arguments and the name of its body argument, and the client calls them positionally.
  • NodaTime values are strings: an Instant is date-time, a LocalDate date.
  • Request bodies are application/json, except file uploads (multipart/form-data) and JSON Patch bodies: a JsonPatchOperation[] sent as application/json-patch+json, the only type a JsonPatchDocument<T> action accepts unless its [Consumes] adds another.

After a change to a transformer or an API model, read the wayd-api.ts diff a rebuild produces, and commit it with specification.json: OpenApiDocumentTests fails while the committed document differs from the one the API serves.

NSwagTemplates/Interface.liquid overrides the TypeScript interface template so that a LocalDate (format: date) is generated as string while an Instant (format: date-time) stays Date — see Dates and instants. Keep it in step with the stock template when NSwag is upgraded; it is a copy of it with that one change.

The document target boots the real API

To read the OpenAPI document, the build starts the actual application. It sets WAYD_SKIP_DB_INIT=true, which HostIntrospection.SkipsDatabaseInitialization uses to skip every piece of startup work that touches the database — EF migrations and seeding, the Hangfire server, and the Hangfire dashboard. A Debug build therefore does not require a running database.

Anything added to startup that connects to the database must be gated the same way. Without the gate the failure is badly misleading: the connection error is reported as a build error against Wayd.Web.Api.csproj (Build FAILED), with the real cause buried in the tool's output. Under Aspire it surfaces only as The project could not be built. and exit code 6, before any resource starts.

CSV import endpoints​

An import endpoint binds only an uploaded file, so the shape of the CSV it reads never reaches the OpenAPI document by itself. Three attributes put it there, and the Settings → System → Imports dialog is built from what they publish — its column lists, accepted values and templates:

  • [CsvImport(XImportDefinition.ImportKey)] on the action names the import definition it submits to.
  • [CsvRows(typeof(ImportXRequest))] on each IFormFile parameter names the class that file is read into — the same type the action passes to ReadCsv. A secondary file also takes a Label ("Manifest").
  • [CsvValues(typeof(SomeEnum))] on a text column that parses into an enum publishes its accepted names.

CsvImportOperationTransformer registers each row class as a schema and stamps the operation with an x-wayd-import extension; each property carries x-csv-column, its exact header. A property's XML summary becomes the column's description in the dialog, so write one wherever the name and type don't say what the cell must hold.

The client's templates are generated from the committed document, not by the build. After changing a row class or adding an import, rebuild the API, then run npm run generate:import-templates in the React client — import-templates.generated.test.ts fails until you do. A new import also needs its submitter and cache tags in store/features/admin/imports-api.ts, and a new API module a group in csv-import-form.tsx; all three are keyed by generated types, so the compiler asks for them. ImportEndpointPermissionTests fails an import endpoint missing either attribute, or missing the submissionGroupId and validateOnly query parameters every import endpoint takes.

What a preflight asks of an import definition​

validateOnly=true records the run as a preflight, which RunImportProcessCommand drives through the definition's real passes inside IImportDbContext.BeginPreflight — a transaction rolled back at the end, with every raised event held back rather than delivered. Only the passes a later pass reads back are saved; the last pass is staged and discarded, so a single-pass import writes nothing and spends no identity values. A pass therefore has to keep to the constraints the runner already sets, because a preflight breaks silently where they are ignored:

  • Write through the context the pass was given. A pass that opens its own transaction, resolves a second context, or dispatches a command that saves in its own scope commits outside the rollback, and a preflight leaves that work behind.
  • Read earlier passes back from the database, never from entities an earlier pass left tracked. The runner clears the tracker between chunks, and in a preflight that is also what discards the last pass.
  • Report a rejection on the row. A preflight of an all-or-nothing import carries the accepted rows into the later passes after a rejection, which a real run never does, so a pass must leave what it staged for those rows consistent when it rejects another one.

ImportPreflightTests (Testcontainers) proves the rollback, the held-back events and the unspent identity values; a new kind of pass that writes differently needs its own case there.

API Conventions​

  • Use RESTful route patterns
  • Return appropriate HTTP status codes, and declare each with [ProducesResponseType] or a WaydApiConventions method so the OpenAPI document lists it
  • Use CancellationToken in all async endpoints
  • Validate input through FluentValidation, at two levels. A request model's IValidator<TRequest> in Wayd.Web.Api runs during model binding (FluentValidation's ASP.NET auto-validation), and a command's validator in its Application project runs in the Wolverine handler pipeline. Both failures answer 422 with validation problem details.
  • A list query ends its order on a unique key (Key or Id last). The hosted MCP server pages a list over several calls with an offset, so an order the database is free to vary repeats and skips records between pages.

Partial updates​

A record has one Update (or Set) command, and a full and a partial update share it. PUT sends the whole request to the command. PATCH takes a JsonPatchDocument<TRequest> (RFC 6902) over the same request type and returns this.Patch(...) from JsonPatchControllerExtensions: it loads the record with the existing Get query, converts it with the request's static FromDto, applies the patch, validates with the request's IValidator<TRequest> (a request with none throws, so register one), and sends the same command. It answers 204, 404 when the record is missing, 422 with every patch and validation error, and 400 when the domain refuses. Never write a separate merge command for a partial change.

FromDto is where the two can drift apart: a request property it leaves unset is cleared by every patch. Each patch endpoint has a test in Wayd.Web.Api.Tests/Sut/Models/ that converts a fully populated DTO and calls PatchRequestAssertions.ShouldSetEveryProperty, excluding (with a comment) only properties the record has no source for. A list in the request is a List<T>, so a patch can append with /listName/-. Take the record's ids from the route, not from the patched request. To offer the patch to agents, mark it [McpTool] with the full tool's name and its verb swapped for Patch (Projects_Update → Projects_Patch), and say in its description which paths exist.

A patch reads the record and then sends the whole request, with no concurrency check between the two, so two patches that append to the same list at once can lose one append. That is the same exposure as a full update from an edit form.

Closed sets and entities​

Before exposing a set of values, decide which kind it is. The question is whether code branches on particular values, not whether it is stored in an enum or a table today.

The setIn the domainIn the contract
Closed: code branches on specific values (lifecycle statuses, member roles, categories)an enumits stable code, e.g. "Active"
Open: users or admins create the values, and code treats them all alike (work types, tags, product types)an entityits id and name, with a lookup endpoint
Open, but behaviour depends on it (Product Management statuses)an entity carrying a closed categorythe entity's id, plus the category's code

A closed set travels as its code, never its number:

  • Type the action parameter or request property as the enum ([FromQuery] ProjectStatus[]? status), not int. The document then lists the accepted codes, an undefined value is rejected with a 400, and the binder still accepts the number from older callers.
  • A lookup endpoint for the set returns each value's Code beside its display name (CommonEnumDto<TEnum> does this), so a client builds its options from the server and never keeps its own id map.
  • The commitment is that the codes are stable, not that the set is a C# enum. If a closed set later gains user-defined members, it becomes an entity with the closed set as its category, and the codes stay valid.

OpenApiSpecificationTests fails when an enum is published by number or a status, state, role or category field is a bare integer. A field that only looks like one (ProblemDetails.status) goes in its allow-list.