ASP.NET • Gestion d'exception
Contexte
Section intitulée « Contexte »Une exception est levée pour une erreur non gérée, une opération interdite ou tout traitement non prévu. En phase de développement, afficher toutes les exceptions est très pratique. Une exception fournit beaucoup d’informations nous permettant d’identifier le code l’ayant levée. Cela nous aide justement à fiabiliser notre code en gérant les potentielles erreurs, en évitant les opérations interdites, etc. Bref en améliorant la robustesse de notre code. Car une exception doit rester… exceptionnelle.
Cependant, lorsque le site web est déployé en production, il est absolument nécessaire de filtrer toutes les exceptions. C’est important pour des raisons de sécurité : une exception contient énormément d’informations sensibles. Elle dévoile potentiellement : les technologies employées, l’emplacement des fichiers de code, le nom du serveur de base de données, etc.
Nous allons voir ensemble deux façons de mettre en place les filtres nécessaires pour n’exposer aucune information sensible au cas où, malgré tous nos efforts, une exception est levée.
Mise en place de la Web API ASP.NET
Section intitulée « Mise en place de la Web API ASP.NET »Création du projet
Section intitulée « Création du projet »Commençons par créer un nouveau projet de type webapi.
Dans un terminal, tapons la commande suivante :
dotnet new webapi --name=ExceptionFilterWebApiOuvrons le projet ExceptionFilterWebApi dans notre IDE favori.
Voici la structure du projet :
RépertoireExceptionFilterWebApi
RépertoireProperties
- launchSettings.json
- appsettings.json
- appsettings.Development.json
- ExceptionFilterWebApi.http
- Program.cs
Installation d’un remplaçant à Swagger UI
Section intitulée « Installation d’un remplaçant à Swagger UI »Swagger UI n’est plus disponible par défaut depuis .NET 9.
Nous allons installer un package NuGet nous permettant d’avoir une page de test de notre Web API :
Scalar.AspNetCore.
Nous pouvons utiliser l’IDE pour ajouter ce package, ou bien passer par un terminal :
dotnet add package Scalar.AspNetCoreNous pouvons maintenant mettre à jour Program.cs :
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi(); app.MapScalarApiReference();}
app.UseHttpsRedirection();
var summaries = new[]{ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};
app.MapGet("/weatherforecast", () => { var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays(index)), Random.Shared.Next(-20, 55), summaries[Random.Shared.Next(summaries.Length)] )) .ToArray(); return forecast; }) .WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary){ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);}Mise à jour des paramètres de démarrage du projet
Section intitulée « Mise à jour des paramètres de démarrage du projet »Afin de nous faciliter la vie, nous allons éditer le fichier de paramétrage de lancement du projet :
- activer l’ouverture automatique du navigateur ;
- ouvrir l’URL
scalar/v1par défaut.
{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "http://localhost:5062", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "launchBrowser": true, "launchUrl": "scalar/v1", "applicationUrl": "https://localhost:7200;http://localhost:5062", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } }}Si nous exécutons le projet, une nouvelle page (ou un nouvel onglet) s’ouvre dans notre navigateur par défaut.
L’URL de la page est :https://localhost:7200/scalar/v1 et la page Scalar est affichée.
Dans le menu de gauche, nous pouvons cliquer sur /weatherforecast GET.
Sur la droite, dans la section /weatherforecast, un clic sur le bouton ▶ Test Request ouvre une popin.
Si nous appuyons sur le bouton ▶ Send alors :
- Le code statut est :
200 OK; - Le body est de type :
application/json; - Le body contient le JSON :
[ { "date": "2024-11-19", "temperatureC": -17, "summary": "Freezing", "temperatureF": 2 }, { "date": "2024-11-20", "temperatureC": 43, "summary": "Sweltering", "temperatureF": 109 }, { "date": "2024-11-21", "temperatureC": 44, "summary": "Balmy", "temperatureF": 111 }, { "date": "2024-11-22", "temperatureC": -8, "summary": "Chilly", "temperatureF": 18 }, { "date": "2024-11-23", "temperatureC": 41, "summary": "Sweltering", "temperatureF": 105 }]Déclenchement d’une exception
Section intitulée « Déclenchement d’une exception »Maintenant que notre Web API est opérationnelle, nous allons faire en sorte qu’une exception soit levée.
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi(); app.MapScalarApiReference();}
app.UseHttpsRedirection();
var summaries = new[]{ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};
app.MapGet("/weatherforecast", () => { throw new Exception("Weather forecast"); var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays(index)), Random.Shared.Next(-20, 55), summaries[Random.Shared.Next(summaries.Length)] )) .ToArray(); return forecast; }) .WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary){ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);}Si nous relançons le projet, l’appel de /weatherforecast dans Scalar retourne :
- Le code statut est :
500 Internal Server Error; - Le body est de type :
text/plain; - Le body ne contient plus les données précédentes.
De nombreuses informations sont présentes maintenant :
- les détails de l’exception ;
- la ligne de code fautive ;
- la pile d’appels ;
- les paramètres de query ;
- les données d’en-tête ;
- etc.
Nous sommes prêts pour mettre en place la gestion des exceptions dans notre projet.
Middleware personnalisé
Section intitulée « Middleware personnalisé »Les middlewares sont un élément important d’une application ASP.NET. Ils sont utilisés dans la gestion des requêtes et des réponses. L’ensemble des middlewares constitue un pipeline que chaque requête traverse. Chaque middleware peut :
- bloquer ou laisser passer la requête au prochain middleware ;
- analyser et modifier la requête (et la réponse associée).
Création du middleware
Section intitulée « Création du middleware »Créons le dossier Middlewares, puis le fichier ExceptionHandlingMiddleware.cs sous ce dossier.
RépertoireExceptionFilterWebApi
RépertoireProperties
- launchSettings.json
RépertoireMiddlewares
- ExceptionHandlingMiddleware.cs
- appsettings.json
- appsettings.Development.json
- ExceptionFilterWebApi.http
- Program.cs
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Middlewares;
internal sealed class ExceptionHandlingMiddleware(RequestDelegate next){ public async Task InvokeAsync(HttpContext context) { try { await next(context); } catch (Exception) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Server Error" };
context.Response.StatusCode = problemDetails.Status.Value; await context.Response.WriteAsJsonAsync(problemDetails); } }}La classe ExceptionHandlingMiddleware :
- injecte le gestionnaire de requête
RequestDelegate; - entoure d’un
try+catchle traitement de la requêteawait next(context);; - si le traitement de la requête fonctionne normalement, alors la requête passe au middleware suivant ;
- si le traitement de la requête lève n’importe quelle exception, alors celle-ci est interceptée.
Dans ce cas, un ProblemDetails est créé et associé :
- au code HTTP 500 (Internal Server Error) ;
- à un titre générique
"Erreur serveur".
La réponse est générée :
- avec le code statut HTTP 500 (Internal Server Error) ;
- avec un body contenant le
ProblemDetailssérialisé en JSON.
Inscription du middleware
Section intitulée « Inscription du middleware »Nous devons maintenant ajouter ce middleware dans le pipeline afin de l’activer.
using ExceptionFilterWebApi.Middlewares;using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi(); app.MapScalarApiReference();}
app.UseHttpsRedirection();app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapGet("/weatherforecast", () => { throw new Exception("Weather forecast"); }) .WithName("GetWeatherForecast");
app.Run();Nous pouvons maintenant relancer notre API Web.
Depuis Scalar, l’appel vers /weatherforecast retourne :
- Le code statut est :
500 Internal Server Error; - Le body est de type :
application/json; - Le body contient le JSON :
{ "title": "Server Error", "status": 500}Ce JSON correspond à ce que nous avons paramétré dans le middleware ExceptionHandlingMiddleware.
Aucune information sensible n’est exposée.
Bien entendu, nous pouvons personnaliser ce message selon nos besoins.
Conclusion
Section intitulée « Conclusion »Notre middleware, comme tous les middlewares, est invoqué à chaque requête. L’ordre des middlewares est important. Le nôtre doit être positionné dans le haut de la pile afin d’intercepter au plus tôt les éventuelles exceptions.
Gestionnaire d’exception
Section intitulée « Gestionnaire d’exception »Nous allons maintenant appliquer une autre solution pour intercepter les exceptions : IExceptionHandler.
Cette solution est disponible depuis .NET 8.
Elle fonctionne comme un middleware spécialisé dans la gestion des exceptions.
Création du handler
Section intitulée « Création du handler »Créons le dossier Handlers, puis le fichier GlobalExceptionsHandler.cs sous ce dossier.
RépertoireExceptionFilterWebApi
RépertoireProperties
- launchSettings.json
RépertoireHandlers
- GlobalExceptionsHandler.cs
RépertoireMiddlewares
- ExceptionHandlingMiddleware.cs
- appsettings.json
- appsettings.Development.json
- ExceptionFilterWebApi.http
- Program.cs
using Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class GlobalExceptionsHandler : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Server Error" };
httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; }}L’interface IExceptionHandler a une seule méthode TryHandleAsync() qui est invoquée si une exception est levée.
Cette méthode asynchrone a pour paramètres :
- le contexte HTTP (comme pour le middleware) ;
- l’exception levée.
Elle retourne un booléen indiquant si l’exception a été traitée ou pas. Cela nous permet d’avoir plusieurs handlers d’exception.
Le code de la méthode TryHandleAsync() est similaire à la méthode InvokeAsync du handler ExceptionHandlingMiddleware :
- créé un
ProblemDetails; - met à jour de la réponse ;
- retourne
trueadin d’indiquer que l’exception a été gérée.
Inscription du gestionnaire
Section intitulée « Inscription du gestionnaire »Nous pouvons maintenant mettre à jour Program.cs afin de déclarer et d’activer notre gestionnaire d’exceptions.
using ExceptionFilterWebApi.Middlewares;using ExceptionFilterWebApi.Handlers;using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
// Register exceptions handlersbuilder.Services.AddExceptionHandler<GlobalExceptionsHandler>();builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi();app.MapScalarApiReference();}
app.UseHttpsRedirection();app.UseMiddleware<ExceptionHandlingMiddleware>();app.UseExceptionHandler();
app.MapGet("/weatherforecast", () => { throw new Exception("Weather forecast"); }) .WithName("GetWeatherForecast");
app.Run();Voici les modifications que nous avons apportées :
- ajout du service
GlobalExceptionsHandlerdans le conteneur de dépendances ; - ajout du service
IProblemDetailsServicedans le conteneur de dépendances (requis) ; - inscription dans le pipeline du middleware de gestion d’exceptions ;
- suppression du middleware
ExceptionHandlingMiddleware.
Nous pouvons maintenant relancer notre API Web.
Depuis Scalar, l’appel vers /weatherforecast retourne :
- Le code statut est :
500 Internal Server Error; - Le body est de type :
application/json; - Le body contient le JSON :
{ "title": "Server Error", "status": 500}Conclusion
Section intitulée « Conclusion »Le résultat est identique au middleware générique que nous avons développé précédemment.
Cependant, les gestionnaires héritant de IExceptionHandler sont spécialisés et ne sont invoqués qu’en cas d’exception.
L’intention est ainsi beaucoup plus claire et nous ne pouvons pas modifier la requête ou la réponse
en dehors du traitement de l’exception.
Gestion selon l’exception
Section intitulée « Gestion selon l’exception »Nous avons présenté jusqu’à présent un cas d’usage assez simple : un gestionnaire “attrape-tout” qui intercepte toutes les exceptions.
Nous allons maintenant voir un autre cas d’usage avec une gestion spécifique par exception.
Création d’exceptions personnalisées
Section intitulée « Création d’exceptions personnalisées »Créons le dossier Exceptions,
puis les deux fichiers UnknownEntityException.cs et RequestValidationException.cs sous ce dossier.
RépertoireExceptionFilterWebApi
RépertoireProperties
- launchSettings.json
RépertoireExceptions
- RequestValidationException.cs
- UnknownEntityException.cs
RépertoireHandlers
- GlobalExceptionsHandler.cs
RépertoireMiddlewares
- ExceptionHandlingMiddleware.cs
- appsettings.json
- appsettings.Development.json
- ExceptionFilterWebApi.http
- Program.cs
namespace ExceptionFilterWebApi.Exceptions;
internal sealed class RequestValidationException : Exception{ public RequestValidationException() { } public RequestValidationException(string message) : base(message) { }}namespace ExceptionFilterWebApi.Exceptions;
internal sealed class UnknownEntityException : Exception{ public UnknownEntityException() { } public UnknownEntityException(string message) : base(message) { }}Ces deux exceptions sont très similaires :
elles héritent de Excecption et implémentent deux constructeurs de la classe de base.
Création d’un handler par exception
Section intitulée « Création d’un handler par exception »Sous le dosser Handlers, créons deux nouveaux gestionnaires :
RequestValidationExceptionHandler.cs et UnknownEntityExceptionHandler.cs.
RépertoireExceptionFilterWebApi
RépertoireProperties
- launchSettings.json
RépertoireExceptions
- RequestValidationException.cs
- UnknownEntityException.cs
RépertoireHandlers
- GlobalExceptionsHandler.cs
- RequestValidationExceptionHandler.cs
- UnknownEntityExceptionHandler.cs
RépertoireMiddlewares
- ExceptionHandlingMiddleware.cs
- appsettings.json
- appsettings.Development.json
- ExceptionFilterWebApi.http
- Program.cs
using ExceptionFilterWebApi.Exceptions;using Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class RequestValidationExceptionHandler : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { if (exception is not RequestValidationException requestValidationException) { return false; }
var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Bad Request", Detail = requestValidationException.Message };
httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; }}using ExceptionFilterWebApi.Exceptions;using Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
public class UnknownEntityExceptionHandler : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { if (exception is not UnknownEntityException unknownEntityException) { return false; }
var problemDetails = new ProblemDetails { Status = StatusCodes.Status404NotFound, Title = "Not Found", Detail = unknownEntityException.Message };
httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; }}Chaque gestionnaire traite son exception :
RequestValidationException↔RequestValidationExceptionHandler;UnknownEntityException↔UnknownEntityExceptionHandler.
Si l’exception n’est pas celle traitée par le gestionnaire,
alors il retourne false afin d’indiquer qu’il n’a pas géré l’exception.
Sinon un nouveau ProblemDetails est créé avec le code status HTTP correspondant à l’exception :
RequestValidationException→ code status HTTP 400 Bad Request ;UnknownEntityException→ code status HTTP 404 Not Found.
Le gestionnaire retourne alors true pour indiquer qu’il a géré l’exception.
Inscription des gestionnaires
Section intitulée « Inscription des gestionnaires »Nous allons maintenant voir comment déclarer ses deux nouveaux gestionnaires d’exception.
using ExceptionFilterWebApi.Handlers;using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
// Register exceptions handlerbuilder.Services.AddExceptionHandler<RequestValidationExceptionHandler>();builder.Services.AddExceptionHandler<UnknownEntityExceptionHandler>();builder.Services.AddExceptionHandler<GlobalExceptionsHandler>();builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi(); app.MapScalarApiReference();}
app.UseHttpsRedirection();app.UseExceptionHandler();
var summaries = new[]{ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};
app.MapGet("/weatherforecast", () => { throw new Exception("Weather forecast"); }) .WithName("GetWeatherForecast");
app.Run();Rien de plus simple : il suffit juste de les ajouter dans le conteneur de dépendances.
Par contre, l’ordre d’enregistrement des gestionnaires est important.
Nous avons conservé et positionné en dernier GlobalExceptionsHandler afin qu’il serve de ramasse-miettes
pour gérer les exceptions autres que RequestValidationException ou UnknownEntityException.
Mise à jour de l’API
Section intitulée « Mise à jour de l’API »Nous allons modifier notre API afin qu’elle exploite les exceptions que nous venons de créer.
using ExceptionFilterWebApi.Handlers;using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapibuilder.Services.AddOpenApi();
// Register exceptions handlerbuilder.Services.AddExceptionHandler<RequestValidationExceptionHandler>();builder.Services.AddExceptionHandler<UnknownEntityExceptionHandler>();builder.Services.AddExceptionHandler<GlobalExceptionsHandler>();builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){ app.MapOpenApi(); app.MapScalarApiReference();}
app.UseHttpsRedirection();app.UseExceptionHandler();
var summaries = new[]{ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};
app.MapGet("/weatherforecast", () => { throw new Exception("Weather forecast"); }) .WithName("GetWeatherForecast");
app.MapGet("/summary/{id}", (int id) => { if (id < 0) { throw new RequestValidationException($"'{id}' is not a valid id."); }
if (id > summaries.Length - 1) { throw new UnknownEntityException($"No summary found for '{id}'"); }
return summaries[id]; }) .WithName("GetSummary");
app.Run();Nous avons créé un nouvel endpoint /summary/{id} qui retourne l’une des valeurs du tableau summaries.
Le paramètre id est utilisé comme index du tableau : return summaries[id].
Avant de retourner la valeur, nous vérifions d’abord que id :
- n’est pas négatif, sinon
RequestValidationExceptionest levée ; - n’est pas hors index, sinon
UnknownEntityExceptionest levée.
Nous avons quelques cas de tests à vérifier.
Endpoint /weatherforecast
Section intitulée « Endpoint /weatherforecast »Tout d’abord vérifions la réponse à l’appel vers /weatherforecast.
- Le code statut est :
500 Internal Server Error; - Le body est de type :
application/json; - Le body contient le JSON :
{ "title": "Server Error", "status": 500}Rien n’a changé pour cet endpoint.
Endpoint /summary/{id}
Section intitulée « Endpoint /summary/{id} »Testons maintenant /summary/{id}.
Depuis le menu de gauche de Scalar, cliquons sur /summary/{id} GET,
puis sur ▶ Test Request dans la section à droite.
La popin correspondante est ouverte et nous pouvons indiquer une valeur pour le paramètre id.
Ce paramètre est présent sur la partie gauche, sous Path Variables.
Endpoint /summary/0
Section intitulée « Endpoint /summary/0 »Saisissons la valeur id = 0, puis appuyons sur ▶ Send.
- Le code statut est :
200 OK; - Le body est de type :
text/plain; - Le body a pour valeur :
"Freezing", première entrée du tableausummaries.
Endpoint /summary/9
Section intitulée « Endpoint /summary/9 »Modifions le paramètre id = 9, puis appuyons sur ▶ Send.
- Le code statut est :
200 OK; - Le body est de type :
text/plain; - Le body a pour valeur :
"Scorching", dernière entrée du tableausummaries.
Endpoint /summary/10
Section intitulée « Endpoint /summary/10 »Que se passe-t-il avec la valeur id = 10 ?
- Le code statut est :
404 Not Found. - Le body est de type :
application/json, a pour valeur ; - Le body contient le JSON :
{ "title": "Not Found", "status": 404, "detail": "No summary found for '10'"}Il s’agit bien du ProblemDetails que nous avons paramétré pour l’exception UnknownEntityException.
Endpoint /summary/-1
Section intitulée « Endpoint /summary/-1 »Maintenant testons avec la valeur id = -1.
- Le code statut est :
400 Bad Request. - Le body est de type :
application/json, a pour valeur ; - Le body contient le JSON :
{ "title": "Bad Request", "status": 400, "detail": "'-1' is not a valid id."}Ce ProblemDetails est également celui que nous avons codé pour RequestValidationException.
Conclusion
Section intitulée « Conclusion »Les gestionnaires d’exception sont un moyen très pratique de personnaliser le traitement des exceptions levées dans la Web Api.
Problem Details
Section intitulée « Problem Details »Nous avons utilisé régulièrement la classe ProblemDetails.
Selon la documentation Microsoft,
cette classe propose un :
Format lisible par l’ordinateur pour spécifier des erreurs dans les réponses de l’API HTTP basées sur https://tools.ietf.org/html/rfc7807.
La norme RFC 7807 définit un “problem details”. Mais ne nous y attardons pas, car elle a été remplacée par la norme RFC 9457.
Certes, mais quel est l’objectif de cette norme RFC 9457 ?
Elle répond à un besoin de standardiser le format retourné par une API Web lorsqu’une erreur survient.
Et justement la classe ProblemDetails nous aide à utiliser ce format standard.
Voici un exemple de ProblemDetails
{ "type": "https://tools.ietf.org/html/rfc9110#name-400-bad-request", "title": "Bad Request", "status": 400, "detail": "'-1' is not a valid id.", "instance": "GET /summary/-1"}Nous allons détailler chaque propriété.
La propriété Type est définie dans le chapitre 3.1.1.
Elle est de type string et contient un URI vers la documentation du type de problème.
Cette documentation doit être compréhensible par un humain.
Les codes statut HTTP sont listés sur la norme RFC 9110 :
- Erreurs client 4xx → https://tools.ietf.org/html/rfc9110#name-client-error-4xx ;
- Erreurs serveur 5xx → https://tools.ietf.org/html/rfc9110#name-server-error-5xx.
Nous pouvons ainsi récupérer le lien de code statut HTTP du problème
et nous en servir comme valeur de la propriété Type :
- Erreur 400 Bad Request → https://tools.ietf.org/html/rfc9110#name-400-bad-request ;
- Erreur 404 Not Found → https://tools.ietf.org/html/rfc9110#name-404-not-found ;
- Erreur 500 Internal Server Error → https://tools.ietf.org/html/rfc9110#name-500-internal-server-error ;
- et ainsi de suite pour chaque code statut HTTP.
Mais nous pouvons également fournir notre propre URI si nous souhaitons décrire nous-même le type de problème.
La propriété Status est définie dans le chapitre 3.1.2.
Elle est de type int et contient le code statut HTTP (400, 404, 500, etc.)
associé à cette occurrence du problème.
Ce code doit être obligatoirement identique à celui de la réponse HTTP.
La propriété Title est définie dans le chapitre 3.1.3.
Elle est de type string et contient un texte court décrivant le type de problème.
Ce texte doit être compréhensible par un humain.
Un type de problème doit toujours avoir le même titre.
La propriété Detail est définie dans le chapitre 3.1.4.
Elle est de type string et contient un texte décrivant cette occurrence du problème.
Ce texte doit être compréhensible par un humain.
Instance
Section intitulée « Instance »La propriété Instance est définie dans le chapitre 3.1.5.
Elle est de type string et contient l’URI qui a déclenché cette occurrence du problème.
Extensions
Section intitulée « Extensions »La propriété Extensions est définie dans le chapitre 3.2.
Elle est de type Dictionary<string, object?>
et permet d’ajouter des informations personnalisées et spécifiques au type de problème.
Service IProblemDetailsService
Section intitulée « Service IProblemDetailsService »Nous avons ajouté le service IProblemDetailsService via l’instruction :builder.Services.AddProblemDetails()
Mais nous ne l’avons pas utilisé dans nos gestionnaires.
Reprenons le code de GlobalExceptionHandler afin d’utiliser IProblemDetailsService.
using Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class GlobalExceptionsHandler : IExceptionHandlerinternal sealed class GlobalExceptionsHandler( IProblemDetailsService problemDetailsService) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Server Error", Type = "https://tools.ietf.org/html/rfc9110#name-500-internal-server-error", };
httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext { Exception = exception, HttpContext = httpContext, ProblemDetails = problemDetails }); }}Nous avons injecté IProblemDetailsService.
Ce service nous donne accès à deux méthodes TryWriteAsync() et WriteAsync().
Nous avons utilisé la méthode TryWriteAsync() qui retourne true
si elle a bien réussi à écrire le ProblemDetails dans le body de la réponse.
Relançons le projet.
Sur la page Scalar, l’appel vers /weatherforecast retourne :
- Le code statut est :
500 Internal Server Error; - Le body est de type :
application/problem+json; - Le body contient le JSON :
{ "type": "https://tools.ietf.org/html/rfc9110#name-500-internal-server-error", "title": "Server Error", "status": 500, "traceId": "00-751fd9b56104df2753c9e16aecfae368-0fcf40099134de5d-00"}Le type est bien celui contient bien l’URI que nous avons défini
La propriété traceId a été ajoutée automatiquement.
Elle contient une valeur générée aléatoirement et différente à chaque fois.
C’est facile à vérifier en renouvelant l’appel.
Le type du body a changé : application/json → application/problem+json.
Le type de contenu application/problem+json est bien celui indiqué dans la norme :
The Problem Details JSON Object.
Conclusion
Section intitulée « Conclusion »L’objet ProblemDetails nous permet de retourner une réponse normalisée lorsqu’une erreur survient dans notre API Web.
L’objet est extensible et permet de s’adapter à tous nos besoins.
Pour aller plus loin
Section intitulée « Pour aller plus loin »Dans cet article, nous avons de nouveau pu définir un middleware personnalisé, puis un gestionnaire d’exception. Ce dernier est également un middleware, mais dédié à une seule tâche : gérer les exceptions.
Les services IExceptionHandler et IProblemDetailsService forment un duo performant
qui nous permet, en cas d’erreur, de générer un ProblemDetails conforme à la norme RFC 9457.
Nous pouvons personnaliser le ProblemDetails grâce à la propriété Extensions
afin d’ajouter toutes les informations nécessaires pour le consommateur de notre API Web.
Pour conclure, nous devons garder en mémoire qu’il est important :
- de contrôler au maximum toutes les erreurs attendues et non attendues ;
- de filtrer les informations sensibles afin de ne pas les exposer à des gens malveillants ;
- de donner les informations strictement nécessaires lorsqu’une erreur contrôlée survient.
C’est un équilibre subtil entre expérience développeur (DX) et sécurité. Selon les contextes, il faut parfois sacrifier l’un au détriment de l’autre.