ASP.NET Core: Partage simple d'erreurs génériques avec React
Dans une application ASP.NET Core + React, il est plutôt courant de vouloir renvoyer quelques erreurs "standard" depuis n'importe quel API endpoint
, et surtout les présenter à l'utilisateur côté Front end de manière propre (grosso modo, sans que l'appli ne crash complètement par exemple :D) Pour des erreurs classiques bien connues tel que 401, 403, 404, etc., Pas besoin de customisation particulière côté ASP.NET Core si ce n'est renvoyer le bon StatusCode
, et utiliser un système Front end pour les gérer tel que les interceptors axios.
Mais, comment faire lorsque nous souhaitons améliorer ces erreurs "génériques" avec un peu plus de détail métier en ne retournant plus une simple "Bad Request", mais plutôt une explication de ce qui ne va pas du genre "ce statut est incorrect, seuls les statuts : STATUT1 et STATUT2 sont autorisés" ?
Et bien voyons une version assez simple d'un système que nous avons pu mettre en place à l'aide de mes collègues Thomas et Zackary.
L'intégralité du code source de l'exemple mentionné dans l'article est disponible sur Github.
Préambule : Standardiser la sortie d'erreurs avec ProblemDetails
?
Comment allons nous gérer les erreurs dans notre application? La classique question qui revient à chaque démarrage de projet, n'est-ce pas ?
Et bien depuis la version 2.2 d'ASP.NET Core, le format Problem Details (basé sur la RFC 7807) est devenu le format standard des réponses en erreur (pour les status code
>= 400), et ce système va nous fournir quelques réponses à notre question précédente.
Exemple de JSON renvoyé lors d'une erreur 404
en utilisant un ProblemDetails
:
{ type: "https://tools.ietf.org/html/rfc7231#section-6.5.4", title: "Not Found", status: 404, traceId: "0HLHLV31KRN83:00000001" }
Le principale intérêt du Problem Details
est de fournir un format "standard" et "simple à comprendre" pour définir le détail de nos erreurs. De plus, l'implémentation .NET possède une propriété IDictionary<string, object>
ProblemDetails.Extensions qui nous permet de définir librement des données additionnelles.
Mais trêve de bavardages, et rentrons dans le vif du sujet avec notre implémentation d'erreurs génériques, à commencer par le côté ASP.NET Core :
Gestion d'erreurs génériques ASP.NET Core avec les Mvc.Filters
et ProblemDetails
Nous avons besoin d'un moyen d'intercepter nos exceptions et d'avoir l'opportunité de les customiser/améliorer en ProblemDetails
avant de les retourner au Front end. Voyons comment répondre à ce scénario en rajoutant un Mvc.Filters
custom.
Note : Une autre solution possible aurait été de faire une implémentation custom de
ProblemDetailsFactory
comme suggéré dans la documentation. Mais pour des raisons de familiarité et simplicité, le principe est montré via lesMvc.Filters
.
Définition custom d'un Mvc.Filters
Pour notre scénario, nous avons juste besoin d'implémenter l'interface IExceptionFilter
et définir la méthode OnException
pour intercepter l'Exception
, vérifier son type et retourner le ProblemDetails
qui convient :
public class MyAppErrorFilter : IExceptionFilter { public void OnException(ExceptionContext context) { if (context.ExceptionHandled) { return; } var result = context.Exception switch { EntityNotFoundException _ => context.CreateErrorResult(ApiErrorCode.EntityNotFound), InvalidStatusChangeException ex => context.CreateErrorResult(ApiErrorCode.InvalidStatusChange, additionalDetails: new { ex.AllowedStatus }), _ => null }; if (result != null) { result.DeclaredType = typeof(ProblemDetails); result.ContentTypes.Add("application/problem+json"); context.Result = result; context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; context.ExceptionHandled = true; } } }
Le switch
se fait sur le type de l'Exception
et appelle la méthode d'extension custom CreateErrorResult
suivante :
public static class ErrorHandlingExtensions { public static ObjectResult CreateErrorResult(this ActionContext context, ApiErrorCode errorCode, object additionalDetails = null) { var problemDetailsFactory = context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>(); var problemDetails = problemDetailsFactory.CreateProblemDetails(context.HttpContext, statusCode: (int?)HttpStatusCode.BadRequest); problemDetails.Extensions["code"] = errorCode; if (additionalDetails != null) { problemDetails.Extensions["additionalDetails"] = additionalDetails; } return new ObjectResult(problemDetails); } }
Dans cette méthode, nous récupérons la ProblemDetailsFactory
pour créer un ProblemDetails
à partir du HttpContext
et statusCode
.
Note : Dans cet exemple, seules les
BadRequest
sont gérées, mais on pourrait facilement imaginer une amélioration du système pour gérer d'autresstatusCode
, par exemple en rajoutant unAttribute
sur notre enumApiErrorCode
qui nous décrirait lestatusCode
correspondant.
Une fois créé, nous rajoutons manuellement à notre ProblemDetails
2 propriétés custom :
code
: Une enumApiErrorCode
(avec les valeursEntityNotFound
etInvalidStatusChange
par exemple) qui correspond à un code d'erreur que l'on peut facilement réutiliser dans le Front end.additionalDetails
: Unobject
représentant toute information additionnelle qui permet une meilleure compréhension de l'erreur (par exemple une liste deAllowedStatus
)
Disclaimer : L'exemple de code mentionné plus haut ne respecte pas intégralement l'ensemble des bonnes pratiques décrites dans la RFC du
ProblemDetails
pour des raisons de simplicité.
Enregitrement de notre filtre custom
Pour la touche finale, nous avons besoin d'enregistrer notre filtre custom dans la méthode ConfigureServices
de notre fichier Startup.cs
:
public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews().AddMvcOptions(options => { options.Filters.Add<MyAppErrorFilter>(); }) .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(options.JsonSerializerOptions.PropertyNamingPolicy, false)); }); }
Note : Pour une meilleure lisibilité de notre enum
ApiErrorCode
, unJsonStringEnumConverter
a également été ajouté pour retourner notre enum sous forme destring
plutôt que deint
.
Résultat d'erreur de l'API
Avec tout ça, l'API devrait nous retourner le résultat suivant en cas d'erreur: :
Parfait, après toute cette configuration, il ne nous reste plus qu'à gérer également ces erreurs dans la partie Front end (React dans notre exemple).
Interagir avec les API et intercepter les erreurs avec axios
La librairie axios
est plutôt classique lorsqu'il s'agit de gérer des appels API côté Front end, et une des fonctionnalités que j'apprécie particulièrement est sa gestion des interceptors.
Cela va nous permettre d'intercepter nos erreurs génériques retournées par l'API.
Le code nécessaire devrait être assez simple :
let axiosInstance = axios.create(); axiosInstance.interceptors.response.use((response: AxiosResponse<any>) => { return response; }, (error) => { const problemDetails: ProblemDetails = error.response.data; switch (problemDetails.code) { case ApiErrorCode.EntityNotFound: toast.error("EntityNotFound!"); break; case ApiErrorCode.InvalidStatusChange: toast.error(`Invalid Status Change! Allowed status: ${(problemDetails.additionalDetails.allowedStatus as string[]).join(', ')}`); break; default: // Do nothing and let specific custom business exception handling. } return Promise.reject(error); });
Dans cet exemple, nous rajoutons un interceptor
à une instance axios
ce qui nous donne accès à la gestion en cas d'erreur et déclenche le workflow suivant :
- Nous récupérons le
ProblemDetails
provenant de la réponse en erreur. - Nous regardons ensuite la propriété
code
pour déterminer le type d'erreur récupéré, et ainsi déclencher la gestion custom que nous souhaitons (déclencher une notification toast par exemple) - Remarquez que pour l'erreur de type
InvalidStatusChange
, nous accédons également à la propriétéadditionalDetails
qui nous fournit des détails complémentaires que nous pouvons ensuite formater pour l'afficher à nos utilisateurs. - Enfin, si l'erreur n'est pas l'une de nos "erreurs génériques", nous ne faisons aucun traitement supplémentaire pour laisser l'opportunité à notre application de gérer cette erreur spécifique par elle-même (avec un traitement métier spéficique à une seule page / composant par exemple)
De plus, pour rendre son usage plus simple, nous pouvons aussi l'envelopper dans un context React
et ainsi le rendre disponible partout dans notre application sous forme de custom React
hook :
type AxiosContext = { axiosInstance?: AxiosInstance }; const initialContext: AxiosContext = { axiosInstance: undefined }; const AxiosReactContext = createContext<AxiosContext>(initialContext); // 1 component to define an Axios Instance in a Context export const AxiosProvider: React.FunctionComponent<{ children: ReactNode }> = (props) => { const contextValue: AxiosContext = useMemo(() => { let axiosInstance = axios.create(); axiosInstance.interceptors.response.use( ... // configure axios interceptor as above ); return { axiosInstance }; }, []); return (<AxiosReactContext.Provider value={contextValue}> {props.children} </AxiosReactContext.Provider>) } // 1 custom hook to access the Axios Instance from the Context export const useAxios = () => useContext(AxiosReactContext).axiosInstance;
Dans cet exemple, je définis un custom React
hook dans lequel je configure mon interceptor
et l'expose ensuite au travers de la Context API React (N'hésitez pas à jeter un coup d'oeil aux articles de Jérémy sur le sujet)
Nous avons ensuite besoin de définir notre composant dans le fichier App.tsx
comme ci-dessous :
+import { AxiosProvider } from './custom-hooks/useAxios'; export default class App extends Component { static displayName = App.name; render() { return ( <Layout> + <AxiosProvider> <Route exact path='/' component={Home} /> <Route path='/counter' component={Counter} /> <Route path='/status' component={Status} /> <Route path='/fetch-data' component={FetchData} /> <ToastContainer position="bottom-right" /> + </AxiosProvider> </Layout> ); } }
Et ensuite l'utiliser depuis n'importe où dans notre application pour nos appels API :
const axios = useAxios(); useEffect(() => { (async () => { if (axios) { try { let result = await axios.get("/weatherforecast/1"); console.debug("result", result); } catch (error) { console.error("error", error); } } })() }, [axios]);
Et voilà ! Nous pouvons enfin appeler librement n'importe quelle API depuis notre application React
sans avoir besoin de se soucier de la gestion de nos erreurs génériques :)
En conclusion
C'est tout pour cet article, j'espère qu'il aura pu vous fournir quelques idées si vous étiez à la recherche d'une solution similaire.
Étant donné que ce problème est assez courant, j'imagine que nombre d'entre vous avez rencontré / implémenté des systèmes similaires.
N'hésitez pas à partager vos solutions dans les commentaires, et comme toujours vous pouvez me contacter sur Twitter @vivienfabing.
Bonus : Ajouter le support de swagger
Pour obtenir une jolie description de notre système de gestion d'erreurs (et notamment de nos ProblemDetails
) dans swagger comme ci-dessous :
Nous avons besoin de rajouter un custom ISchemaFilter
à notre configuration swagger comme décrit dans le commit
suivant : https://github.com/vfabing/simple-aspnetcore-react-shared-generic-errors/commit/c5ab26dc9f6f0ad8e71d19100f5c1f3c5ffc52f4
"Mais pourquoi attacher autant d'importance à obtenir une jolie description dans notre système swagger ?" me direz-vous. Et bien sachez que ces efforts nous serons très utiles dans le cas de la mise en place d'un système qui nous permettrait de générer automatiquement nos DTO
et clients API, mais c'est le sujet d'un prochain article de blog ;)
En attendant, que le code soit avec vous !
Commentaires