1. Introduction

Dans beaucoup d’applications ASP.Net le pattern MVC (Modèle-vue-contrôleur) est implémenté. Lorsqu’on souhaite développer une API, la notion de contrôleur est souvent utilisée. Pour se détacher de cette approche propre au contrôle d’une IHM, nous vous proposons une approche différente avec l’utilisation de endpoints.

Un endpoint décrit la relation entre une route (URL) et un service web.

2. UseEndpoints

Nous allons parler ici de comment architecturer une API en .Net 5 sans utiliser de contrôleurs.

Depuis .Net Core 3 il est possible d’utiliser les endpoints dans Kestrel.

Pour ce faire, il suffit d’utiliser la méthode UseEndpoints dans le Configure de votre startup.cs :

app.UseRouting();
app.UseEndpoints(endpoints =>
{
    ...
});

endpoints implémente l’interface IEndpointRouteBuilder et expose des méthodes pour définir des routes directement dans le fichier startup.cs. :

  • MapGet
  • MapPost
  • MapPut
  • MapDelete …

Nous pouvons donc indiquer à Kestrel comment il doit interpréter les requêtes reçues pour telle ou telle route, exemple :

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/api/", async (context) => { context.Response.StatusCode = (int)HttpStatusCode.NoContent; });
});

Il est alors possible de créer toute une API de cette manière!

Prenons l’exemple d’une API qui gère des utilisateurs :

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/api/users/", async (context) =>
    {
        try
        {
            // Permet de récupérer les services des autres instances injectées
            var serviceProvider = context.RequestServices;
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            await context.Response.WriteAsync(ex.Message);
        }
    });
    endpoints.MapGet($"/api/users/{{id}}", async (context) =>
    {
        try
        {
            var serviceProvider = context.RequestServices;
            // On récupère l'Id depuis la route
            var stringValues = context.Request.RouteValues["id"].ToString();
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            await context.Response.WriteAsync(ex.Message);
        }
    });
    endpoints.MapPost("/api/users/", async (context) =>
    {
        try
        {
            var serviceProvider = context.RequestServices;
            var requestBody = await context.Request.ReadFromJsonAsync<object>();
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            await context.Response.WriteAsync(ex.Message);
        }
    });
});

Nous pouvons définir ici toutes les routes de notre API et agir en conséquence.

3. UseAPIEndpoints

Afin de mieux organiser le code, nous allons déporter la définition des routes dans une autre classe nommée APIRoutes.cs

public static class APIRoutes
{
    private const string _apiName = "api/users/";
    internal static IEndpointRouteBuilder UseAPIEndpoints(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet($"/{_apiName}/ ", async (context) =>
        {
            try
            {
                // Permet de récupérer les services est autres instances injectées
                var serviceProvider = context.RequestServices;
                context.Response.StatusCode = (int)HttpStatusCode.NoContent;
            }
            catch (Exception ex)
            {
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                await context.Response.WriteAsync(ex.Message);
            }
        });
        endpoints.MapPost($"/{_apiName}/", async (context) =>
        {
            try
            {
                var serviceProvider = context.RequestServices;
                var requestBody = await context.Request.ReadFromJsonAsync<object>();
                context.Response.StatusCode = (int)HttpStatusCode.NoContent;
            }
            catch (Exception ex)
            {
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                await context.Response.WriteAsync(ex.Message);
            }
        });
        endpoints.MapGet($"/{_apiName}/{{id}}", async (context) =>
        {
            try
            {
                var serviceProvider = context.RequestServices;
                // On récupère l'Id depuis la route
                var stringValues = context.Request.RouteValues["id"].ToString();
                context.Response.StatusCode = (int)HttpStatusCode.NoContent;
            }
            catch (Exception ex)
            {
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                await context.Response.WriteAsync(ex.Message);
            }
        });
        return endpoints;
    }
}

Notre UseEndpoints (startup.cs) devient donc :

app.UseEndpoints(endpoints =>
{
    endpoints.UseAPIEndpoints();
});

4. Factoriser le code

Cela allège déjà le fichier startup.cs mais si on s’arrête là, la classe APIRoutes est déjà conséquente alors que nous n’avons définit que trois endpoints !

Pour chaque endpoint nous allons créer une classe associée dans des fichiers différents . Chaque classe implémente une méthode statique Delegate contenant le code qui interprète la requête HTTP :

public class GetUsersDelegate
{
    public static RequestDelegate Delegate => async context =>
    {
        var serviceProvider = context.RequestServices;
        var logger = serviceProvider.GetService<ILogger<GetUsersDelegate>>();
        try
        {
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            logger.LogError(ex.Message);
            logger.LogTrace(ex.StackTrace);
            await context.KO(ex);
        }
    };
}
public class PostUserDelegate
{
    public static RequestDelegate Delegate => async context =>
    {
        var serviceProvider = context.RequestServices;
        var logger = serviceProvider.GetService<ILogger<PostUserDelegate>>();
        try
        {
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            logger.LogError(ex.Message);
            logger.LogTrace(ex.StackTrace);
            await context.KO(ex);
        }
    };
}
public class GetUserByIdDelegate
{
    public static RequestDelegate Delegate => async context =>
    {
        var serviceProvider = context.RequestServices;
        var logger = serviceProvider.GetService<ILogger<GetUserByIdDelegate>>();
        try
        {
            // On récupère l'Id depuis la route
            var stringValues = context.Request.RouteValues["id"].ToString();
            context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            logger.LogError(ex.Message);
            logger.LogTrace(ex.StackTrace);
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            await context.Response.WriteAsync(ex.Message);
        }
    };
}

La méthode UseEnvironmentEndpoints de APIRoutes.cs devient donc :

internal static IEndpointRouteBuilder UseEnvironmentEndpoints(this IEndpointRouteBuilder endpoints)
{
    endpoints.MapGet($"/{_apiName }/ ", GetUsersDelegate.Delegate);
    endpoints.MapGet($"/{_apiName }/{{id}}", GetUserByIdDelegate.Delegate);
    endpoints.MapPost($"/{_apiName }/", PostUserDelegate.Delegate);
    return endpoints;
}

Et voilà !

Rien ne vous empêche ensuite de créer d’autres fichiers de type APIRoutes.cs et de séparer vos APIs par domaine métier.

Voici à présent l’arborescence du projet :

Root
│   Programme.cs
│   Startup.cs
│
└───RequestDelegates
│   └───API
│       │   GetUserByIdDelegate.cs
│       │   GetUsersDelegate.cs
│       │   PostUserDelegate.cs
│       │   ...
│   
└───Routes
    │   APIRoutes.cs

5. Conclusion

Nous avons vu comment créer une API en se passant de contrôleurs.

Cette forme permet de centraliser la gestion des routes. Cela permet une compréhension rapide de ce que fait l’API à la simple lecture du fichier APIRoutes.cs. Il n’y a plus besoin de se balader dans les différents contrôleurs pour savoir ce que fait l’application. Libre à vous ensuite de séparer la définition des routes en plusieurs fichiers (par domaine métier par exemple).

On peut reprocher à cette méthode de perdre l’avantage que nous donne le constructeur du contrôleur lorsque plusieurs actions utilisent les mêmes instances de classe (injection de dépendance) cependant si une action ne nécessite pas tout ce qui est fait dans le constructeur (exemple : lecture de configuration), il peut y avoir une légère perte de performances. De plus si la couche HTTP de votre application est bien conçue, elle devrait avoir comme fonction principale de rediriger les requêtes vers les commandes / queries de la couche métier (cf. CQRS Command Query Responsibility Segregation).

Et pour ce qui est de la gestion de la sécurité ou autre filtre des requêtes, ASP.Net 5 nous fournis déjà tout l’attirail nécessaire pour ne pas implémenter ça dans nos requestDelegates : intergiciel (middleware), IAuthorizationHandler.

Liens Externes :