|
7 | 7 |
|
8 | 8 | Conventions useful for creating an ASP.NET Core based REST API on top of a domain model. |
9 | 9 |
|
10 | | -## Exception Filters |
| 10 | +## `Result` Extensions |
11 | 11 |
|
12 | | -### DomainExceptionFilter |
| 12 | +This library provides a `ToActionResult` extension method for `Result<TData>` which converts it to an appropriate `IActionResult`. |
| 13 | +There are various overloads to provide flexibiility. |
| 14 | +It is expected that this will be used within an [`ApiController`](https://docs.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-2.2#annotation-with-apicontroller-attribute) so that ASP.NET Core will apply its REST API conventions to the `IActionResult`. |
13 | 15 |
|
14 | | -An [Exception Filter](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.filters.iexceptionfilter) for converting [supported Exceptions](https://github.com/wintoncode/Winton.DomainModelling.Abstractions#exceptions) to [IActionResult](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.iactionresult)s, automatically setting the type, status code, and message, as appropriate. The following conversions are performed by default: |
| 16 | +### Successful Result Mappings |
15 | 17 |
|
16 | | -* Base [DomainException](https://github.com/wintoncode/Winton.DomainModelling.Abstractions#domainexception) to [BadRequest](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.badrequestobjectresult) (HTTP 400) |
17 | | -* [UnauthorizedException](https://github.com/wintoncode/Winton.DomainModelling.Abstractions#unauthorizedexception) to [Unauthorized](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.unauthorizedresult) (HTTP 401) |
18 | | -* [EntityNotFoundException](https://github.com/wintoncode/Winton.DomainModelling.Abstractions#entitynotfoundexception) to [NotFound](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.notfoundobjectresult) (HTTP 404) |
| 18 | +The following default mappings happen when the `Result` is a `Success`. |
19 | 19 |
|
20 | | -#### Usage |
| 20 | +| Result | IActionResult | HTTP Status | |
| 21 | +| ---------------- | --------------------- | ------------- | |
| 22 | +| `Success<TData>` | `ActionResult<TData>` | 200 Ok | |
| 23 | +| `Success<Unit>` | `NoContentResult` | 204 NoContent | |
21 | 24 |
|
22 | | -The `DomainExceptionFilter` should be added to the collection of filters on the [MvcOptions](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.mvcoptions) configuration for your application. For example, if MVC Core is added to your service collection using a custom configurer |
| 25 | +The defaults can be overriden by calling the extension method that takes a success mapping function. |
| 26 | +A common example of when this is used is in a `POST` action when an entity has been created and we would like to return a 201 Created response to the client. |
23 | 27 |
|
24 | 28 | ```csharp |
25 | | -services.AddMvcCore(options => options.ConfigureMvc()) |
| 29 | +[HttpPost] |
| 30 | +public async Task<IActionResult> CreateFoo(NewFoo newFoo) |
| 31 | +{ |
| 32 | + return await CreateFoo(newFoo.Bar) |
| 33 | + .Select(FooResource.Create) |
| 34 | + .ToActionResult( |
| 35 | + f => Created( |
| 36 | + Url.Action(nameof(Get), new { f.Id }), |
| 37 | + f)); |
| 38 | +} |
26 | 39 | ``` |
27 | 40 |
|
28 | | -then simply add the `DomainExceptionFilter` to the collection of filters |
| 41 | +The `CreateFoo` method performs the domain logic to create a new `Foo` and returns `Result<Foo>`. |
| 42 | + |
| 43 | +*In a real application it would be defined in the domain model project. |
| 44 | +To give the domain model an API which is defined in terms of commands and queries and to decouple it from the outer application layers the mediator pattern is often adopted. |
| 45 | +Jimmy Bogard's [MediatR](https://github.com/jbogard/MediatR) is a useful library for implementing that pattern.* |
| 46 | + |
| 47 | +### Failure Result Mappings |
| 48 | + |
| 49 | +If the `Result` is a `Failure` then the `Error` it contains is mapped to a [`ProblemDetails`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails) and is wrapped in an `IActionResult` with the corresponding status code. |
| 50 | + |
| 51 | +The following table shows the default mappings. |
| 52 | + |
| 53 | +| Error | IActionResult | HTTP Status | |
| 54 | +| -------------------- | --------------------- | -------------- | |
| 55 | +| `Error`* | `BadRequestResult` | 400 BadRequest | |
| 56 | +| `UnauthorizedError` | `ForbidResult` | 403 Forbidden | |
| 57 | +| `NotFoundError` | `NoContentResult` | 404 NotFound | |
| 58 | + |
| 59 | +_*This includes any other types that inherit from `Error` and are not explicitly listed._ |
| 60 | + |
| 61 | +The defaults can be overriden by calling the extension method that takes an error mapping function. |
| 62 | +This is useful when the domain model has defined additional error types and these need to be converted to the relevant problem details. |
| 63 | +The status code that is set on the `ProblemDetails` will also be set on the `IActionResult` by the extension method so that the HTTP status code on the response is correct. |
| 64 | + |
| 65 | +For example consider a domain model that deals with payments. |
| 66 | +It could be a news service which requires a subscription to access content. |
| 67 | +It might contain several operations that require payment to be made before they can proceed. |
| 68 | +This domain may therefore define a new error type as follows: |
29 | 69 |
|
30 | 70 | ```csharp |
31 | | -internal static class MvcConfigurer |
| 71 | +public class PaymentRequired : Error |
32 | 72 | { |
33 | | - public static void ConfigureMvc(this MvcOptions options) |
| 73 | + public PaymentRequired(string detail) |
| 74 | + : base("Payment Required", detail) |
34 | 75 | { |
35 | | - ... |
36 | | - options.Filters.Add(new DomainExceptionFilter()); |
37 | 76 | } |
38 | 77 | } |
39 | 78 | ``` |
40 | 79 |
|
41 | | -#### Extensibility |
| 80 | +It would therefore make sense to map this to a `402 Payment Required` HTTP response with relevant `ProblemDetails`. |
| 81 | +This can be achieved like so: |
| 82 | + |
| 83 | +```csharp |
| 84 | +[HttpGet("{id}")] |
| 85 | +public async Task<IActionResult> GetNewsItem(string id) |
| 86 | +{ |
| 87 | + return await GetNewsItem(id) |
| 88 | + .ToActionResult( |
| 89 | + error => new ProblemDetails |
| 90 | + { |
| 91 | + Detail = error.Detail, |
| 92 | + Status = StatusCodes.Status402PaymentRequired, |
| 93 | + Title = error.Title, |
| 94 | + Type = "https://example.com/problems/payment-required" |
| 95 | + } |
| 96 | + ) |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +The type field should return a URI that resolves to human-readable documentation about the type of error that has occurred. |
| 101 | +This can either be existing documentation, such as https://httpstatuses.com for common errors, or your own documentation for domain-specific errors. |
| 102 | + |
| 103 | +Problem details is formally documented in [RFC 7807](https://tools.ietf.org/html/rfc7807). |
| 104 | +More information about how the fields should be used can be found there. |
42 | 105 |
|
43 | | -Since `DomainException` is extensible for any domain-specific error, `DomainExceptionFilter` can be extended to support custom Exception to Result mappings. Simply pass a `Func<DomainException, ErrorResponse, IActionResult>` to the constructor, such as |
| 106 | +In order to maintain a loose coupling between the API layer and the domain model each action method should know how to map any kind of domain error. |
| 107 | +To achieve this we could define a function that does this mapping for us and then use it throughout. |
| 108 | +For example: |
44 | 109 |
|
45 | 110 | ```csharp |
46 | | -new DomainExceptionFilter((exception, response) => exception is TeapotException ? new TeapotResult() : null) // 418 |
| 111 | +internal static ProblemDetails MapDomainErrors(Error error) |
| 112 | +{ |
| 113 | + switch (error) |
| 114 | + { |
| 115 | + case PaymentRequired _: |
| 116 | + return new ProblemDetails |
| 117 | + { |
| 118 | + Detail = error.Detail, |
| 119 | + Status = StatusCodes.Status402PaymentRequired, |
| 120 | + Title = error.Title, |
| 121 | + Type = "https://example.com/problems/payment-required" |
| 122 | + } |
| 123 | + // handle other custom types |
| 124 | + default: |
| 125 | + return null; |
| 126 | + } |
| 127 | +} |
47 | 128 | ``` |
48 | 129 |
|
49 | | -Note that all custom mappings are handled **after** `EntityNotFoundException`s and `UnauthorizedException`s. |
| 130 | +By using C# pattern matching we can easily match on the type of error and map it to a `ProblemDetails`. |
| 131 | +Returning `null` in the default case means the existing error mappings for the common error types, as defined above, are used. |
| 132 | + |
| 133 | +If you have a custom error type and you are happy for your REST API to return `400 Bad Request` when it occurs, then the default error mappings for the base `Error` type should already work for you. |
| 134 | +It maps the error's details and title to the corresponding fields on the problem details. |
0 commit comments