An Exception Handler is also needed for this to work. You can read more within the follow-up post, ExceptionHandler Needed.
In .NET Core 2.2/3.0 the ASP.NET Team made a move towards a validation and error reporting standard called Problem Details (RFC 7807). I don’t know much about the history of the standard except for what’s listed on it’s description. It became a proposed standard in March 2016 (which means it was probably being developed for years before that), and it was sponsored by M. Nottingham (W3C Technical Architecture Group), Akamai (they’re pretty famous) and E. Wilde (Siemens).
This standardization also lines up with something David Fowler has been talking about for a little while (1, 2, 3), Distributed Systems Tracing. From an outsiders perspective it really feels like many of the teams at Microsoft are trying their best to get more observability, metrics, and tracing into their tooling. And Microsoft seems to be using the “extensions” described in the Problem Details RFC to add a new property called “traceId”. I think this property will line up with a larger effort by Microsoft to support OpenTelemetery (Microsoft reference) and potential improvements to Application Insights.
So … Microsoft has these great baseline ProblemDetail objects which help standardize the way 500 and 400 errors are returned from Web APIs. But, how can you extend upon their work to add some customizations that are particular to your needs?
Well, when you read the Microsoft Handle errors in ASP.NET Core web APIs documentation, you feel like it must be pretty easy because they say you just need to “Implement ProblemDetailsFactory”. But, that’s all the documentation does. It just “says” you should implement it, there is no example code to work from. The example that is given shows how to replace the default factory with your custom factory (which is a great example, Thank You!), but there’s no example given on what your factory could look like.
This leads to the question of “How does Microsoft do it?”. Well … they use an internal (non-public) DefaultProblemDetailsFactory.
It would be great if DefaultProblemDetailsFactory could be made public.
One of the striking features of that default implementation it never references System.Exception. It’s job is to translate an uncaught exception into a 500 Internal Server Error response object. But, it never uses an exception object in it’s code?
Maybe that’s because it does the translation earlier on the process, like in ProblemDetailsClientErrorFactory. I really don’t know how it all connects. The original developers are some pretty smart people to get it all working.
Anyways … for this example, I’m going to:
- Use the DefaultProblemDetailsFactory as the starting code to extend.
- Create a custom class which the Factory will look for in order to alter the returned ProblemDetails object.
- Use a Feature on the httpContext to pull in Exception information (I don’t know how else to get the exception object?)
- Use the ProblemDetailsFactoryTest class from Microsoft to help build a unit test.
- Update the unit test to inject the exception.
Let’s start with the custom class (YourBussException.cs) that will be used by our custom factory to extend the returned data. The class will:
- Use the underlying Exception’s Message property to fill in the “Detail” property of the ProblemDetail object.
- Add an ExtendedInfo object where your development teams can add extra information that can help inform API clients on how to resolve the issue.
using System; | |
using System.Diagnostics.CodeAnalysis; | |
namespace Your.Namespace | |
{ | |
public class YourBussException : Exception | |
{ | |
public string Type { get; } = "http://your.business.com/rfc7807/businessexception"; | |
public string Title { get; } = "A business validation exception occurred."; | |
public object ExtendedInfo { get; } // this will be used to add more info to the returned object | |
public YourBussException( | |
[NotNull] string message, | |
object extendedInfo = null, | |
string type = null, | |
string title = null) | |
: base(message) | |
{ | |
ExtendedInfo = extendedInfo ?? ExtInfo; | |
Type = type ?? Type; | |
Title = title ?? Title; | |
} | |
} | |
} |
Next we'll make some small updates to the factory in order to create one that will translate our exception into a 400 Bad Request response (YourBussProblemDetailsFactory.cs):
using System; | |
using System.Diagnostics; | |
using Microsoft.AspNetCore.Diagnostics; | |
using Microsoft.AspNetCore.Http; | |
using Microsoft.AspNetCore.Mvc; | |
using Microsoft.AspNetCore.Mvc.ModelBinding; | |
using Microsoft.Extensions.Options; | |
namespace Your.Namespace | |
{ | |
/// <summary> | |
/// Based on Microsoft's DefaultProblemDeatilsFactory | |
/// https://github.com/aspnet/AspNetCore/blob/2e4274cb67c049055e321c18cc9e64562da52dcf/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs | |
/// </summary> | |
public class YourBussProblemDetailsFactory : Microsoft.AspNetCore.Mvc.Infrastructure.ProblemDetailsFactory | |
{ | |
private readonly ApiBehaviorOptions _options; | |
/// <inheritdoc /> | |
public YourBussProblemDetailsFactory( | |
IOptions<ApiBehaviorOptions> options) | |
{ | |
_options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | |
} | |
/// <inheritdoc /> | |
public override ProblemDetails CreateProblemDetails( | |
HttpContext httpContext, | |
int? statusCode = null, | |
string title = null, | |
string type = null, | |
string detail = null, | |
string instance = null) | |
{ | |
statusCode ??= 500; // <-- Microsoft hard codes the value? Why aren't they using StatusCodes.Status500InternalServerError? | |
ProblemDetails problemDetails = null; | |
var context = httpContext.Features.Get<IExceptionHandlerFeature>(); | |
if (context?.Error != null) | |
{ | |
if (context.Error is YourBussException ybe) | |
{ | |
statusCode = 400; | |
httpContext.Response.StatusCode = statusCode.Value; // <-- The result serializer doesn't use the status from the | |
// ProblemDetails object to set this code. You have to set | |
// it by hand. | |
problemDetails = new ProblemDetails | |
{ | |
Status = statusCode, | |
Title = ybe.Title, | |
Type = ybe.Type, | |
Detail = ybe.Message, | |
Instance = instance, | |
Extensions = | |
{ | |
{ "extinfo", ybe.ExtendedInfo } | |
} | |
}; | |
} | |
} | |
if (problemDetails == null) | |
{ | |
// default exception handler | |
problemDetails = new ProblemDetails | |
{ | |
Status = statusCode, | |
Title = title, | |
Type = type, | |
Detail = detail, | |
Instance = instance, | |
}; | |
} | |
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value); | |
return problemDetails; | |
} | |
/// <inheritdoc /> | |
public override ValidationProblemDetails CreateValidationProblemDetails( | |
HttpContext httpContext, | |
ModelStateDictionary modelStateDictionary, | |
int? statusCode = null, | |
string title = null, | |
string type = null, | |
string detail = null, | |
string instance = null) | |
{ | |
if (modelStateDictionary == null) | |
{ | |
throw new ArgumentNullException(nameof(modelStateDictionary)); | |
} | |
statusCode ??= 400; | |
var problemDetails = new ValidationProblemDetails(modelStateDictionary) | |
{ | |
Status = statusCode, | |
Type = type, | |
Detail = detail, | |
Instance = instance, | |
}; | |
if (title != null) | |
{ | |
// For validation problem details, don't overwrite the default title with null. | |
problemDetails.Title = title; | |
} | |
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value); | |
return problemDetails; | |
} | |
private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode) | |
{ | |
//problemDetails.Status ??= statusCode; | |
problemDetails.Status = problemDetails.Status ?? statusCode; | |
if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData)) | |
{ | |
problemDetails.Title ??= clientErrorData.Title; | |
problemDetails.Type ??= clientErrorData.Link; | |
} | |
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier; | |
if (traceId != null) | |
{ | |
problemDetails.Extensions["traceId"] = traceId; | |
} | |
} | |
} | |
} |
Alternatively, you can use a Decorator Pattern to wrap the default InvalidModelStateFactory as described in AspNetCore.Docs/issue/12157, How to log automatic 400 responses on model validation errors, option #2 (using PostConfigure<>). The concern I have with this approach is that you are no longer using the Dependency Injection system to create your factory. You are hand creating an instance of the factory and that instance is no longer easily referenceable to any code that wants to interact with it. This also makes the code more brittle to changes and less testable.
Finally, we can use the example code from Microsoft Handle errors in ASP.NET Core web APIs documentation, to swap in our new YourBussProblemDetailsFactory (Startup.cs):
public void ConfigureServices(IServiceCollection serviceCollection) | |
{ | |
services.AddControllers(); | |
services.AddTransient<ProblemDetailsFactory, YourBussProblemDetailsFactory>(); | |
} |
Now, you should be able to throw your exception from anywhere in the code and have it translated back as a 400 error:
using System; | |
namespace Your.Namespace | |
{ | |
public class YourService | |
{ | |
private IYourDataRepository _yourDataRepository; | |
public YourService(IYourDataRespository yourDataRespository) | |
{ | |
_yourDataRepository = yourDataRespository; | |
} | |
public string GetSomeCalculatedValueFromObject(string id) | |
{ | |
var bussObj = _yourDataRepository.Get(id); | |
if (bussObj.State != "GoodState") | |
{ | |
throw new YourBussException( | |
$"BusinessObject with id, '{id}', is not in a good state. Change X and try again.", | |
new { State = bussObj.State, SomeRelaventOtherValue = bussObj.ObjectProperty }); | |
} | |
// ... omitted | |
} | |
} | |
} | |
{ | |
"type": "http://your.business.com/rfc7807/businessexception", | |
"title": "A business validation exception occurred.", | |
"detail": "BusinessObject with id, '5', is not in a good state. Change X and try again.", | |
"status": 400, | |
"traceId": "|d98798e908a9", | |
"extendedInfo": { | |
"state": "SomeBadState", | |
"someRelaventOtherValue": { ... } | |
} | |
} |
Some things to take note of:
- The ProblemDetails classes were introduced in ASP.NET Core 3.0. So, you’ll have to update your target framework to ‘aspnetcore3.0’ or above to make this work.
- You’ll also need to add in a FrameworkReference to ‘Microsoft.AspNetCore.App’ as the Microsoft.AspNetCore.Mvc.Infrastructure namespace only exists within it. And you can only get that reference through the FrameworkReference (as opposed to nuget packages). See Migrate from ASP.NET Core 2.2 to 3.0 for an example.
- The null-coalescing operator (??=) only compiles in C# 8.0. So, if your project, or your referenced projects depend on ‘netstandard2.0’ or ‘netcoreapp2.X’ then you’ll need to update them to get the compiler to work (this took a while to figure out.) (<--That’s right, your referenced projects have to update too; it’s really non-intuitive.)
Finally, let’s take a look at a unit test. I’m going to make this code sample a bit short. To make the full example work, you will need to copy all of these internal classes into your testing code:
This is the code snippet needed just for the test (YourBussProblemDetailsFactoryTests.cs):
namespace Your.Namespace.Tests | |
{ | |
public class TestExceptionHandlerFeature : IExceptionHandlerFeature | |
{ | |
public TestExceptionHandlerFeature(Exception exception) | |
{ | |
Error = exception; | |
} | |
public Exception Error { get; } | |
} | |
public class YourBussProblemDetailsFactoryTests | |
{ | |
// ... omitted | |
[Fact] | |
public void CreateProblemDetails_YourBuss() | |
{ | |
// Assemble | |
var exception = new YourBussException("unique description", new { Name = "some-value" }); | |
var exceptionHandlerFeature = new TestExceptionHandlerFeature(exception); | |
var httpContext = GetHttpContext(); | |
httpContext.Features.Set((IExceptionHandlerFeature)exceptionHandlerFeature); | |
// Act | |
var problemDetails = Factory.CreateProblemDetails(httpContext); | |
// Assert | |
Assert.Equal(400, problemDetails.Status); | |
Assert.Equal("A business validation exception occurred.", problemDetails.Title); | |
Assert.Equal("http://your.business.com/rfc7807/businessexception", problemDetails.Type); | |
Assert.Null(problemDetails.Instance); | |
Assert.Equal("unique description", problemDetails.Detail); | |
Assert.Collection( | |
problemDetails.Extensions, | |
kvp => | |
{ | |
Assert.Equal("extendedInfo", kvp.Key); | |
dynamic extendedInfo = kvp.Value; | |
Assert.Equal("some-value", extendedInfo.Name); | |
}, | |
kvp => | |
{ | |
Assert.Equal("traceId", kvp.Key); | |
Assert.Equal("some-trace", kvp.Value); | |
}); | |
} | |
// ... omitted | |
private static ProblemDetailsFactory GetProblemDetails() | |
{ | |
var options = new ApiBehaviorOptions(); | |
new ApiBehaviorOptionsSetup().Configure(options); | |
return new YourBussProblemDetailsFactory(Options.Create(options)); // <-- change that one | |
} | |
} | |
} |
An Exception Handler is also needed for this to work. You can read more within the follow-up post, ExceptionHandler Needed.
9 comments:
Thank you! It helped me, actually. I was looking for some solution which helps me to return predefined response in case of any server error.
You can decorate the DefaultProblemDetailsFactory. Create your decorator and have it take a ProblemDetailsFactory as a constructor parameter. Delegate both methods to your inner factory and then modify the returned problem details. To register the decorator use `services.Decorate();`. IMPORTANT - this must be called after `services.AddControllers();` as that is where the default factory is registered.
Oh wow! That's a really cool idea, I gotta try that.
Does services.Decorate() come with the framework? Or is there a third party library you would suggest? Or, maybe that's code we should write?
How can I access ProblemDetailsFactory in .NetStandard 2.0 class library. I want to try your code in a class library instead of in webapi project itself.
Thanks.
SAM, I did the same thing. I moved the code to a class library. It looks like it's containing project is targeting `netcoreapp3.0`.
I also have some fuzzy memories of it requiring a netcoreapp target because the underlying Microsoft.AspNetCore.Mvc.Infrastructure.ProblemDetailsFactory class is only available in a library in netcoreapp; it's not available in any nuget package. So, you have to reference a framework.
Thanks for this useful article. When I try to throw an exception from controller I don't receive a ProblemDetail reponse, neither did get a breakpoint hit CustomProblemDetailsFactory. Is there a configuration that I am missing to set?
Yeah, I missed a piece using an ExceptionHandler to tie the pieces together. Try also reading the follow-up article https://stevenmaglio.blogspot.com/2020/01/exceptionhandler-needed.html
For some of the questions you raised above:
https://github.com/dotnet/AspNetCore.Docs/issues/21767
Oh, that's cool. Thanks hB!
Post a Comment