Skip to content

2. Contrôleurs, actions, routage

Ce chapitre étudie le chemin d'une requête, du navigateur jusqu'à l'action qui la traite, et la réponse que renvoie cette action. Les vues n'apparaîtront qu'au chapitre 4 : ici, les actions renvoient directement du texte ou du JSON.

2.1. Exemple 01 : la structure d'une application ASP.NET Core MVC

Le premier exemple est la plus petite application [ASP.NET Core MVC] possible. Outre les fichiers [Exemple01.csproj] et [appsettings.json] présentés en introduction, elle a deux fichiers :

1
2
3
4
5
6
01-structure/
├── Controllers/
│   └── AccueilController.cs
├── appsettings.json
├── Exemple01.csproj
└── Program.cs

2.1.1. Le point d'entrée : Program.cs

// le "constructeur" de l'application : configuration, journalisation, services...
var builder = WebApplication.CreateBuilder(args);

// on enregistre les services dont MVC a besoin pour trouver et exécuter les contrôleurs
builder.Services.AddControllers();

// on construit l'application
var app = builder.Build();

// les routes déclarées par les contrôleurs ([HttpGet("/")]...) sont activées
app.MapControllers();

// on démarre le serveur HTTP (http://localhost:5000 par défaut) ; Ctrl-C l'arrête
app.Run();

Commentons ce code :

  • ligne 2 : [WebApplication.CreateBuilder] crée le « constructeur » de l'application. Il lit la configuration (le fichier [appsettings.json], les variables d'environnement...), met en place la journalisation, et prépare la liste des services de l'application, [builder.Services]. Le paramètre [args] contient les arguments de la ligne de commande ;
  • ligne 5 : on ajoute aux services ceux dont [MVC] a besoin pour trouver les contrôleurs de l'application et exécuter leurs actions ;
  • ligne 8 : on construit l'application. Après cette ligne, la liste des services ne peut plus être modifiée ;
  • ligne 11 : les routes déclarées par les contrôleurs (les attributs [[HttpGet]], [[Route]]... que nous allons voir) sont activées : sans cette ligne, aucune action ne serait jamais appelée ;
  • ligne 14 : démarre le serveur web [Kestrel], et attend les requêtes jusqu'à ce qu'on arrête l'application ([Ctrl-C]). Sans autre indication, le serveur écoute sur le port 5000.

Ce fichier n'a ni classe ni méthode [Main] : c'est un programme à instructions de niveau supérieur. Le compilateur [C#] enveloppe lui-même ces instructions dans une méthode [Main], point d'entrée du programme. On remarque aussi l'absence de directives [using] : [WebApplication] appartient à l'espace de noms [Microsoft.AspNetCore.Builder], importé automatiquement (cf. [ImplicitUsings] en introduction).

2.1.2. Le contrôleur : Controllers/AccueilController.cs

using Microsoft.AspNetCore.Mvc;

namespace Exemple01.Controllers;

// un contrôleur est une classe qui hérite de Controller
public class AccueilController : Controller
{
    // une action : la méthode appelée pour la requête GET /
    [HttpGet("/")]
    public string Index()
    {
        // la valeur rendue par l'action est la réponse envoyée au navigateur
        return "Bonjour de la part d'ASP.NET Core MVC !";
    }
}
  • ligne 1 : l'espace de noms qui contient la classe [Controller] et les attributs de [MVC] ;
  • ligne 3 : l'espace de noms des classes de ce fichier. Par convention, il reprend le nom du projet ([Exemple01]) et le dossier du fichier ([Controllers]). La forme [namespace X;] (sans accolades) s'applique à tout le fichier ;
  • ligne 6 : un contrôleur est une classe publique qui hérite de la classe [Controller]. Par convention, son nom se termine par [Controller]. [ASP.NET Core] trouve tout seul les contrôleurs de l'application : il n'y a pas à les déclarer ;
  • ligne 9 : l'attribut [[HttpGet("/")]] associe la méthode [Index] aux requêtes [GET /]. Une méthode ainsi associée à une route est une action. Un attribut [C#] s'écrit entre crochets, juste avant ce qu'il décrit : il ajoute une information à la méthode, que le framework lit au démarrage ;
  • ligne 13 : la valeur rendue par l'action est la réponse envoyée au navigateur.

2.1.3. Exécution

Dans un terminal ouvert sur le dossier [01-structure], on tape [dotnet run]. Après la compilation, le serveur démarre :

1
2
3
4
5
6
7
8
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\cours\exemples\01-structure

Les messages de démarrage indiquent l'adresse d'écoute ([http://localhost:5000]) et l'environnement d'exécution : [Production], l'environnement par défaut. Nous verrons que certains comportements d'[ASP.NET Core] dépendent de l'environnement ([Development] ou [Production]). Demandons l'URL [http://localhost:5000/] avec un navigateur :

Image

puis avec [curl]. L'option [-i] affiche aussi les entêtes HTTP de la réponse (nous n'en gardons ici que quelques-uns) :

curl -i http://localhost:5000/
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Bonjour de la part d'ASP.NET Core MVC !

Une chaîne de caractères rendue par une action est envoyée avec l'entête [Content-Type: text/plain] : le navigateur l'affiche comme du texte brut, sans l'interpréter. C'est une différence avec [NestJS], qui l'envoyait comme du HTML ([text/html]). L'exemple 03 montrera comment envoyer du HTML.

2.2. Exemple 02 : le routage

Le routage associe une URL et une méthode HTTP (GET, POST...) à une action. L'exemple 02 a deux contrôleurs :

1
2
3
4
5
6
7
02-routage/
├── Controllers/
│   ├── AccueilController.cs
│   └── PremierController.cs
├── appsettings.json
├── Exemple02.csproj
└── Program.cs

Le fichier [Program.cs] est identique à celui de l'exemple 01 : c'est le cas de tous les exemples de ce chapitre, sauf le dernier. Le fichier [Controllers/AccueilController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple02.Controllers;

public class AccueilController : Controller
{
    // GET / : une page HTML qui contient des liens vers les autres actions
    [HttpGet("/")]
    public ContentResult Index()
    {
        const string html = """
            <h2>Exemple 02 - le routage</h2>
            <ul>
              <li><a href="/premier">/premier</a></li>
              <li><a href="/premier/action01">/premier/action01</a></li>
              <li><a href="/premier/produits/17">/premier/produits/17</a></li>
              <li><a href="/premier/produits/17/avis/3">/premier/produits/17/avis/3</a></li>
              <li><a href="/premier/fichiers/images/logo.png">/premier/fichiers/images/logo.png</a></li>
              <li><a href="/premier/produits/abc">/premier/produits/abc</a></li>
              <li><a href="/inconnue">/inconnue</a></li>
            </ul>
            """;
        // Content(texte, type) : une réponse dont on choisit le type (ici du HTML)
        return Content(html, "text/html; charset=utf-8");
    }
}
  • ligne 9 : l'action ne rend plus une chaîne, mais un [ContentResult] : une réponse dont on précise le contenu et le type ;
  • lignes 11-22 : une chaîne brute [C#] ([raw string literal]), délimitée par trois guillemets. Elle peut s'étendre sur plusieurs lignes et contenir des guillemets sans les échapper. Les espaces qui précèdent le ["""] fermant sont retirés de chaque ligne ;
  • ligne 24 : la méthode [Content(texte, type)] de la classe [Controller] fabrique un [ContentResult]. Le type [text/html] demande au navigateur d'interpréter le texte comme du HTML.

L'action [Index] renvoie ainsi une page HTML qui contient des liens vers les actions de l'autre contrôleur :

Image

Le fichier [Controllers/PremierController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple02.Controllers;

// préfixe commun à toutes les routes du contrôleur
[Route("premier")]
public class PremierController : Controller
{
    // GET /premier
    [HttpGet("")]
    public string Index()
    {
        return "Action [Index] du contrôleur [Premier]";
    }

    // GET /premier/action01
    [HttpGet("action01")]
    public string Action01()
    {
        return "Action [Action01] du contrôleur [Premier], requête GET";
    }

    // POST /premier/action01 : même chemin, autre méthode HTTP
    [HttpPost("action01")]
    public string Action01Post()
    {
        return "Action [Action01Post] du contrôleur [Premier], requête POST";
    }

    // GET /premier/produits/17 : {id} est un paramètre de route, ":int" une contrainte
    [HttpGet("produits/{id:int}")]
    public string Produit(int id)
    {
        return $"Produit n° {id}";
    }

    // GET /premier/produits/17/avis/3 : deux paramètres de route
    [HttpGet("produits/{id:int}/avis/{numero:int}")]
    public string Avis(int id, int numero)
    {
        return $"Avis n° {numero} du produit n° {id}";
    }

    // GET /premier/fichiers/... : {**chemin} accepte n'importe quelle fin de chemin
    [HttpGet("fichiers/{**chemin}")]
    public string Fichier(string chemin)
    {
        return $"Fichier demandé : {chemin}";
    }
}

Commentons ce code :

  • ligne 6 : l'attribut [[Route("premier")]], posé sur la classe, fixe le préfixe commun à toutes les routes du contrôleur : ses actions traitent des URL qui commencent par [/premier] ;
  • lignes 10-11 : [[HttpGet("")]] : l'action [Index] traite [GET /premier]. L'URL d'une action est la concaténation du préfixe du contrôleur et du chemin de l'action ;
  • lignes 17-18 : l'action [Action01] traite [GET /premier/action01] ;
  • lignes 24-25 : le même chemin, mais pour la méthode POST : [[HttpPost]] remplace [[HttpGet]]. Il existe aussi [[HttpPut]], [[HttpDelete]], [[HttpPatch]]... Le nom de la méthode [C#] ([Action01Post]) est libre : seuls les attributs comptent pour le routage. Dans une même classe, deux méthodes ne peuvent d'ailleurs pas porter le même nom avec les mêmes paramètres ;
  • lignes 31-32 : [{id}] est un paramètre de route : ce segment de l'URL peut prendre plusieurs valeurs. [:int] est une contrainte : le segment doit être un nombre entier, sinon la route ne correspond pas. La valeur du segment est passée au paramètre de même nom de l'action, [id], déjà convertie en [int] : pour [/premier/produits/17], [id] vaut le nombre 17. C'est la liaison de modèle ([model binding]), que le chapitre suivant étudiera en détail ;
  • lignes 38-39 : deux paramètres de route, passés à deux paramètres de l'action ;
  • lignes 44-46 : [{**chemin}] est un paramètre « attrape-tout » ([catch-all]) : il accepte n'importe quelle fin d'URL, y compris des [/].

Voici les réponses à quelques requêtes :

curl -i http://localhost:5000/premier
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Action [Index] du contrôleur [Premier]
curl -i http://localhost:5000/premier/action01
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Action [Action01] du contrôleur [Premier], requête GET
curl -i http://localhost:5000/premier/produits/17
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Produit n° 17
curl -i http://localhost:5000/premier/produits/17/avis/3
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Avis n° 3 du produit n° 17
curl -i http://localhost:5000/premier/fichiers/images/logo.png
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Fichier demandé : images/logo.png

Pour envoyer une requête POST, on utilise l'option [-X POST] de [curl] :

curl -i -X POST http://localhost:5000/premier/action01
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Action [Action01Post] du contrôleur [Premier], requête POST

Une méthode HTTP qu'aucune action ne traite pour ce chemin produit une erreur 405 ([Method Not Allowed]). L'entête [Allow] indique les méthodes acceptées :

curl -i -X PUT http://localhost:5000/premier/action01
1
2
3
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Allow: GET, POST

Enfin, une URL qu'aucune route ne traite produit une erreur 404. C'est aussi le cas d'un identifiant qui ne respecte pas la contrainte [:int] : aucune route ne correspond à [/premier/produits/abc].

curl -i http://localhost:5000/premier/produits/abc
HTTP/1.1 404 Not Found
Content-Length: 0
curl -i http://localhost:5000/inconnue
HTTP/1.1 404 Not Found
Content-Length: 0

La réponse 404 d'[ASP.NET Core] n'a aucun contenu ([Content-Length: 0]) : le navigateur affiche sa propre page d'erreur.

Image

Le chapitre 7 montrera comment remplacer cette réponse vide par une page HTML.

2.3. Exemple 03 : les réponses d'une action

Ce que renvoie une action détermine la réponse HTTP. L'exemple 03 passe en revue les principaux cas. Le fichier [Controllers/ReponsesController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple03.Controllers;

[Route("reponses")]
public class ReponsesController : Controller
{
    // une chaîne : envoyée telle quelle, avec le type [text/plain]
    [HttpGet("texte")]
    public string Texte()
    {
        return "<h1>Action [Texte]</h1>";
    }

    // la même chaîne, annoncée comme du HTML : le navigateur l'interprète
    [HttpGet("html")]
    public ContentResult Html()
    {
        return Content("<h1>Action [Html]</h1>", "text/html; charset=utf-8");
    }

    // un objet : sérialisé en JSON, avec le type [application/json]
    [HttpGet("json")]
    public object Json()
    {
        return new { nom = "Martin", prenom = "Jean", age = 42 };
    }

    // un tableau : lui aussi sérialisé en JSON
    [HttpGet("tableau")]
    public string[] Tableau()
    {
        return ["Mme", "Melle", "Mr"];
    }

    // IActionResult : l'action choisit la réponse complète (code, contenu)
    // StatusCode(201, ...) : le code 201 (Created) au lieu de 200 (OK)
    [HttpGet("cree")]
    public IActionResult Cree()
    {
        return StatusCode(201, new { message = "ressource créée" });
    }

    // aucune réponse : le code 204 (No Content) signifie "rien à afficher"
    [HttpGet("vide")]
    public IActionResult Vide()
    {
        return NoContent();
    }

    // une action asynchrone : ASP.NET attend la fin de la tâche (Task)
    [HttpGet("tache")]
    public async Task<object> Tache()
    {
        var debut = DateTime.Now;
        await Task.Delay(500); // une attente de 500 ms, sans bloquer le serveur
        return new { attente = $"{(DateTime.Now - debut).TotalMilliseconds:0} ms" };
    }

    // une erreur "prévue" : NotFound(...) produit une réponse 404
    [HttpGet("introuvable")]
    public IActionResult Introuvable()
    {
        return NotFound(new { message = "Cette ressource n'existe pas" });
    }

    // une erreur "imprévue" : une exception non traitée produit une réponse 500
    [HttpGet("exception")]
    public string Exception()
    {
        throw new InvalidOperationException("Une erreur imprévue");
    }
}

Commentons ce code :

  • lignes 9-12 : une chaîne est envoyée telle quelle, avec l'entête [Content-Type: text/plain; charset=utf-8] ;
  • lignes 16-19 : la même chaîne, annoncée comme du HTML (cf. exemple 02) : le navigateur l'interprète ;
  • lignes 23-26 : un objet est sérialisé en JSON, avec l'entête [Content-Type: application/json]. [new { nom = ..., prenom = ... }] crée un objet d'un type anonyme : un type sans nom, fabriqué par le compilateur, commode pour une donnée qui ne sert qu'une fois. Les noms des propriétés du JSON sont écrits en « camelCase » (première lettre en minuscule), selon la convention de [JavaScript] ;
  • lignes 30-33 : un tableau est lui aussi sérialisé en JSON. [["Mme", "Melle", "Mr"]] est une expression de collection ([C#] 12), qui crée ici un tableau de chaînes ;
  • lignes 39-41 : quand l'action doit choisir le code de statut, elle rend un [IActionResult] : un objet qui décrit la réponse complète. Les méthodes de la classe [Controller] en fabriquent de nombreux : [StatusCode(201, objet)] produit une réponse de code 201 ([Created]) dont le contenu est l'objet sérialisé en JSON ;
  • lignes 46-48 : [NoContent()] produit une réponse de code 204 ([No Content]) : « pas de contenu » ;
  • lignes 53-57 : une action peut être asynchrone ([async]) : elle rend alors une tâche ([Task<T>]), l'équivalent d'une promesse [JavaScript], et [ASP.NET Core] attend la fin de la tâche avant de répondre. [await Task.Delay(500)] attend 500 ms sans bloquer le serveur : pendant l'attente, le thread qui exécutait l'action est rendu au serveur, qui peut s'en servir pour traiter d'autres requêtes. C'est ainsi que sont écrites toutes les actions qui interrogent une base de données ;
  • lignes 62-64 : une erreur prévue par le programme : [NotFound(objet)] produit une réponse 404, avec un contenu JSON. Il existe de même [BadRequest(...)] (400), [Unauthorized()] (401), [Forbid()] (403), [Ok(...)] (200)... ;
  • lignes 69-71 : une erreur imprévue : l'action lève une exception, qu'elle ne traite pas. [ASP.NET Core] répond alors par une erreur 500 ([Internal Server Error]).

Les réponses obtenues :

curl -i http://localhost:5000/reponses/texte
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

<h1>Action [Texte]</h1>
curl -i http://localhost:5000/reponses/html
1
2
3
4
5
HTTP/1.1 200 OK
Content-Length: 22
Content-Type: text/html; charset=utf-8

<h1>Action [Html]</h1>
curl -i http://localhost:5000/reponses/json
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"nom":"Martin","prenom":"Jean","age":42}
curl -i http://localhost:5000/reponses/tableau
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

["Mme","Melle","Mr"]
curl -i http://localhost:5000/reponses/cree
1
2
3
4
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"message":"ressource créée"}
curl -i http://localhost:5000/reponses/vide
HTTP/1.1 204 No Content
curl -i http://localhost:5000/reponses/tache
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"attente":"509 ms"}
curl -i http://localhost:5000/reponses/introuvable
1
2
3
4
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"message":"Cette ressource n'existe pas"}
curl -i http://localhost:5000/reponses/exception
HTTP/1.1 500 Internal Server Error
Content-Length: 0

Pour l'exception, la réponse ne dit rien de l'erreur : ses détails pourraient aider un attaquant. Ils sont écrits dans la console du serveur (nous n'en reproduisons que le début) :

1
2
3
4
5
fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HNOQTDR615N7", Request id "0HNOQTDR615N7:00000001": An unhandled exception was thrown by the application.
      System.InvalidOperationException: Une erreur imprévue
         at Exemple03.Controllers.ReponsesController.Exception() in ...\03-reponses\Controllers\ReponsesController.cs:line 71
         ...

Dans un navigateur, la différence entre [text/plain] et [text/html] est visible :

Image

Image

2.4. Exemple 04 : les redirections

Une action peut répondre par une redirection : un code 301 ou 302 et un entête [Location] qui indique au navigateur quelle URL demander ensuite. Le navigateur la demande aussitôt, sans intervention de l'utilisateur. Les redirections sont omniprésentes dans une application web : après un formulaire réussi, après une connexion, après un changement de langue...

Le fichier [Controllers/RedirectionsController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple04.Controllers;

public class RedirectionsController : Controller
{
    // redirection permanente (301) : la page a définitivement changé d'adresse
    [HttpGet("ancienne")]
    public IActionResult Ancienne()
    {
        return RedirectPermanent("/nouvelle");
    }

    [HttpGet("nouvelle")]
    public string Nouvelle()
    {
        return "Vous êtes sur la page [nouvelle]";
    }

    // redirection temporaire (302), vers une adresse calculée
    [HttpGet("aller/{destination}")]
    public IActionResult Aller(string destination)
    {
        return Redirect($"/page/{destination}");
    }

    // redirection vers une ACTION : ASP.NET fabrique lui-même l'URL
    [HttpGet("vers-action/{destination}")]
    public IActionResult VersAction(string destination)
    {
        return RedirectToAction(nameof(Page), new { nom = destination });
    }

    [HttpGet("page/{nom}")]
    public string Page(string nom)
    {
        return $"Vous êtes sur la page [{nom}]";
    }

    // redirection vers un autre site
    [HttpGet("documentation")]
    public IActionResult Documentation()
    {
        return Redirect("https://learn.microsoft.com/aspnet/core");
    }
}

Commentons ce code :

  • lignes 9-11 : [RedirectPermanent(url)] produit une redirection permanente (code 301, [Moved Permanently]) : la page a définitivement changé d'adresse. Les navigateurs et les moteurs de recherche peuvent mémoriser la nouvelle adresse ;
  • lignes 22-24 : [Redirect(url)] produit une redirection temporaire (code 302, [Found]), ici vers une adresse calculée. [$"/page/{destination}"] est une chaîne interpolée : l'expression entre accolades est remplacée par sa valeur ;
  • lignes 29-31 : [RedirectToAction(action, valeurs)] redirige vers une action, et non vers une URL : [ASP.NET Core] fabrique lui-même l'URL de l'action [Page] à partir de sa route ([page/{nom}]) et des valeurs fournies ([nom = destination]). Si la route de l'action change un jour, la redirection suit. [nameof(Page)] rend le nom de la méthode (["Page"]) : une faute de frappe serait une erreur de compilation, ce qui n'est pas le cas d'une chaîne écrite à la main ;
  • lignes 42-44 : on peut rediriger vers un autre site.

[curl] montre la redirection sans la suivre :

curl -i http://localhost:5000/ancienne
1
2
3
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: /nouvelle

Avec l'option [-L], [curl] suit la redirection, comme le ferait un navigateur. On voit alors les deux réponses successives :

curl -i -L http://localhost:5000/ancienne
1
2
3
4
5
6
7
8
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: /nouvelle

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Vous êtes sur la page [nouvelle]
curl -i http://localhost:5000/aller/contact
1
2
3
HTTP/1.1 302 Found
Content-Length: 0
Location: /page/contact
curl -i http://localhost:5000/vers-action/contact
1
2
3
HTTP/1.1 302 Found
Content-Length: 0
Location: /page/contact

Les deux actions produisent la même redirection, l'une avec une URL écrite à la main, l'autre avec une URL fabriquée par [ASP.NET Core]. Dans le navigateur, on ne voit que la page finale ; la barre d'adresse affiche la nouvelle URL :

Image

curl -i http://localhost:5000/documentation
1
2
3
HTTP/1.1 302 Found
Content-Length: 0
Location: https://learn.microsoft.com/aspnet/core

2.5. Exemple 05 : les objets Request et Response

Chaque requête est représentée par un objet [HttpContext], qui contient la requête ([Request]) et la réponse en cours de fabrication ([Response]). Une action y accède directement, par les propriétés [HttpContext], [Request] et [Response] de la classe [Controller]. C'est parfois nécessaire, et nous le ferons dans l'étude de cas (pour lire ou déposer un cookie, par exemple), mais il faut savoir ce que l'on fait.

Le fichier [Controllers/HttpController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple05.Controllers;

[Route("http")]
public class HttpController : Controller
{
    // la requête : méthode, chemin, paramètres, entêtes...
    [HttpGet("requete")]
    public object Requete()
    {
        return new
        {
            methode = Request.Method,
            chemin = Request.Path.Value,
            parametres = Request.QueryString.Value,
            navigateur = Request.Headers.UserAgent.ToString(),
            ip = HttpContext.Connection.RemoteIpAddress?.ToString(),
        };
    }

    // la réponse : on lui ajoute un entête, puis l'action rend normalement sa valeur
    [HttpGet("entete")]
    public string Entete()
    {
        Response.Headers["X-Exemple"] = "05-httpcontext";
        return "Réponse avec un entête ajouté";
    }

    // la réponse écrite "à la main" : code, type, puis le corps
    [HttpGet("reponse")]
    public async Task Reponse()
    {
        Response.StatusCode = 200;
        Response.ContentType = "text/plain; charset=utf-8";
        await Response.WriteAsync("Réponse écrite par [Response.WriteAsync]");
    }

    // ERREUR à ne pas commettre : écrire la réponse, PUIS rendre un résultat
    [HttpGet("double")]
    public async Task<IActionResult> Double()
    {
        await Response.WriteAsync("Début de la réponse... ");
        // la réponse est déjà partie : son code et ses entêtes ne peuvent plus changer
        return NotFound();
    }
}

Commentons ce code :

  • lignes 10-18 : la requête : la méthode HTTP ([Request.Method]), le chemin ([Request.Path]), les paramètres de l'URL ([Request.QueryString]), les entêtes ([Request.Headers]), et, par [HttpContext.Connection], l'adresse IP du client. [?.] est l'opérateur « conditionnel null » : si [RemoteIpAddress] vaut [null], l'expression vaut [null] au lieu de provoquer une erreur ;
  • lignes 24-27 : l'action ajoute un entête à la réponse, puis rend normalement sa valeur, qu'[ASP.NET Core] envoie. C'est l'usage le plus courant de [Response] ;
  • lignes 32-36 : l'action écrit elle-même la réponse : code de statut, type, puis le corps, par [Response.WriteAsync]. Elle ne rend rien ([Task], et non [Task<T>]) ;
  • lignes 41-45 : l'erreur à ne pas commettre : écrire une partie de la réponse, puis rendre un résultat. Dès que les premiers octets du corps sont partis vers le navigateur, le code de statut et les entêtes, envoyés avant le corps, ne peuvent plus changer.

Les réponses obtenues :

curl -i -A "Mozilla/5.0 (exemple)" "http://localhost:5000/http/requete?nom=martin"
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"methode":"GET","chemin":"/http/requete","parametres":"?nom=martin","navigateur":"Mozilla/5.0 (exemple)","ip":"127.0.0.1"}

(l'option [-A] de [curl] fixe l'entête [User-Agent], qui identifie le navigateur)

curl -i http://localhost:5000/http/entete
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Exemple: 05-httpcontext

Réponse avec un entête ajouté
curl -i http://localhost:5000/http/reponse
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Réponse écrite par [Response.WriteAsync]
curl -i http://localhost:5000/http/double
1
2
3
HTTP/1.1 200 OK

Début de la réponse... 

Pour la dernière action, le navigateur reçoit un code 200 et le début de la réponse, et non la 404 demandée. La console du serveur signale l'erreur :

1
2
3
4
fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HNOQTDT0BVP1", Request id "0HNOQTDT0BVP1:00000001": An unhandled exception was thrown by the application.
      System.InvalidOperationException: StatusCode cannot be set because the response has already started.
      ...

Dans la suite du document, les actions rendront presque toujours un résultat ([IActionResult]), et laisseront [ASP.NET Core] écrire la réponse. [Request] et [Response] ne serviront qu'à lire la requête et à déposer des cookies.

2.6. Exemple 06 : services et injection de dépendances

Les contrôleurs ne doivent pas contenir la logique de l'application : ils la délèguent à des services. Un service est un objet ordinaire, qu'[ASP.NET Core] crée et fournit aux classes qui en ont besoin : c'est l'injection de dépendances. L'exemple 06 découpe une petite application en deux parties, les produits et les clients :

06-services/
├── Clients/
│   └── ClientsController.cs
├── Produits/
│   ├── IProduitsService.cs
│   ├── Produit.cs
│   ├── ProduitsController.cs
│   ├── ProduitsExtensions.cs
│   └── ProduitsService.cs
├── appsettings.json
├── Exemple06.csproj
└── Program.cs

2.6.1. Le produit

1
2
3
4
namespace Exemple06.Produits;

// un "record" : une classe de données, dont les propriétés sont fixées par le constructeur
public record Produit(int Id, string Nom, decimal Prix);

Un record : une classe de données, déclarée en une ligne. Le compilateur crée les propriétés [Id], [Nom] et [Prix], un constructeur qui les initialise, et des méthodes utilitaires (comparaison, affichage). Le type [decimal] représente les montants sans erreur d'arrondi.

2.6.2. Le contrat du service : IProduitsService

1
2
3
4
5
6
7
8
namespace Exemple06.Produits;

// le CONTRAT du service : ce qu'il sait faire, sans dire comment
public interface IProduitsService
{
    IReadOnlyList<Produit> FindAll();
    Produit? FindById(int id);
}
  • ligne 4 : une interface décrit ce que sait faire le service, sans dire comment. Les classes qui utilisent le service ne connaîtront que cette interface ;
  • ligne 6 : [IReadOnlyList<Produit>] : une liste que l'appelant peut lire, mais pas modifier ;
  • ligne 7 : [Produit?] : un produit, ou [null] si aucun produit n'a cet identifiant (cf. [Nullable] en introduction).

2.6.3. L'implémentation : ProduitsService

namespace Exemple06.Produits;

// une IMPLÉMENTATION du contrat : ici, des données en mémoire
public class ProduitsService : IProduitsService
{
    private readonly List<Produit> produits =
    [
        new Produit(1, "stylo", 1.5m),
        new Produit(2, "cahier", 3.2m),
        new Produit(3, "gomme", 0.8m),
    ];

    public IReadOnlyList<Produit> FindAll() => produits;

    public Produit? FindById(int id) => produits.FirstOrDefault(p => p.Id == id);
}
  • ligne 4 : la classe implémente l'interface : elle doit fournir toutes ses méthodes ;
  • lignes 6-11 : les données, ici en mémoire, dans une liste initialisée par une expression de collection ;
  • lignes 13-15 : les deux méthodes, écrites avec la syntaxe [=>] (méthode dont le corps est une seule expression). [FirstOrDefault] ([LINQ]) rend le premier produit qui vérifie la condition, ou [null] s'il n'y en a pas.

2.6.4. Le contrôleur ProduitsController

using Microsoft.AspNetCore.Mvc;

namespace Exemple06.Produits;

[Route("produits")]
public class ProduitsController : Controller
{
    private readonly IProduitsService produitsService;

    // injection de dépendance : ASP.NET fournit l'implémentation de IProduitsService
    public ProduitsController(IProduitsService produitsService)
    {
        this.produitsService = produitsService;
    }

    [HttpGet("")]
    public IReadOnlyList<Produit> Liste()
    {
        return produitsService.FindAll();
    }

    [HttpGet("{id:int}")]
    public IActionResult UnProduit(int id)
    {
        var produit = produitsService.FindById(id);
        if (produit is null)
        {
            return NotFound(new { message = $"Produit n° {id} introuvable" });
        }
        return Ok(produit);
    }
}
  • lignes 11-13 : l'injection de dépendances. Le contrôleur déclare, dans son constructeur, qu'il a besoin d'un [IProduitsService] ; [ASP.NET Core] lui fournit l'objet enregistré pour ce type (cf. plus bas). Le contrôleur ne fait jamais [new ProduitsService()] : il ne connaît même pas cette classe, seulement l'interface. On pourrait remplacer l'implémentation (par une version qui lit une base de données, ou par une version de test) sans modifier le contrôleur ;
  • lignes 17-19 : l'action délègue au service. La liste est sérialisée en JSON ;
  • lignes 23-30 : [{id:int}] convertit l'identifiant ; un produit inexistant donne une réponse 404. [Ok(objet)] produit une réponse 200 dont le contenu est l'objet sérialisé en JSON. [is null] teste si une valeur est nulle.

2.6.5. Le contrôleur ClientsController

using Exemple06.Produits;
using Microsoft.AspNetCore.Mvc;

namespace Exemple06.Clients;

// "constructeur principal" (C# 12) : le paramètre du constructeur est écrit
// directement après le nom de la classe, et utilisable dans toute la classe
[Route("clients")]
public class ClientsController(IProduitsService produitsService) : Controller
{
    [HttpGet("panier")]
    public object Panier()
    {
        var produits = produitsService.FindAll();
        return new { client = "Jean Martin", articles = produits.Count, total = produits.Sum(p => p.Prix) };
    }
}
  • ligne 9 : un constructeur principal ([C#] 12) : le paramètre du constructeur est écrit directement après le nom de la classe. Il est utilisable dans toute la classe : plus besoin de le recopier dans un champ. C'est la forme la plus compacte de l'injection de dépendances ; elle sera utilisée dans la suite du document. [ClientsController] reçoit le même service que [ProduitsController] ;
  • lignes 12-15 : il l'utilise pour calculer le total d'un panier. [Sum] ([LINQ]) additionne les prix.

2.6.6. L'enregistrement des services

[ASP.NET Core] ne peut fournir un service que s'il le connaît : chaque service est enregistré dans [builder.Services], avec sa durée de vie. Le fichier [Produits/ProduitsExtensions.cs] :

namespace Exemple06.Produits;

// une "méthode d'extension" : elle s'appelle comme si elle faisait partie de
// IServiceCollection (builder.Services.AddProduits()). Elle regroupe les
// enregistrements de la partie "produits" de l'application.
public static class ProduitsExtensions
{
    public static IServiceCollection AddProduits(this IServiceCollection services)
    {
        // "quand on demande un IProduitsService, fournir un ProduitsService" ;
        // Singleton : une seule instance, partagée par toutes les requêtes
        services.AddSingleton<IProduitsService, ProduitsService>();
        return services;
    }
}
  • ligne 6 : une classe statique, qui ne contient que des méthodes statiques ;
  • ligne 8 : une méthode d'extension. Le mot-clé [this] devant le premier paramètre permet de l'appeler comme si elle faisait partie du type de ce paramètre : [builder.Services.AddProduits()]. C'est ainsi que les bibliothèques d'[ASP.NET Core] ajoutent leurs services ([AddControllers], [AddAuthentication]...), et c'est une bonne façon de regrouper les enregistrements d'une partie de l'application ;
  • ligne 12 : « quand une classe demande un [IProduitsService], lui fournir un [ProduitsService] ». [Singleton] est la durée de vie du service : une seule instance, créée à la première demande et partagée par toutes les requêtes. Il existe deux autres durées de vie : [Scoped] (une instance par requête HTTP) et [Transient] (une nouvelle instance à chaque demande). Le chapitre 6 y reviendra ;
  • ligne 13 : la méthode rend la liste des services, pour qu'on puisse enchaîner les appels.

Le fichier [Program.cs] :

using Exemple06.Produits;

var builder = WebApplication.CreateBuilder(args);

// les services de MVC
builder.Services.AddControllers();
// les services de la partie "produits" (cf. Produits/ProduitsExtensions.cs)
builder.Services.AddProduits();

var app = builder.Build();
app.MapControllers();
app.Run();
  • ligne 1 : l'espace de noms de la méthode d'extension ;
  • ligne 8 : l'enregistrement des services des produits.

Les contrôleurs, eux, ne sont pas enregistrés un à un : [AddControllers] les trouve tous, et [ASP.NET Core] crée un nouvel objet contrôleur pour chaque requête, en lui injectant les services qu'il demande.

2.6.7. Exécution

curl -i http://localhost:5000/produits
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":1,"nom":"stylo","prix":1.5},{"id":2,"nom":"cahier","prix":3.2},{"id":3,"nom":"gomme","prix":0.8}]
curl -i http://localhost:5000/produits/2
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":2,"nom":"cahier","prix":3.2}
curl -i http://localhost:5000/produits/9
1
2
3
4
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"message":"Produit n° 9 introuvable"}
curl -i http://localhost:5000/clients/panier
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"client":"Jean Martin","articles":3,"total":5.5}

L'étude de cas est construite exactement ainsi : des contrôleurs minces, qui délèguent à des services injectés, décrits par des interfaces et enregistrés dans [Program.cs].