Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/modules/ROOT/pages/changelog.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@ include::partial$changelog-{release}.adoc[]
== `{release}`
include::partial$changelog-release-section.adoc[]
include::partial$changelog-{release}.adoc[]

:release: 2.0.0
== `{release}`
include::partial$changelog-release-section.adoc[]
include::partial$changelog-{release}.adoc[]
17 changes: 17 additions & 0 deletions docs/modules/ROOT/partials/changelog-2.0.0.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
////
The changelog for a specific release.
Note that it is best to update the changelog as new code is developed.

All attributes used, must be included in this file to keep it self-contained.

This file is translated into markdown and used for the release notes in the GitHub release!
////

=== Fixes
* Renamed `OperationCanceledExceptionHandler` to `OperationCancelledExceptionHandler` because of typo (breaking change)
* Renamed `SemanticExceptionHandler` to `SemanticExceptionExceptionHandler` (breaking change)
* Improve documentation

=== New
* `ApiUsageErrorExceptionHandler`: initial version of exception handler for `ApiUsageError`
* `ContractViolationExceptionHandler`: initial version of exception handler for `ContractViolation`
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"rollForward": "disable",
"version": "10.0.102"
"version": "10.0.300"
}
}
37 changes: 37 additions & 0 deletions src/I/Exceptions/ApiUsageErrorExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026 by PeopleWare n.v..
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Hosting;

using PPWCode.Vernacular.Exceptions.V;

namespace PPWCode.AspNetCore.Server.I.Exceptions;

/// <inheritdoc />
public sealed class ApiUsageErrorExceptionHandler : BaseExceptionHandler<ApiUsageErrorExceptionHandler, ApiUsageError>
{
/// <inheritdoc />
public ApiUsageErrorExceptionHandler(ProblemDetailsFactory problemDetailsFactory, IHostEnvironment environment)
: base(problemDetailsFactory, environment)
{
}

/// <inheritdoc />
protected override bool LogException
=> true;

/// <inheritdoc />
protected override int? GetStatusCode(ExceptionContext context, ApiUsageError? contextException)
=> StatusCodes.Status400BadRequest;
}
130 changes: 125 additions & 5 deletions src/I/Exceptions/BaseExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
using Microsoft.AspNetCore.Http;
// Copyright 2026 by PeopleWare n.v..
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
Expand All @@ -8,13 +19,50 @@

namespace PPWCode.AspNetCore.Server.I.Exceptions;

/// <summary>
/// Base class for <see cref="IExceptionHandler" /> implementations that translate a specific
/// <typeparamref name="TException" /> into an HTTP response, typically a <see cref="ProblemDetails" /> payload.
/// </summary>
/// <remarks>
/// <para>
/// The handling pipeline in <see cref="Handle" /> works as follows:
/// <list type="number">
/// <item><see cref="CanHandle" /> decides whether this handler applies to the current exception.</item>
/// <item>
/// When <see cref="LogException" /> is <see langword="true" />, the exception is logged through
/// <see cref="LogContext" />.
/// </item>
/// <item>
/// When <see cref="GetStatusCode" /> returns a status code, a <see cref="ProblemDetails" /> is produced
/// (optionally enriched through <see cref="EnrichProblemDetails" />) and used as the response.
/// </item>
/// <item>
/// Otherwise <see cref="CreateActionResult" /> is given the chance to produce a custom
/// <see cref="IActionResult" />.
/// </item>
/// </list>
/// </para>
/// <para>
/// Derived classes customize the behaviour by overriding the relevant <see langword="virtual" /> members; most
/// handlers only need to override <see cref="GetStatusCode" />.
/// </para>
/// </remarks>
/// <typeparam name="THandler">
/// The concrete handler type, used as the category for the <see cref="ILogger{TCategoryName}" />.
/// </typeparam>
/// <typeparam name="TException">The exception type this handler is responsible for.</typeparam>
public abstract class BaseExceptionHandler<THandler, TException> : IExceptionHandler
where THandler : IExceptionHandler
where TException : Exception
{
private readonly ProblemDetailsFactory _problemDetailsFactory;
private readonly IHostEnvironment _environment;
private readonly ProblemDetailsFactory _problemDetailsFactory;

/// <summary>
/// Initializes a new instance of the <see cref="BaseExceptionHandler{THandler, TException}" /> class.
/// </summary>
/// <param name="problemDetailsFactory">The factory used to create the <see cref="ProblemDetails" /> response.</param>
/// <param name="environment">The host environment, used to decide whether development-only details are exposed.</param>
protected BaseExceptionHandler(
ProblemDetailsFactory problemDetailsFactory,
IHostEnvironment environment)
Expand All @@ -23,9 +71,20 @@ protected BaseExceptionHandler(
_environment = environment;
}

/// <summary>
/// Indicates whether a handled exception should be logged through <see cref="LogContext" />.
/// Defaults to <see langword="false" />.
/// </summary>
protected virtual bool LogException
=> false;

/// <summary>
/// Indicates whether the current <see cref="IHostEnvironment.EnvironmentName" /> is considered a development
/// environment, in which case additional exception details are added to the <see cref="ProblemDetails" />.
/// </summary>
protected bool IsDevelopment
=> PpwProblemDetailsFactory.EnvironmentsConsideredAsDevelopment.Contains(_environment.EnvironmentName);

/// <inheritdoc />
public bool Handle(ExceptionContext context)
{
Expand All @@ -46,6 +105,11 @@ public bool Handle(ExceptionContext context)
.CreateProblemDetails(
context.HttpContext,
statusCode: statusCode.Value);
if (IsDevelopment && contextException is not null)
{
problemDetail.Extensions.Add("Exception", FormatException(contextException));
}

EnrichProblemDetails(context, contextException, problemDetail);
context.Result = new ObjectResult(problemDetail);
return true;
Expand All @@ -70,25 +134,81 @@ private ILogger<T> CreateLogger<T>(ExceptionContext context)
return factory.CreateLogger<T>();
}

protected bool IsDevelopment
=> PpwProblemDetailsFactory.EnvironmentsConsideredAsDevelopment.Contains(_environment.EnvironmentName);

/// <summary>
/// Determines whether this handler is able to handle the exception in <paramref name="context" />.
/// By default this is the case when the exception is assignable to <typeparamref name="TException" />.
/// </summary>
/// <param name="context">The current exception context.</param>
/// <returns><see langword="true" /> when this handler can handle the exception; otherwise <see langword="false" />.</returns>
protected virtual bool CanHandle(ExceptionContext context)
=> context.Exception is TException;

/// <summary>
/// Logs the handled exception. Only invoked when <see cref="LogException" /> is <see langword="true" />.
/// </summary>
/// <param name="logger">The logger for <typeparamref name="THandler" />.</param>
/// <param name="context">The current exception context.</param>
protected virtual void LogContext(ILogger<THandler> logger, ExceptionContext context)
=> logger.LogError(context.Exception, "Handled exception");

/// <summary>
/// Produces a custom <see cref="IActionResult" /> for the exception. Only invoked when
/// <see cref="GetStatusCode" /> returned <see langword="null" />. Returns <see langword="null" /> by default,
/// leaving the exception unhandled.
/// </summary>
/// <param name="context">The current exception context.</param>
/// <returns>The result to use as the response, or <see langword="null" /> when no result is produced.</returns>
protected virtual IActionResult? CreateActionResult(ExceptionContext context)
=> null;

/// <summary>
/// Returns the HTTP status code used to build the <see cref="ProblemDetails" /> response, or
/// <see langword="null" /> to fall back to <see cref="CreateActionResult" />. Returns <see langword="null" />
/// by default.
/// </summary>
/// <param name="context">The current exception context.</param>
/// <param name="contextException">
/// The exception cast to <typeparamref name="TException" />, or <see langword="null" /> when the cast failed.
/// </param>
/// <returns>The HTTP status code, or <see langword="null" />.</returns>
protected virtual int? GetStatusCode(ExceptionContext context, TException? contextException)
=> null;

/// <summary>
/// Allows derived classes to add extra information to the <see cref="ProblemDetails" /> before it is returned.
/// Does nothing by default.
/// </summary>
/// <param name="context">The current exception context.</param>
/// <param name="contextException">
/// The exception cast to <typeparamref name="TException" />, or <see langword="null" /> when the cast failed.
/// </param>
/// <param name="problemDetail">The problem details to enrich.</param>
protected virtual void EnrichProblemDetails(
ExceptionContext context,
TException? contextException,
ProblemDetails problemDetail)
{
}

/// <summary>
/// Builds a structured, serialization-friendly representation of an exception (including its stack trace and
/// inner exceptions), added to the <see cref="ProblemDetails" /> extensions when <see cref="IsDevelopment" />
/// is <see langword="true" />.
/// </summary>
/// <param name="exception">The exception to format.</param>
/// <returns>An object describing the exception, including its inner exception chain.</returns>
protected virtual object FormatException(Exception exception)
=> new
{
Type = exception.GetType().FullName,
exception.Message,
StackTrace =
exception
.StackTrace?
.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries),
InnerException =
exception.InnerException is { } inner
? FormatException(inner)
: null
};
}
41 changes: 41 additions & 0 deletions src/I/Exceptions/ContractViolationExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026 by PeopleWare n.v..
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Diagnostics.CodeAnalysis;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Hosting;

using PPWCode.Vernacular.Contracts.I;

namespace PPWCode.AspNetCore.Server.I.Exceptions;

/// <inheritdoc />
[ExcludeFromCodeCoverage]
public sealed class ContractViolationExceptionHandler
: BaseExceptionHandler<ContractViolationExceptionHandler, ContractViolation>
{
/// <inheritdoc />
public ContractViolationExceptionHandler(ProblemDetailsFactory problemDetailsFactory, IHostEnvironment environment)
: base(problemDetailsFactory, environment)
{
}

/// <inheritdoc />
protected override bool LogException
=> true;

/// <inheritdoc />
protected override int? GetStatusCode(ExceptionContext context, ContractViolation? contextException)
=> StatusCodes.Status500InternalServerError;
}
45 changes: 45 additions & 0 deletions src/I/Exceptions/ExceptionExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2026 by PeopleWare n.v..
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Diagnostics.CodeAnalysis;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Hosting;

namespace PPWCode.AspNetCore.Server.I.Exceptions;

/// <inheritdoc />
/// <remarks>
/// <inheritdoc cref="BaseExceptionHandler{ExceptionExceptionHandler,Exception}" path="/remarks/node()" />
/// <para>
/// Use this exception handler to handle all exceptions, such as unhandled exceptions. This should be registered
/// last.
/// </para>
/// </remarks>
[ExcludeFromCodeCoverage]
public sealed class ExceptionExceptionHandler : BaseExceptionHandler<ExceptionExceptionHandler, Exception>
{
/// <inheritdoc />
public ExceptionExceptionHandler(ProblemDetailsFactory problemDetailsFactory, IHostEnvironment environment)
: base(problemDetailsFactory, environment)
{
}

/// <inheritdoc />
protected override bool LogException
=> true;

/// <inheritdoc />
protected override int? GetStatusCode(ExceptionContext context, Exception? contextException)
=> StatusCodes.Status500InternalServerError;
}
23 changes: 18 additions & 5 deletions src/I/Exceptions/ExternalErrorExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
using System.Diagnostics.CodeAnalysis;
// Copyright 2026 by PeopleWare n.v..
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Diagnostics.CodeAnalysis;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
Expand All @@ -9,20 +20,22 @@

namespace PPWCode.AspNetCore.Server.I.Exceptions;

/// <inheritdoc />
[ExcludeFromCodeCoverage]
public sealed class ExternalErrorExceptionHandler
: BaseExceptionHandler<ExternalErrorExceptionHandler, ExternalError>
{
/// <inheritdoc />
public ExternalErrorExceptionHandler(ProblemDetailsFactory problemDetailsFactory, IHostEnvironment environment)
: base(problemDetailsFactory, environment)
{
}

/// <inheritdoc />
protected override int? GetStatusCode(ExceptionContext context, ExternalError? exception)
=> StatusCodes.Status500InternalServerError;

/// <inheritdoc />
protected override bool LogException
=> true;

/// <inheritdoc />
protected override int? GetStatusCode(ExceptionContext context, ExternalError? exception)
=> StatusCodes.Status500InternalServerError;
}
Loading
Loading