Skip to content

Commit c288d2f

Browse files
authored
Ensure UnauthorizedError maps to 403 status code and update README. (#8)
1 parent a19a67c commit c288d2f

File tree

3 files changed

+115
-20
lines changed

3 files changed

+115
-20
lines changed

README.md

Lines changed: 103 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,128 @@
77

88
Conventions useful for creating an ASP.NET Core based REST API on top of a domain model.
99

10-
## Exception Filters
10+
## `Result` Extensions
1111

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`.
1315

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
1517

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`.
1919

20-
#### Usage
20+
| Result | IActionResult | HTTP Status |
21+
| ---------------- | --------------------- | ------------- |
22+
| `Success<TData>` | `ActionResult<TData>` | 200 Ok |
23+
| `Success<Unit>` | `NoContentResult` | 204 NoContent |
2124

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.
2327

2428
```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+
}
2639
```
2740

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:
2969

3070
```csharp
31-
internal static class MvcConfigurer
71+
public class PaymentRequired : Error
3272
{
33-
public static void ConfigureMvc(this MvcOptions options)
73+
public PaymentRequired(string detail)
74+
: base("Payment Required", detail)
3475
{
35-
...
36-
options.Filters.Add(new DomainExceptionFilter());
3776
}
3877
}
3978
```
4079

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.
42105

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:
44109

45110
```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+
}
47128
```
48129

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.

src/Winton.DomainModelling.AspNetCore/ErrorExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ int GetStatusCode()
2525
switch (error)
2626
{
2727
case UnauthorizedError _:
28-
return StatusCodes.Status401Unauthorized;
28+
return StatusCodes.Status403Forbidden;
2929
case NotFoundError _:
3030
return StatusCodes.Status404NotFound;
3131
default:

test/Winton.DomainModelling.AspNetCore.Tests/ErrorExtensionsTests.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ public sealed class ToActionResult : ErrorExtensionsTests
1818
new object[]
1919
{
2020
new UnauthorizedError("Access denied"),
21-
new UnauthorizedResult()
21+
new ObjectResult(
22+
new ProblemDetails
23+
{
24+
Detail = "Access denied",
25+
Status = 403,
26+
Title = "Unauthorized",
27+
Type = "https://httpstatuses.com/403"
28+
})
29+
{
30+
StatusCode = StatusCodes.Status403Forbidden
31+
}
2232
},
2333
new object[]
2434
{

0 commit comments

Comments
 (0)