A small helper package to help you with WebApi on ASP.NET Core. It's mainly for myself at the moment to avoid writing the same code over and over again. Hopefully it might help you too 😉
As you would expect each namespace divides up the helper functionality. If and when this package gets unwieldy I'll split it up into smaller packages.
> dotnet add package DalSoft.AspNetCore.WebApi
PM> Install-Package DalSoft.AspNetCore.WebApi
The ErrorHandling namespace aims to give you a consistent pattern to retrieve and return error responses. It extends the great work the ASP.NET Core team has done to implement Problem Details RFC 7807 by providing centralized error handling.
Enums decorated with the ProblemDetailsAttribute are used to describe known errors (problem types](https://tools.ietf.org/html/rfc7807)) that will be returned to clients using your API.
The Enum's named constants are used for the problem type (and for lookup). In the example below this would be NotOnMonday. The properties of the ProblemDetailsAttribute are used to create a new ASP.NET Core ProblemDetails object.
public enum DeveloperProblemsTypes
{
[ProblemDetails(
title:"Sorry we're shut on Mondays.",
details: "We want developers to have a happy Monday :)",
status:HttpStatusCode.BadRequest
)]
NotOnMonday,
[ProblemDetails(
title:"Sorry we're shut on Fridays.",
details: "Developers have their slack time on Fridays.")",
status:HttpStatusCode.NotAcceptable
)]
NotOnFriday
}
Now we have created our problem typess using the ProblemDetailsAttribute
it's time to tell DI about them, to do this add the following code to Startup.cs.
public void ConfigureServices(IServiceCollection services)
{
services
.AddProblemDetailFactory(new Uri("/docs", UriKind.Relative))
.AddOrUpdateProblemDetails<DeveloperProblemsTypes>()
.ConfigureInvalidModelStateResponseFactory();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
app.UseProblemDetailExceptionHandler(env);
}
AddProblemDetailFactory
Registers the ProblemDetailFactory and other required services. This will allow us to register, update and retrive our problem types. This extends what the ASP.NET Core team has done by allowing you to set the base URI for your documentation. The enum constant name is then appended (formatted as snake case) to the base URI on retrival giving us our problem type - in our example this would be /docs/not_on_monday, see usage for example of the responses.
AddOrUpdateProblemDetails
Registers problem types with the ConcurrentDictionary using an enum decorated with ProblemDetailsAttributes. Notice the enum is a generic paramater this allows you to add multiple problem type enums (enums were added as generic constraints in C# 7.3).
AddOrUpdateProblemDetails also takes IEnumerable which is useful for registering/updating problem types from a database. Problem types are registered/updated in the order specified. Registering the same problem type will update it's details. This means you can resgister your problems types intially as enums for easy lookup, and update the details from a database.
ConfigureInvalidModelStateResponseFactory
Optional get a validation problem type anytime a client calls a controller and ModelState is not Valid. This stops you having to have an if statement checking that ModelState IsValid. It's the equivalent of Problem(DefaultProblemsDetails.ValidationFailed, ModelState)
. See DefaultProblemTypes for more details.
UseProblemDetailExceptionHandler
Optional automatically respond with DefaultProblemsDetails.InternalServerError
if an unhandled exception is thrown, and respond with a ProblemDetailsResponse
if a ProblemDetailsException
is thrown. See ProblemDetailExceptionHandler
for more details.
Now we have registered the ProblemDetailFactory with DI and registered one or more problem types we are ready to use them in our controllers etc.
Injecting the ProblemDetailsActionResultFactory into our controller allows us to return a ProblemDetailsResponse
from an controller's action.
[ApiController, Route("[Controller]")]
public class TestProblemDetailsController : ControllerBase
{
private readonly ProblemDetailsActionResultFactory _problemDetailsActionResultFactory;
public TestProblemDetailsControllerProblemDetailsActionResultFactory problemDetailsActionResultFactory)
{
_problemDetailsActionResultFactory = problemDetailsActionResultFactory;
}
[HttpGet]
public ActionResult Get()
{
return _problemDetailsActionResultFactory.Problem(DeveloperProblemsDetails.NotOnMonday);
}
}
This responds with a status code of 400 Bad Request and this response body:
{
"type": "/docs/not_on_monday",
"title": "Sorry we're shut on Mondays.",
"details:" "We want developers to have a happy Monday :)",
"status": 400,
"instance": "https://localhost/TestProblemDetails"
}
Note the only difference between getting a ProblemDetailsResponse from
ProblemDetailsActionResultFactory Problem
vsProblemDetailsFactory ResponseFor
is that it wraps it in a ObjectResult, sets the status code and instance for you (if your didn't add it via theProblemDetailsAttribute
).
Problem
has overloads that take ModelState and Extensions, and you can look up a regsistered problem type using a string rather than enum. Below is an example using a overload of the Problem method.
[ApiController, Route("[Controller]")]
public class TestProblemDetailsController : ControllerBase
. private readonly ProblemDetailsActionResultFactory _problemDetailsActionResultFactory;
public TestProblemDetailsControllerProblemDetailsActionResultFactory problemDetailsActionResultFactory)
{
_problemDetailsActionResultFactory = problemDetailsActionResultFactory;
}
[HttpGet]
public ActionResult Get()
{
ModelState.AddModelError("email", "Email address is invalid");
return _problemDetailsActionResultFactory.Problem("NotOnMonday", ModelState);
}
}
This responds with a status code of 400 Bad Request and this response body:
{
"type": "/docs/not_on_monday",
"title": "Sorry we're shut on Mondays.",
"details:""We want developers to have a happy Monday :)",
"status": 400,
"instance": "httpa://localhost/TestProblemDetails",
"errors": {
"email": [
"Email address is invalid"
]
}
}
Note the status code returned is 400 because that what we set using the
ProblemDetailsAttribute
.
Why do we wrap ProblemDetails in a ProblemDetailsResponse rather than directly returning ASP.NET Core's ProblemDetails? Two reasons:
-
For unhandled exception (see ProblemDetailsExceptionHandler) thrown during development we want to be able to return exception details.
-
We want extensions (see ProblemDetailsResponse Extensions) to serialize using the same JsonSerializer you configured in Startup.cs, currently because of this bug they don't.
Section 3.2 of the Problem Details RFC says that "Problem type definitions MAY extend the problem details object with additional members."
Both the ProblemDetailsActionResultFactory Problem
and ProblemDetailsFactory ResponseFor
methods allow you to supply a Dictionary<stirng, object> representing the extension to be added the registered problem type.
Extensions generally contain contextual information so you need to pass your Dictionary<stirng, object> at runtime. Below shows how you would do this in a controller's action to return the example shown in the Problem Detail RFC.
[ApiController, Route("[Controller]")]
public class TestProblemDetailsController : ControllerBase
.
private readonly ProblemDetailsActionResultFactory _problemDetailsActionResultFactory;
public TestProblemDetailsControllerProblemDetailsActionResultFactory problemDetailsActionResultFactory)
{
_problemDetailsActionResultFactory = problemDetailsActionResultFactory;
}
[HttpGet]
public ActionResult Get()
{
var extensions = new Dictionary<string, object>
{
{ "Balance", 30 },
{ "Accounts", new[] {"/account/12345", "/account/67890" } }
};
return _problemDetailsActionResultFactory.Problem("NotOnMonday", extensions);
}
}
This responds with a status code of 400 Bad Request and this response body:
{
"type": "/docs/not_on_monday",
"title": "Sorry we're shut on Mondays.",
"details:""We want developers to have a happy Monday :)",
"status": 400,
"instance": "https://localhost/TestProblemDetails",
"balance": 30,
"accounts": [
"/account/12345",
"/account/67890"
]
}
WARNING: Use extreme caution when extending a problem type response, in particular responding with user suppiled data and exposing implementation internals in error messages. For more information see section 5 of the Problem Details RFC.
When registering the ProblemDetailFactory by calling AddProblemDetailFactory
in Startup.cs two problem types are registered by default.
DefaultProblemTypes.ValidationFailed
Should be used to tell the client using your API that their request failed validation. This will happpen automatically if you call ConfigureInvalidModelStateResponseFactory
in Startup.cs.
This responds with a status code of 400 Bad Request and this response body:
{
"type": "/docs/validation_failed",
"title": "Your request parameters didn't validate.",
"status": 400,
"instance": "https://localhost/TestProblemDetails"
}
DefaultProblemTypes.InternalServerError
problem type should be used to tell the client using your API that their request failed with an unexpected error. This will happpen automatically if you configure ProblemDetailsExceptionHandler
in Startup.cs.
This responds with a status code of 500 internal server error and this response body:
{
"type": "/docs/internal_server_error",
"title": "Something unexpected happened, please try again.",
"status": 500,
"instance": "https://localhost/TestProblemDetails"
}
There is no reason not to use DefaultProblemTypes as you can change any of the problem details by calling
AddOrUpdateProblemDetails
in Startup.cs with your changes. By doing this you keep the benefits of automatically returning these types.
ProblemDetailsFactory has lower level methods for working with your registered problem types. For convenience some methods can be called statically.
The following methods are static:
Contains
checks if a problem type is registered it takes a string representing the type and returns bool if it found.
ResponseFor
creates a ProblemDetailsResponse
from a registered problem type using the enum constant name supplied. Like Problem
it has overloads that take ModelState, Extensions and you can look up a regsistered problem type using a string rather than enum.
There are generally two reasons to use ResponseFor
instead of returning a ViewResult using Problem
-
You want to get a
ProblemDetailsResponse
outside of a controller. -
You want to exceptionally change the details of the
ProblemDetailsResponse
- for example returning a different status code.
Below is an example of using ResponseFor
.
[HttpGet, Route("TestProblemDetails")]
public ActionResult TestProblemDetails()
{
// Change the status code for ValidationFailed just for this controllers action
// Get new instance of a ProblemDetailsResponse
var validationFailedProblemType = ProblemDetailsFactory.ResponseFor(DefaultProblemTypes.ValidationFailed);
validationFailedProblemType.Status = HttpStatusCode.NotAcceptable;
return new ObjectResult(validationFailedProblemType)
{
StatusCode = validationFailedProblemType.Status.Value.ParseStatus()
};
}
ExceptionFor
creates a ProblemDetailsException
from a registered problem type using the enum constant name supplied. Like ResponseFor
it has overloads that take ModelState, Extensions, and you can look up a regsistered problem type using a string rather than enum. Designed to work with the ProblemDetailsExceptionHandler
see ProblemDetailsExceptionHandler for more details.
The following method requires an injected instance:
AddOrUpdateProblemDetails
allows you to register or update one or many ProblemDetails, by passing either an enumerable of ProblemDetails or by passing an enum decorated with ProblemDetailsAttributes.
Registering or updating ProblemDetails should be thread safe as it uses a concurrent Dictionary. Provding a problem type that is already registered will perform an update.
Below is an example of registering and updating registered Problem types.
public class MyProblemDetailsUpdateService
{
private readonly ProblemDetailsFactory _problemDetailsFactory;
public MyProblemDetailsUpdateService(ProblemDetailsFactory problemDetailsFactory)
{
_problemDetailsFactory = problemDetailsFactory;
}
public void PopulateMyProblemDetails()
{
// ... you could get the problem details from a DB or whatever
// Register a list of problem types
var problemTypes1 = new List<ProblemDetails>
{
new ProblemDetails
{
Type = "NotOnMonday",
Title = "Sorry we're shut on Mondays.",
Detail = "We want developers to have a happy Monday :)",
Status = HttpStatusCode.BadRequest.ParseStatus()
}
};
_problemDetailsFactory.AddOrUpdateProblemDetails(problemTypes1);
// Register problem Types using an enum
_problemDetailsFactory.AddOrUpdateProblemDetails<DeveloperProblemTypes>();
// Update the title of a previous registered problem type
var problemTypes2 = new List<ProblemDetails>
{
new ProblemDetails
{
Type = "NotOnMonday",
Title = "We are closed Mondays.",
}
};
_problemDetailsFactory.AddOrUpdateProblemDetails(problemTypes2);
}
}
WARNING: Use extreme caution when allowing end users to get or update problem types - in particular protect against overposting, and ensure only Authorized users have access.
ProblemDetailsExceptionHandler is biased to API's returning JSON, at the moment the ProblemDetailsExceptionHandler will return JSON only.
The ProblemDetailsExceptionHandler allows you to centralize API exceptions and handle exceptions at a macro level. It avoids bolierplate try catches in your services and controllers, just throw a ProblemDetailsException using a registered problem type.
To use the ProblemDetailsExceptionHandler just register your problem types and call UseProblemDetailExceptionHandler in Startup.cs.
public void ConfigureServices(IServiceCollection services)
{
services
.AddProblemDetailFactory(new Uri("/docs", UriKind.Relative))
.AddOrUpdateProblemDetails<DeveloperProblemTypes>()
.ConfigureInvalidModelStateResponseFactory();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
app.UseProblemDetailExceptionHandler(env);
}
Now you can lookup and throw a registered problem type in any controller or service.
throw ProblemDetailsFactory.ExceptionFor(DeveloperProblemTypes.NotOnMonday);
This would repsond to a client using your API with a 400 Bad Request and this response body:
{
"type": "/docs/not_on_monday",
"title": "Sorry we're shut on Mondays.",
"details:""We want developers to have a happy Monday :)",
"status": 400,
"instance": "https://localhost/TestProblemDetails"
}
Any unhandled exceptions will return the DefaultProblemTypes.InternalServerError
problem type.
throw new InvalidOperationException("I'm an unhandled exception");
This would repsond to a client using your API with a 500 Internal Server error and this response body:
{
"type": "/docs/internal_server_error",
"title": "Something unexpected happened, please try again.",
"status": 500,
"instance": "https://localhost/TestProblemDetails"
}
Where possible try to avoid throwing exceptions for known errors
When debugging it's useful to see the exception details of the exception that is thrown. If ASPNETCORE_ENVIRONMENT
is set to Development
exception details are added to DefaultProblemTypes.InternalServerError
problem type.
{
"type": "/docs/internal_server_error",
"title": "Something unexpected happened, please try again.",
"status": 500,
"instance": "https://localhost/TestProblemDetails",
"debug": {
"ClassName": "System.InvalidOperationException",
"Message": "I'm an unhandled exception",
"Data": null,
"InnerException": null,
"HelpURL": null,
"StackTraceString": " at ...",
"RemoteStackTraceString": null,
"RemoteStackIndex": 0,
"ExceptionMethod": null,
"HResult": -2146233079,
"Source": "...",
"WatsonBuckets": null
}
}
Important ensure
ASPNETCORE_ENVIRONMENT
is NOT set toDevelopment
in production as you will be exposing implementation internals in the problem type response. In future versions you will be able to completely turn this feature off.
WARNING: Use extreme caution when allowing end users to get or update configuration - in particular protect against overposting, and ensure only Authorized users have access.
ASP.NET Core comes with great support for where you get you configuration from:
- Azure Key Vault
- Command-line parameters
- Environment variables
- Files (INI, JSON, XML)
- Memory Configuration Provider In-memory collections
- User secrets
However there is often a situation where you want to change config at runtime or load config from somewhere that isn't supported SQL Server, MongoDB etc.
The ConcurrentConfigurationProvider makes this possible by loading config from a ConcurrentDictionary. You just populate the Dictionary via the AddOrUpdateConfiguration methods. You can populate the Dictionary from a database etc, you can update at runtime and get the updated config using IOptionsSnapshot.
In Startup.cs
private readonly IConfigurationRoot _configuration;
private readonly ConcurrentConfiguration<TestConfig> _concurrentConfiguration = new ConcurrentConfiguration<TestAppSettings>();
// 1. Add Configuration Source
public Startup(IHostingEnvironment env, ILoggerFactory loggerFactory)
{
var builder = new ConfigurationBuilder()
// ...
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
//... Whatever other configuration sources you want to use
// Add concurrent configuration source
.AddConcurrentConfiguration(_concurrentConfiguration);
_configuration = builder.Build();
}
// 2. Add Concurrent Configuration and ASP.NET Options to DI
public void ConfigureServices(IServiceCollection services)
{
services.AddConcurrentConfiguration(_concurrentConfiguration); // Add Concurrent cCnfiguration
services.AddOptions(); // Enable options
services.Configure<TestConfig>(_configuration.GetSection(nameof(TestAppSettings))); // Add configure Options
}
// 3. Populate Concurrent Configuration
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
// You could use _concurrentConfiguration too :)
var concurrentConfiguration = app.ApplicationServices.GetService<ConcurrentConfiguration<TestAppSettings>>();
// Get the config you want populate from database etc ...
// Populate config
concurrentConfiguration.AddOrUpdateConfiguration(testConfig => testConfig.WebHostUrl, "https://localhost");
// ...
}
Now you have configured ConcurrentConfiguration in Startup.cs you can inject Options to your controller normally, these Options don't reflect any updates.
[ApiController, Route("[Controller]")]
public class ConfigController : Controller
{
private readonly IOptions<TestAppSettings> _testConfig;
public ConfigController(IOptions<TestAppSettings> testConfig)
{
_testConfig = testConfig;
}
// Action to get updated config
[HttpGet, Route("TestConfig")]
public ActionResult TestConfig()
{
return new ObjectResult(_testConfig?.Value);
}
}
To get updated config inject IOptionsSnapshot. You can also inject ConcurrentConfiguration and update the config at runtime.
Putting it together you get:
[ApiController, Route("[Controller]")]
public class ConfigController : Controller
{
private readonly ConcurrentConfiguration<TestAppSettings> _concurrentConfiguration;
private readonly IOptionsSnapshot<TestAppSettings> _testConfig;
public ConfigController(ConcurrentConfiguration<TestAppSettings> concurrentConfiguration, IOptionsSnapshot<TestAppSettings> testConfig)
{
_concurrentConfiguration = concurrentConfiguration;
_testConfig = testConfig;
}
// Action to update the config at runtime using ConcurrentConfiguration
[HttpPut, Route("TestConfig")]
public ActionResult SetTestConfig()
{
_concurrentConfiguration.AddOrUpdateConfiguration(config => config.WebHostUrl, "https://dalsoft.co.uk"); // Update concurrent dictionary
// Update database etc ....
return Ok();
}
// Action to get updated config using IOptionsSnapshot
[HttpGet, Route("TestConfig")]
public ActionResult TestConfig()
{
return new ObjectResult(_testConfig?.Value);
}
}
Setup ConcurrentConfiguration in Startup.cs, inject ConcurrentConfiguration
and update config by calling AddOrUpdateConfiguration
, finally you can get your updated config using IOptionsSnapshot
.
WARNING: Use extreme caution when allowing end users to get or update configuration - in particular protect against overposting, and ensure only Authorized users have access.
This is a very simple abstract class that lets you bind the inheriting class to appsetting.json using a static method. It's useful for test projects where you don't have DI setup but want an easy way to get settings from appsettings.json.
For your actual WebApi project you should use the Options pattern where possible.
Create a class inheriting from AppSettings where T is your class.
public class TestAppSettings : AppSettings<TestAppSettings>
{
public string WebHostUrl { get; set; }
}
Add your appsettings.json
// appsetting.json
{
"ConnectionStrings": {
"DbContext": "Server=(localdb)..."
},
"TestAppSettings": {
"WebHostUrl": "https://localhost"
}
}
Then bind your settings from appsettings.json by calling GetSettings().
TestAppSettings
.GetSettings().WebHostUrl; // Returns "https://localhost"
After the first call to GetSettings(), your settings are cached. GetSettings() supports overriding settings using appsettings.{environmentName}.json convention.