View on GitHub
Clean Architecture Toolkit for .NET
Clean Architecture Toolkit is a clean, modular architecture foundation for .NET applications.
It embraces SOLID principles and focuses on maintainability, testability, and scalability through clear boundaries and pluggable design.

|   HOME   |   MODULE OVERVIEW   |   DEMO   |

Cat.Minimal.Api

Cat.Minimal.Api turns service requests into ASP.NET Core minimal API endpoints. You map a request type to a route with one line per http method. The endpoint runs the request through the service request processor and writes the result.

You decide which response types your handler returns. The OpenAPI description lists exactly those status codes, so the consumers of your api see what the endpoint really does. A failed response becomes a problem details object with the CAT errors, so clients always get one consistent error shape. Unexpected exceptions and requests that can’t be bound are reported the same way.

Table of contents

NuGet packages

NuGet Version

Package Role Install Downloads
Cat.Minimal.Api Implementation dotnet add package gmarginet.Cat.Minimal.Api NuGet Downloads

Namespaces

Namespace What it holds
Cat.Minimal.Api Maps service requests to minimal API endpoints and reports failures as problem details.

Dependencies

Module Why Cat.Minimal.Api needs it
Cat.Responses Endpoints turn responses into results and problem details, and list the response types in the OpenAPI description.
Cat.ServiceRequests Endpoints run requests through the service request processor. The patch endpoint uses its PatchDocumentRequest.

Usage

Register the module

Call AddCatMinimalApiModule to register the exception handler and the problem details services. Call UseCatMinimalApiModule early in the pipeline, before you map the endpoints.

The endpoints run through ICatServiceRequestProcessor, so register the Cat.ServiceRequests module too.

using Cat.Minimal.Api.Extensions;
using Cat.ServiceRequests.Extensions;

builder.Services.AddCatMinimalApiModule();
builder.Services.AddCatServiceRequestsModule(typeof(TodoGetRequestHandler));

var app = builder.Build();

app.UseCatMinimalApiModule();

What the two calls set up:

Map a service request

Call one of the MapCat methods on the app or on a route group. Pass the request type and a route pattern. Each call maps one http method.

Method Http method The request implements Default response type
MapCatGet<TRequest, TResponse> GET ICatServiceRequest<TResponse> Ok
MapCatGetList<TRequest, TResponse> GET ICatServiceRequest<IList<TResponse>> Ok
MapCatGetPagedList<TRequest, TResponse> GET ICatServiceRequest<ICatPagedList<TResponse>> Ok
MapCatPost<TRequest> POST ICatServiceRequest Created
MapCatPost<TRequest, TResponse> POST ICatServiceRequest<TResponse> Created
MapCatPut<TRequest> PUT ICatServiceRequest NoContent
MapCatPatch<TRequest> PATCH ICatServiceRequest NoContent
MapCatPatchDocument<TRouteParameters, TModel> PATCH None, see Patch with a JSON Patch document NoContent
MapCatDelete<TRequest> DELETE ICatServiceRequest NoContent

Every method returns the RouteHandlerBuilder, so you can chain WithName, RequireAuthorization and the other endpoint methods.

using Cat.Minimal.Api;
using Cat.Responses.Abstractions;

var group = app.MapGroup("/todo");

group.MapCatGetList<TodoGetListRequest, TodoModel>("/");
group.MapCatGet<TodoGetRequest, TodoModel>("/{todoId:int}", CatResponseType.Ok, CatResponseType.NotFound)
    .WithName("GetTodo");
group.MapCatPost<TodoSaveRequest>("/", CatResponseType.Ok);

The endpoint reads the request like any minimal API handler with [AsParameters]. A property binds from the route, the query string, the headers or the body.

public record TodoGetRequest(int? TodoId) : ICatServiceRequest<TodoModel>;

public record TodoSaveRequest(TodoModel? Todo) : ICatServiceRequest;

The body holds the TodoModel itself. It doesn’t wrap it in the request:

POST /todo/ HTTP/1.1
Content-Type: application/json

{"id":1,"categoryId":1,"title":"Todo 1","description":"Description for todo 1."}

Choose the response types

Pass the response types your handler returns as the last arguments. The endpoint documents them and allows them at runtime.

Leave them out and the endpoint uses the default of its http method:

Http method Default
GET Ok
POST Created
PUT, PATCH and DELETE NoContent

Your list replaces the default. List Ok again when you still return it.

Some status codes can always happen, so every endpoint allows and documents them, whatever you pass:

These are the response types you can pass:

Response type HTTP status
Ok 200
Created 201
NoContent 204
BadRequest 400
Unauthorized 401
Forbidden 403
NotFound 404
Conflict 409
InternalServerError 500

The endpoint checks the response type of the handler at runtime. A handler that returns a type the endpoint doesn’t list causes a 500. In the Development environment the detail names the type to add:

The ServiceRequestHandler that handled the TodoGetRequest request returned CatResponseType Conflict.
This GET endpoint only allows Ok, NotFound, BadRequest, InternalServerError.
Add Conflict to the responseTypes argument where the endpoint is mapped.

That way the OpenAPI description and the real behavior can’t drift apart unnoticed.

Return the created value with a location

MapCatPost<TRequest, TResponse> returns the created value in the body of a Created response. Pass a location to add the Location header. It builds the header from the created value.

group.MapCatPost<TodoCreateRequest, TodoModel>("/", todo => $"/todo/{todo.Id}", CatResponseType.Created, CatResponseType.Conflict);

Without a location, or when it returns null, the response has no Location header.

Patch with a JSON Patch document

MapCatPatchDocument accepts a JSON Patch document (application/json-patch+json). Give it a type for the route values and the model the patch applies to.

public record TodoRouteParameters(int TodoId);

group.MapCatPatchDocument<TodoRouteParameters, TodoModel>("/{todoId:int}");

The handler receives a PatchDocumentRequest<TRouteParameters, TModel> from the Cat.ServiceRequests.Minimal.Api package. See Cat.ServiceRequests for the handler.

PATCH /todo/1 HTTP/1.1
Content-Type: application/json-patch+json

[{ "op": "replace", "path": "/title", "value": "New title" }]

A request without a patch document in its body gets a 400. A request with another content type gets a 415.

The patch types come from Microsoft.AspNetCore.JsonPatch.SystemTextJson, which supports net10.0 and up.

What the endpoint returns

Response of the handler HTTP status Body
Ok with a value 200 The value as JSON.
Ok without a value 200 None.
Created with a value 201 The value as JSON. A Location header when you pass a location.
Created without a value 201 None.
NoContent 204 None.
BadRequest, like a failed validation 400 Problem details.
Unauthorized 401 Problem details.
Forbidden 403 Problem details.
NotFound 404 Problem details.
Conflict 409 Problem details.
InternalServerError 500 Problem details.
A type the endpoint doesn’t list 500 Problem details.

Use Unauthorized when the caller isn’t authenticated. Use Forbidden when the caller is authenticated but isn’t allowed to do this.

The OpenAPI description

Each endpoint lists the response types you passed, plus 400 and 500, plus 415 when it reads a body. It lists nothing else.

For the endpoints in the example above, the description lists:

Endpoint Documented status codes
GET /todo 200, 400, 500
GET /todo/{todoId} 200, 404, 400, 500
POST /todo 200, 400, 500, 415

A consumer of your api reads the status codes the handler really uses, not a fixed list of everything the module supports.

The problem details body

A failed response is written as CatProblemDetails. It is a standard problem details object with two extra properties:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "The requested resource was not found.",
  "status": 404,
  "detail": "Look at the CatErrors property for more details.",
  "instance": "/todo/99999",
  "catErrors": [
    {
      "code": "CAT-DEM-1000",
      "message": "{ResourceName} with id {id} could not be found.",
      "arguments": [ "Todo", 99999 ]
    }
  ],
  "catTraceId": "00-4830c1162ed931d8ea980122e08eb547-6e98ab8852f34d7e-00"
}

The message is a template. Fill its placeholders with the arguments, in order. The first argument is the field name for validation errors.

Errors and exceptions

Every failure reaches the client as a problem details body with the catErrors and catTraceId properties. These are the kinds you can get:

Kind Status title detail catErrors
A failed BadRequest response 400 The request isn't valid. Look at the CatErrors property for more details. The errors of the response.
A failed Unauthorized response 401 Authentication is required for this request. Look at the CatErrors property for more details. The errors of the response.
A failed Forbidden response 403 The caller isn't allowed to perform this request. Look at the CatErrors property for more details. The errors of the response.
A failed NotFound response 404 The requested resource was not found. Look at the CatErrors property for more details. The errors of the response.
A failed Conflict response 409 The request conflicts with the current state of the resource. Look at the CatErrors property for more details. The errors of the response.
A failed InternalServerError response 500 An unexpected error occurred. Look at the CatErrors property for more details. The errors of the response.
A request that can’t be bound, like malformed JSON 400 The request isn't valid. The reason from ASP.NET Core. An empty list.
A patch endpoint without a patch document 400 The request isn't valid. The request has no patch document in its body. An empty list.
A content type the endpoint doesn’t read 415 Unsupported Media Type None. An empty list.
An unknown route or a method that isn’t mapped 404 or 405 The standard title of the status. None. An empty list.
A response type the endpoint doesn’t list 500 An unexpected error occurred. Depends on the environment, see below. An empty list.
An unexpected exception 500 An unexpected error occurred. Depends on the environment, see below. An empty list.

The failed responses you can get depend on the handler and its decorators. A request that fails validation gives the rule errors of Cat.Validators in catErrors, with CAT-VAL- codes. A handler that returns NotFound gives the code and message it chose, like CAT-DEM-1000 in the demo.

A client can branch on the status first, then on the code of each error:

var response = await httpClient.GetAsync("/todo/1");

if (response.IsSuccessStatusCode is false)
{
    var problem = await response.Content.ReadFromJsonAsync<CatProblemDetails>();

    foreach (var error in problem!.CatErrors ?? [])
    {
        Console.WriteLine($"{error.Code}: {error.Message}");
    }
}

Other things to know:

Unexpected exceptions

When a handler throws, or returns a response type the endpoint doesn’t list, the exception handler logs it and answers with a 500 CatProblemDetails:

What the client sees about the exception depends on the environment, so internal details stay private:

Environment detail
Development The exception type name and message, like InvalidOperationException: secret detail.
Any other Use the CatTraceId to find the error in the logs.

Expected failures, like a missing resource or a failed validation, should come back as failed responses, not exceptions. They give the client a useful status and error codes.