Le routage en ASP.NET Core en 5 min

Quand on utilise la technologie ASP.NET Core pour implémenter une application web, il est possible d’utiliser le middleware et le pattern MVC (i.e. Model-View-Controler) pour organiser les classes qui répondront aux différentes requêtes HTTP. ASP.NET Core permet de router les requêtes vers les bonnes instances d’objets à condition que le “routage” soir configuré. Dans le cadre de MVC, cette fonctionnalité de “routage” (i.e. routing) va ainsi permettre de trouver le controller et l’action dans le controller qui sera évoquée en fonction de l’adresse web de la requête HTTP.

Le but de cet article n’est pas de paraphraser la documentation mais d’être un aide-mémoire sur les principales caractéristiques du routage. La documentation de Microsoft permet d’avoir des détails plus exhaustifs sur tous les aspects du routage.

Source: reddit.com/r/InfrastructurePorn

Fonctionnement général

Lorsqu’une requête HTTP est adressée à une application web ASP.NET Core, elle traverse différentes couches de l’application en utilisant le pipeline de middlewares. Ces middlewares sont évoqués successivement et permettent d’adresser différents points techniques liés à la requête comme par exemple la gestion d’erreurs, la gestion des cookies, l’authentification, la gestion de sessions etc… L’exécution successive des différents middlewares aboutira le cas échéant à créer une réponse à la requête. Le routage est l’un de ces middlewares.

Après avoir exécuté les middlewares précédents, la requête parvient au middleware routing (correspond aux classes dans le namespace Microsoft.AspNetCore.Routing) qui va effectuer les étapes suivantes:

  • Parser l’URL pour déterminer les différents paramètres de la requête.
  • En fonction des paramètres, trouver la route parmi les différentes routes configurées qui permettra de répondre à la requête.
  • Si une route est trouvée alors la requête est passée à une classe satisfaisant IRouteHandler (dans Microsoft.AspNetCore.Routing). Par défaut, la classe RouteHandler.
  • Si aucune route n’est trouvée, la requête est passée au middleware suivant.

Ainsi, le middleware routing est composé de différents objets:

  • Les routes (la classe correspondante est Route): elles définissent les différentes chemin de routage que pourrait emprunter la requête.
    Par exemple, une route pourrait être définie en utilisant l’expression:

    "{controller=Home}/{action=Index}/{id?}"
    
  • Une liste de routes (i.e. route collection): les routes sont testées successivement parmi cette liste pour déterminer qu’elle est la première qui convient. Si une route convient aux paramètres de la requête, les routes suivantes ne sont pas testées.
  • IRouter: la classe satisfaisant cette interface va être appelée pour déterminer quelle classe va traiter la requête pour en générer une partie de la réponse. Dans le cas d’une application ASP.NET Core MVC, le route handler, par défaut, est la classe MvcRouteHandler.

    Cette classe va chercher le controller et l’action dans le controller à évoquer pour générer une réponse.

Ces différents objets doivent être configurés pour que le routage s’effectue correctement.

Configurer les routes

Routage basique

Pour configurer un routage par défaut, il faut d’abord ajouter les services MVC pour indiquer que l’application ASP.NET Core utilisera ce pattern dans la classe Startup servant à la configuration initiale:

public class Startup 
{ 
  public void ConfigureServices(IServiceCollection services) 
  { 
     services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 
     // ...
  } 
}

SetCompatibilityVersion(CompatibilityVersion.Version_2_2) permet d’indiquer qu’on utilise une logique de routage compatible avec celle d’ASP.NET Core 2.2.

On indique ensuite, qu’on souhaite utiliser le middleware de routage et on l’ajoute au pipeline de middleware avec la ligne:

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
      app.UseMvc();
  } 
} 

UseMvc() ajoute le middleware de routage mais ne configure aucune route. Pour configurer une route par défaut, il faut utiliser la méthode (on explicitera par la suite quelle est la route par défaut):

app.UseMvcWithDefaultRoute(); 

Les 2 ajouts sont nécessaires services.AddMvc() et app.UseMvc().

Template d’une route

Par défaut, dans le cadre de MVC, une route se définit en indiquant un template. Ce template contient des indications concernant:

  • Le controller qui implémente le code permettant de construire la réponse à la requête,
  • L’action c’est-à-dire la fonction dans le controller qui sera exécutée pour générer la réponse,
  • Eventuellement des arguments nécessaires à l’exécution de l’action.

De façon générique, une route se définit en indiquant les différents éléments successivement:

"{controller}/{action}/{id}" 

L’utilisation de { } permet d’indiquer qu’un élément n’a pas une valeur fixe.

Par exemple, le template de la route définie précédemment sera utilisée si l’adresse appelée est du type:

pizzaOrder/get/23 

Dans ce cas:

  • {controller} est égal à pizzaOrder,
  • {action} est égal à get et
  • {id} est égal à 23

Elément à valeur fixe

On peut indiquer qu’un élément de l’adresse a une valeur fixe. Par exemple, si le template de la route est:

admin/{controller}/{action}/{id} 

admin est fixe et toutes les adresses devront commencer par admin pour être prise en compte par la route. Par exemple:

admin/pizzaOrder/get/23 

Elément facultatif

On peut indiquer qu’un élément est facultatif en utilisant ?, par exemple:

"{controller}/{action}/{id?}" 

Cette route correspond à des adresses du type:

pizzaOrder/list 
pizzaOrder/list/12

Element par défaut

Si un élément n’est pas précisé, alors la valeur par défaut sera utilisée. Pour préciser la valeur par défaut, il faut utiliser le caractère =<valeur par défaut>, par exemple:

"{controller}/{action=index}/{id?}" 

Dans ce cas, l’action par défaut sera index.

Si on utilise l’adresse:

pizzaOrder 

L’adresse équivalente sera: pizzaOrder/index (l’élément id étant facultatif).

Mise à part controller, action d’autres mots clés peuvent désigner des éléments précis dans l’application ASP.NET Core comme area, handler et page.

Template par défaut

Si on utilise app.UseMvcWithDefaultRoute() dans StartUp.Configure(), le template par défaut utilisé est:

"{controller=Home}/{action=Index}/{id?}" 

Ajouter une route

L’ajout explicite d’une route se fait en utilisant l’une des surcharges suivantes dans StartUp.Configure():

app.UseMvc(routes => {
  routes.MapRoute("<nom unique de la route>",  "<template de la route>");
}); 

Par exemple:

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
      app.UseMvc(routes => {    
         routes.MapRoute("default",  "{controller}/{action=index}");
     }); 
  } 
} 

D’autres surcharges sont possibles:

  • Pour indiquer que des éléments sont fixes:
    routes.MapRoute("Home", "{home}", new { Controller = "Home", Action = "Index" }); 
    

    Dans ce cas, la route nommée "Home" contient le template "{home}" désignant le controller HomeController et l’action Index.

  • Pour préciser des éléments par défaut:
    routes.MapRoute("Home", "{controller}/{action}", 
        defaults: new { Controller = "Home", Action = "Index" }); 
    

    Le template de la route est "{controller}/{action}" et les valeurs par défaut sont "Home" pour le controller et "Index" pour l’action.

  • Pour préciser des contraintes sur les éléments:
    routes.MapRoute("Home", "{controller}/{action}",  
       defaults: new { Controller = "Home", Action = "Index" }, 
       constraints: new { id = new IntRouteConstraint() }); 
    

    La contrainte impose que l’élément id doit être un entier.

  • D’autres contraintes existent comme:
    • Pour imposer une contrainte sur le type: BoolRouteConstraint, DateTimeRouteConstraint, DecimalRouteConstraint, DoubleRouteConstraint, GuidRouteContraint, FloatRouteConstraint ou LongRouteConstraint.
    • Pour imposer une contrainte sur la longueur d’une chaîne de caractères: MinLengthRouteConstraint ou MaxLengthConstraint
    • Pour imposer une contrainte sur une valeur: MinRouteConstraint, MaxRouteConstraint ou RangeRouteConstraint.
    • Pour imposer une contrainte avec une regex: RegexInlineRouteConstraint.
Ordre d’ajout des routes

L’ordre d’ajout des routes est important puisque le parcours des routes dans la liste de routes se fera dans l’ordre d’ajout de celles-ci.

Contrainte sur un élément d’une route

Il est possible d’indiquer des contraintes concernant un élément dans le template d’une route. Par exemple, si on considère le template de route suivant:

"{controller}/{action}/{id}" 

Si on souhaite ajouter des contraintes sur le paramètre id, il faut utiliser la syntaxe suivante:

"{controller}/{action}/{id:<contrainte 1>:<contrainte 2>:<etc...>}" 

On peut utiliser le caractère ? pour indiquer que le paramètre id est facultatif:

"{controller}/{action}/{id:<contrainte 1>:<contrainte 2>:<etc...>?}" 

Par exemple pour indiquer que le paramètre id doit être de type entier, on utilise la syntaxe:

"{controller}/{action}/{id:int}" 

Contraintes de type

Pour contraindre le type d’un paramètre, on peut utiliser les syntaxes suivantes:

Entier {id:int}
Alphabétique (caractères de A à Z et a à z seulement) {id:alpha}
bool {id:bool}
DateTime {id:datetime}
decimal {id:decimal}
double {id:double}
GUID {id:guid}
float {id:float}

Contraintes de valeurs

Pour contraindre un paramètre à avoir des valeurs dans un intervalle spécifique:

Chaine de caractères d’une longueur minimum (par exemple 5 caractères) {id:minlength(5)}
Chaine de caractères d’un longueur maximum (par exemple 10 caractères) {id:maxlength(10)}
Chaine de caractères de longueur spécifique (par exemple 7 caractères) {id:length(7)}
Chaine de caractères de longueur bornée (par exemple comprise entre 4 et 9 caractères) {id:length(4, 9)}
Entier supérieur ou égal (par exemple 5) {id:min(5)}
Entier inférieur ou égal (par exemple 14) {id:max(14)}
Entier compris dans un interval borné (par exemple entre 3 et 9) {id:range(3, 9)}

Contrainte avec une regex

Pour contraindre un paramètre à respecter une regex:

"{id:regex(<regex>)}" 

Par exemple:

"{id:regex(^2019$)}" 

Il faut utiliser des caractères d’échappement quand on utilise \,{, }, [ ou ]. Par exemple la regex ^\d{5}$ doit s’écrire:

"{id:regex(^\\d{{5}}$)}" 

Définition des routes avec des attributs

Il n’est pas obligatoire de préciser des routes en utilisant un template comme dans le paragraphe précédent. On peut utiliser des attributs placés sur:

  • un controller et/ou
  • une action d’un controller

Pour définir les routes par attributs, il faut utiliser la fonction suivante dans StartUp.Configure():

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
       app.UseMvc();
  } 
} 

RouteAttribute

Au niveau d’un controller

L’attribut RouteAttribute peut être utilisé au niveau d’un controller pour définir une route:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
    // ... 
} 

Ce controller sera appelé dès que l’adresse commence par PizzaOrder.

Au niveau d’une action

RouteAttribute peut aussi être utilisé au niveau d’une action:

[Route("PizzaOrder")] 
public class PizzaOrderController : Controller 
{ 
  [Route("Index")]
  public IActionResult Index()  
  { 
        // ... 
  } 
} 

L’action sera appelée si l’adresse est: PizzaOrder/Index.

Utilisation de “tokens” de remplacement

On peut utiliser des tokens pour éviter d’avoir à préciser le nom du controller ou de l’action. Au lieu d’indiquer le nom du controller ou de l’action, on utilise:

  • [controller] pour désigner le nom du controller
  • [action] pour désigner le nom de l’action.
  • [area] pour indiquer le nom de la zone.

Par exemple:

[Route("[controller]")]
public class PizzaOrderController : Controller 
{ 
   [Route("[action]")]
   public IActionResult Index()  
   { 
      // ... 
   } 
} 

On peut aussi tout préciser directement au niveau du controller:

[Route("[controller]/[action]")]
public class PizzaOrderController : Controller 
{ 
    public IActionResult Index()  
    { 
       // ... 
    } 
} 

La définition des routes se combinent

Les attributs utilisés au niveau d’un controller et d’une action se combinent toutefois il est aussi possible d’utiliser plusieurs attributs au même niveau. Par exemple si on indique:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
    [Route("")]  
    [Route("Index")]  
    [Route("/")]
    public IActionResult Index()  
    { 
       // ... 
   }
} 

L’action Index() est appelée si pour les adresses:

  • PizzaOrder à cause de [Route("PizzaOrder")] et [Route("")]
  • PizzaOrder/Index à cause de [Route("PizzaOrder")] et [Route("Index")]
  • "" (ou rien) à cause de [Route("PizzaOrder")] et [Route("/")]

HttpGetAttribute, HttpPostAttribute, HttpPutAttribute et HttpDeleteAttribute

Dans le cadre d’une API REST (i.e. REpresentational State Transfer) on utilise les verbes HTTP pour effectuer les requêtes comme:

  • GET pour effectuer des opérations de lecture sur une ressource,
  • POST pour créer une ressource,
  • PUT pour mettre à jour une ressource et
  • DELETE pour supprimer une ressource.

Il est possible d’utiliser les attributs suivants pour définir des routes correspondant aux requêtes contenant les verbes HTTP:

Ces attributs sont à utiliser au niveau des actions:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
   [HttpGet("")]
   public IActionResult ListOrders()  
   { 
       // ... 
   } 

   [HttpGet("{id}")]
   public IActionResult GetOrder(int id)  
   { 
       // ... 
   } 

   [HttpPost("")]
   public IActionResult CreateOrder(Order newOrder)  
   { 
       // ... 
   } 
} 

Dans ce cas, l’utilisation de ces attributs permet de différencier les actions à appeler même dans le cas où les adresses sont les mêmes, par exemple:

  • Requête GET à l’adresse PizzaOrder appelera l’action ListOrders() à cause de [Route("PizzaOrder")] et [HttpGet("")].
  • Requête GET à l’adresse PizzaOrder/23 appelera l’action GetOrder() à cause de [Route("PizzaOrder")] et [HttpGet("{id}")].
  • Requête POST à l’adresse PizzaOrder appelera l’action CreateOrder() à cause de [Route("PizzaOrder")] et [HttpPost("")].
Utiliser des contraintes

Il est possible d’utiliser des contraintes sur les paramètres utilisés avec les attributs comme pour les templates de route.

Par exemple, pour contraindre le paramètre id à être entier et à être compris entre 4 et 9:

[HttpGet("{id:int:range(4,9)}")]  
public IActionResult GetOrder(int id)  
{ 
   // ... 
} 

Exemples

Pour mettre en application le routage, on se propose de créer une Web Api.

Installation de .NET Core sur Linux

Pour installer .NET Core sur Debian, il faut suivre les étapes suivantes:

Il faut ensuite générer un squelette de Web Api en exécutant la commande:

user@debian:~/% dotnet new webapi --name webapi_example

Pour avoir Swagger et requêter facilement la Web API, on peut ajouter le package NuGet Swashbuckle.AspNetCore en exécutant la commande suivante:

user@debian:~/% dotnet add webapi_example/webapi_example.csproj package swashbuckle.aspnetcore

Pour configurer Swashbuckle, il faut ajouter les lignes suivantes dans StartUp.cs:

public class Startup 
{ 
  public void ConfigureServices(IServiceCollection services) 
  { 
    services.AddSwaggerGen(c => 
    { 
        // Permet de préciser de la documentation 
        c.SwaggerDoc("v1", new Info { Title = "My API", Version = "v1" }); 
    });
  } 
  
  public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
  { 
    app.UseSwagger(); 
  
    app.UseSwaggerUI(c => 
    { 
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "My simple API V1"); 
    });
  } 
} 

On ajoute les controllers suivants dans le répertoire Controllers:

On ajoute les services suivants après avoir créé le répertoire Services:

On enregistre les services dans le container d’injection de dépendances en ajoutant ces lignes dans StartUp.ConfigureServices():

public class Startup 
{ 
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddSingleton<IPizzaFlavourRepositoryService>(new PizzaFlavourRepositoryService()); 
        services.AddSingleton<IPizzaOrderRepositoryService, PizzaOrderRepositoryService>(); 
    } 
} 
} 

On lance la compilation et l’exécution en exécutant successivement les lignes:

user@debian:~/% cd webapi_example
user@debian:~/webapi_example/% dotnet build
user@debian:~/webapi_example/% dotnet run

On se connecte ensuite, avec un browser à l’adresse: http://localhost:5000/swagger/index.html.

Il est, ainsi, possible de requêter les fonctions des controllers PizzaFlavour et PizzaOrder avec Swagger.

Le code de cet exemple se trouve dans le repository GitHub suivant: https://github.com/msoft/webapi_example.

Routage avec des attributs

L’exemple dans la branche principale permet d’illustrer le routage avec attributs:

  • Avec les attributs RouteAttribute et ApiControllerAttribute sur les controllers par exemple:
    [Route("api/[controller]")] 
    [ApiController]
    public class PizzaOrderController : ControllerBase 
    { 
        // ... 
    } 
    
  • Avec les attributs HttpGetAttribute, HttpDeleteAttribute et HttpPostAttribute sur les functions correspondant aux actions, par exemple:
    [HttpGet]
    public ActionResult<IEnumerable<OrderedPizza>> GetOrderedPizzas() 
    { 
        // ... 
    } 
    
    [HttpPost("{pizzaFlavour}")]
    public ActionResult<int> AddNewOrder(string pizzaFlavour) 
    { 
        // ... 
    }
    

Routage avec template

La branche “usingRoutes” contient un exemple de routage en utilisant un template. Dans Startup.Configure(), on définit quelques templates:

app.UseMvc(routes => { 
    routes.MapRoute("secure", "secure", new { Controller = "Admin", Action="GetOrders"});  
    routes.MapRoute("admin", "{Controller=Admin}/{Action}/{id?}");  
    routes.MapRoute("default", "api/{Controller=PizzaOrder}/{Action=GetOrders}/{id?}"); 
}); 

Ainsi:

  • La route "secure" permet d’appeler toujours le controller AdminController avec l’action GetOrders. Pour l’invoquer il suffit d’effectuer une requête GET à l’adresse: http://localhost:5000/secure
  • La route "admin" permet de définir une route plus générale pour appeler le controller AdminController. Par exemple pour appeler l’action DeleteOrder, il faut effectuer une requête GET à l’adresse http://localhost:5000/admin/DeleteOrder/1
  • La route "default" définit une route plus générale pour appeler les controllers PizzaFlavourController et PizzaOrderController.

    Par exemple pour appeler l’action FindFlavour dans le controller PizzaFlavourController, il faut effectuer une requête GET à l’adresse http://localhost:5000/PizzaFlavour/FindFlavour/Regina

Utiliser cURL ou postman

Dans cet exemple, pour illustrer l’utilisation des templates, on a supprimé les attributs dans les controller AdminController et PizzaFlavourController. A cause de cette suppression, les fonctions ne sont plus visibles dans Swagger toutefois elles sont toujours fonctionnelles. Pour les invoquer, il faut utiliser:

  • cURL: par exemple exécutant à la ligne de commandes:
    curl -X GET "http://localhost:5000/PizzaFlavour/FindFlavour/Regina" -H  "accept: text/plain"
  • postman

Définir un route handler spécifique

On peut implémenter un routage plus personnalisé en implémentant une classe satisfaisant IRouter:

namespace Microsoft.AspNetCore.Routing 
{ 
    public interface IRouter 
    { 
        VirtualPathData GetVirtualPath(VirtualPathContext context); 
        Task RouteAsync(RouteContext context); 
    } 
} 

Dans la classe:

  • GetVirtualPath() permet de générer des url en fonction d’une route.
  • RouteAsync() permet d’indiquer un routage particulier en fonction d’une logique implémentée.

Dans l’exemple, l’implémentation de la classe satisfaisant IRouter est:

public class CustomRouter : IRouter 
{ 
    private IRouter _defaultRouter; 
    public CustomRouter(IRouter defaultRouter) 
    { 
        _defaultRouter = defaultRouter; 
    } 

    public VirtualPathData GetVirtualPath(VirtualPathContext context) 
    { 
        return _defaultRouter.GetVirtualPath(context); 
    } 

    public async Task RouteAsync(RouteContext context) 
    { 
         var path = context.HttpContext.Request.Path.Value; 

         if (path.Contains("admin")) 
         { 
             context.RouteData.Values["controller"] = "Admin"; 
             context.RouteData.Values["action"] = "GetOrders"; 

             await _defaultRouter.RouteAsync(context); 
         } 
    } 
} 

Cette classe va invoquer le controller AdminController si l’URL contient "admin".

Pour que cette classe soit prise en compte, il faut le configurer dans Startup.Configure() en précisant une autre route par défaut:

app.UseMvc(routes => 
{ 
    routes.Routes.Add(new CustomRouter(routes.DefaultHandler));
    routes.MapRoute( 
        name: "default", 
        template: "{controller=Home}/{action=Index}/{id?}"); 
}); 
A partir d’ASP.NET Core 2.2

Le middleware permettant d’effectuer le routage n’est plus le même à partir d’ASP.NET Core 2.2. Jusqu’à ASP.NET Core 2.1, le routage de l’URL vers le controller et l’action était effectué au niveau du middleware MVC. A partir d’ASP.NET Core 2.2, le routage est fait plus en amont et avant l’exécution du middleware MVC dans le pipeline, il est effectué par un middleware spécial appelé “Endpoint Routing”.

Comme on peut voir sur les schémas suivants:

ASP.NET Core ≤ 2.1 ASP.NET Core ≥ 2.2
Source: rolandguijt.com
Source: rolandguijt.com

Ainsi, à partir d’ASP.NET Core 2.2, il faut ensuite désactiver le routage par point de terminaison pour que le routage s’effectue avec IRouter avec la ligne suivante:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc(options => options.EnableEndpointRouting = false)
        .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 
 
    // ... 
} 
Références

Leave a Reply