Loupe

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 les Mvc.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'autres statusCode, par exemple en rajoutant un Attribute sur notre enum ApiErrorCode qui nous décrirait le statusCode correspondant.

Une fois créé, nous rajoutons manuellement à notre ProblemDetails 2 propriétés custom :

  • code: Une enum ApiErrorCode (avec les valeurs EntityNotFound et InvalidStatusChange par exemple) qui correspond à un code d'erreur que l'on peut facilement réutiliser dans le Front end.
  • additionalDetails: Un object représentant toute information additionnelle qui permet une meilleure compréhension de l'erreur (par exemple une liste de AllowedStatus)

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, un JsonStringEnumConverter a également été ajouté pour retourner notre enum sous forme de string plutôt que de int.

Résultat d'erreur de l'API

Avec tout ça, l'API devrait nous retourner le résultat suivant en cas d'erreur: :

02a-generic-error-with-payload.png

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]);

02b-generic-error-with-payload.PNG

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 :

03-generic-error-with-swagger-support.PNG

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 !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus