In large projects we will encounter multiple ASP.NET Core 6.0 Web applications. Those Web application contain one or more of the following HTTP based applications:
- Web site
- Unversioned backend-for-frontend Web API service
- Versioned Web API service
To support common tasks for ASP.NET Web applications we created a project named Web.Core that contains functionality and a demo application for all of the above types of applications.
The solution contains two libraries Web.Core and Web.Core.WebApi with functionality to be used with web applications and APIs and a sample project called Acme to demonstrate all of the features of the libraries and a way how application logic can be shared between App and API.
The features we now support are:
- Error handling / reporting
- Base class for API controllers
- ErrorDetailsProblemDetailsFactory
- ErrorHandlingMiddleware
- ApiControllerBase
- IErrorDetails
- ExceptionProblemDetails
- Application configuration / validation
- API documentation
- API versioning
- Health monitoring
- Ping
The way error reporting is done is different between a Web application and a Web API. They both responds with a HTTP statuscode (e.g. 200, 401, 500) but the response body can differ. A Web application responds with an HTML page (Content-Type: text/html) and a Web API responds with JSON data (Content-Type: application/json), a machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807.
Unfortunately not all methods on the standard ControllerBase class respond in the same format and for that problem we created a custom ErrorDetailsProblemDetailsFactory to replace the standard ProblemDetailsFactory and a middleware component ErrorHandlingMiddleware to handle unexpected exceptions. The middleware catches any unhandled exception and creates a response with statuscode: 500 Internal Server Error.
To make it easier to develop an API controller, a custom base class ApiControllerBase is available that derives from the standard ControllerBase.
This base class implements the ErrorDetailsProblemDetailsFactory and overrides various methods like: BadRequest, NotFound, etc to use an object that implements IErrorDetails to respond with a proper problem details output.
Any unhandled exception is translated into an ExceptionProblemDetails response which contains detailed information about the exception. The detail level (Minimal, Moderate, Full) and inner exception depth can be controlled via configuration in the application settings.
In a production environment it is not a good practice to output the exception details, but for development and/or debugging is can be very useful. So be careful what settings to use!
public void ConfigureServices(IServiceCollection services)
services.AddTransient<ProblemDetailsFactory, ErrorDetailsProblemDetailsFactory>(); // must be called after 'services.AddControllers();' where the default factory is registered.public void Configure(IApplicationBuilder app)
if (!Environment.IsProduction())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseMiddleware<ErrorHandlingMiddleware>(); // ErrorHandlingMiddleware must be called after UseExceptionHandler and/or UseDeveloperExceptionPage, otherwise no destinction can be made between a json or html response (only needed in a web-application also containing an API, in a WebApi only application there is no need for the above if-blockappsettings.json
"ExceptionProblemDetails": {
"Details": "Minimal",
"Depth": 1
},example controller
[ApiController]
[ApiVersion("1")]
[Route("api/v{version:apiVersion}/data")]
public class DataController : ApiControllerBase
{
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorProblemDetails<AcmeDataErrorDetails>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ExceptionProblemDetails), StatusCodes.Status500InternalServerError)]
public IActionResult Get(int value = 0)
{
if (value == 0)
{
return BadRequest(new AcmeDataErrorDetails
{
BooleanValue = true,
DateValue = DateTime.Now,
DecimalValue = 26.3M,
IntValue = 42,
StringValue = "Lorem Ipsum Honda Magna",
});
}
if (value == 1)
{
throw new Exception("Something went very wrong!");
}
return Ok();
}
} public class AcmeDataErrorDetails : IErrorDetails
{
public int IntValue { get; set; }
public decimal DecimalValue { get; set; }
public string StringValue { get; set; }
public DateTime DateValue { get; set; }
public bool BooleanValue { get; set; }
}More information about Problem Details Response see: REST API Error Handling - Problem Details Response
To read more about ASP.NET Core Configuration see: Configuration in ASP.NET Core
When the applications are run with the Kestrel web server, the appsettings.json files are read with the reloadOnChange option set to: true.
We have a set of rules that we apply with respect to configuration files:
- We only use Debug and Release mode, no additional project configurations
- In Debug mode the value of the environment variable
ASPNETCORE_ENVIRONMENTisDevelopmentandIWebHostEnvironment.IsDevelopment()returnstrue - In Release mode the value of the environment variable
ASPNETCORE_ENVIRONMENTisProductionOR the the environment variableASPNETCORE_ENVIRONMENTis undefined andIWebHostEnvironment.IsProduction()returnstrue - There are only two configuration files in the folder of a project:
appsettings.json- the default application settings file that is always usedappsettings.Development.json- contains overrides for development only
- On the local development machine we always execute our code in Debug mode, which means that the
appsettings.Development.jsonis always active on top ofappsettings.json - The
appsettings.jsonfile specifies:- Settings that are used equally in all environments (local development - in Debug mode, deployment environments - in Release mode)
- Settings that must be replaced:
- In local development (Debug mode) by using
appsettings.Development.json - In deployed environments (Release mode) by replacing the values in the
appsettings.jsonOR by overriding the values using environment variables (necessary when running in a Docker container)
- In local development (Debug mode) by using
- On deployment all
appsettings.*.jsonfiles should be deleted, so theapsettings.Development.jsonconfiguration settings don't kick in when settingASPNETCORE_ENVIRONMENTtoDevelopmenton a deployed environment (we have been there:-))
All application settings to be replaced are marked with {{ ... }} in the appsettings.json file, e.g.:
{
"AzureAd": {
"Instance": "{{Replace with Azure AD instance, like https://login.microsoftonline.com/}}",
"Domain": "{{Replace with Azure AD domain, like mydomain.onmicrosoft.com}}",
"TenantId": "{{Replace with tentant id, this is a GUID}}",
"ClientId": "{{Replace with client id, this is a GUID}}"
},
"ApplicationInsights": {
"InstrumentationKey": "{{Replace with instrumentation key, this is a GUID}}"
},
"BlobStorage": {
"ConnectionString": "{{Replace with blob storage connection string}}",
"Containers": {
"Content": "content"
}
},
"VersionInfo": {
"BuildVersion": "#{BUILDVERSION_TOKEN}#"
}
}These {{...}} replacement values should be handled in the deployment pipeline by replacement in the appsettings.json file, or by override through environment variables.
Note that a value like BlobStorage.ConnectionString can be replaced by a connection string that contains references to a secret from the Keyfault. In that case these secrets are identified by a value $(keyfault_key) and will also be replaced in the deployment pipeline.
The value of VersionInfo:BuildVersion has a special notation #{BUILDVERSION_TOKEN}#. This value is not replaced for local development (and gives no validation errors), but is replaced in the appsettings.json file in the build pipeline.
To ensure all {{...}} replacement values are replaced or overridden, we have a ConfigurationValidator class that can validate all settings at runtime.
IConfigurationValidator.Validate() returns a string array containing any errors.
Add this validator to the DI container via:
public void ConfigureServices(IServiceCollection services)
services.AddTransient<IConfigurationValidator, ConfigurationValidator>();Turn on API Versioning via:
public void ConfigureServices(IServiceCollection services)
services.ConfigureApiVersioning();Decorate the API controllers with the ApiVersion and Route attributes
eg:
[ApiController]
[ApiVersion("1", Deprecated = false)]
[Route("api/v{version:apiVersion}/data")]
public class DataController : ApiControllerBase
{
...
}To read more on ASP.NET API Versioning see: https://github.com/microsoft/aspnet-api-versioning
To see if a user is authorized to access a api a ApiAuthorize attribute is added. This is needed to produce a 401 statuscode, instead of redirecting to a login page. Which is expected for a web page, but not for a api-call. The attribute can be used on action or controller level.
[ApiAuthorize]
[HttpGet("secure")]
[Produces("application/json")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ExceptionProblemDetails), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(typeof(ExceptionProblemDetails), StatusCodes.Status401Unauthorized)]
public IActionResult Secure()
{
...
} or
[ApiController]
[ApiAuthorize]
[ApiVersion("1")]
[Route("api/v{version:apiVersion}/data")]
public class DataController : ApiControllerBase
{
...
} For API documentation we chose for NSWag: The Swagger/OpenAPI toolchain for .NET, ASP.NET Core and TypeScript. An important reason for this selection is that NSwag provides the capabilities to generate clients in TypeScript or C# for the consumption of the API.
To have detailed information of your controllers and actions, turn on the XML documentation file via the project properties (tab: Build) of the project containing the controllers, and specified the assembly in the UseSwaggerWithDocumentation extension.
public void ConfigureServices(IServiceCollection services)
services.ConfigureSwaggerDoc("My Title", "My description about this API");public void Configure(IApplicationBuilder app)
app.UseSwaggerWithDocumentation(new []
{
Assembly.GetEntryAssembly(),
});For more information on NSWag see: https://github.com/RicoSuter/NSwag
- ApplicationInfoHealthCheck
- AddApplicationInfoHealthCheck extension
- ConfigurationValidationHealthCheck
- AddConfigurationValidationHealthCheck extension
- ApplicationEndpointHealthCheck
- AddApplicationEndpointsHealthCheck extension
- UseHealthCheckEndPoints extension
ApplicationInfoHealthCheck is a healthcheck that always returns Healthy and can be used as an endpoint to see if the application is up-and-running and contains some information about it.
ConfigurationValidationHealthCheck is a healthcheck that uses the ConfigurationValidator to validate if all the application configuration replacements are done.
ApplicationEndpointHealthCheck is a healthcheck that does a HEAD request to a specified URI and returns Healthy when the request has an OK (200) response.
AddApplicationEndpointsHealthCheck is an extension that uses appsettings.json to configure which endpoints should be requested.
public void ConfigureServices(IServiceCollection services)
services.AddHealthChecks()
.ApplicationInfoHealthCheck("My application name")
.AddConfigurationValidationHealthCheck("configuration")
.AddApplicationEndpointsHealthCheck("ping", "HealthChecks").Get<HealthCheckOptions>());appsettings.json
"HealthChecks": {
"ApplicationEndpoints": [
{
"Name": "Acme.WebApi",
"Url": "{{replace-with-application-ping-url}}",
"Timeout": 5000
}
]
}UseHealthCheckEndPoints is an extension that creates two endpoints for health monitoring:
/hc- from minimal information and only returnsHealthyorUnHealthy./mon- from detailed information on all health checks that are added to monitor.
public void Configure(IApplicationBuilder app)
app.UseHealthCheckEndPoints();For more information on configuration see: Health monitoring
To see if an application is up-and-running add the Ping lightweight endpoint. This will create a route: /ping which should be called with a HEAD request.
public void Configure(IApplicationBuilder app)
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.AddPing();
});By default all ASP.NET Core Web and API applications contain two main entry point files:
- Program.cs
- Startup.cs
See the Amce.WebApp, Acme.WebSpa and Acme.WebApi applications for an example!