API minimale (.NET 6)

Crédit

Dans le but d’apporter une réponse technique au besoin de pouvoir créer des applications web, Microsoft a développé la technologie ASP.NET quasi depuis les débuts de .NET. Quelques années plus tard, est arrivé ASP.NET MVC permettant de construire des pages web en utilisant le modèle Modèle-Vue-Controleur (MVC) de façon à permettre d’organiser le code lié à la GUI dans la vue et le code plus fonctionnel dans le controller. Lorsque .NET Core est apparu en 2016, ASP.NET MVC a été remplacé par ASP.NET Core. Avec l’arrêt du développement du framework .NET et le renommage de .NET Core en .NET en 2022, l’appelation ASP.NET Core a été abandonnée pour revenir à ASP.NET. Fonctionnellement ASP.NET regroupe des cas d’utilisations assez différents liés aux applications web: ASP.NET Web Forms, ASP.NET Web Pages, ASP.NET Web API etc… Même si la technologie sous-jacente est la même, chaque cas d’utilisation est adressé avec ces différents modèles de programmation.

Parmi ces modèles, ASP.NET Web API comme son nom l’indique, a pour but de créer des API Web. Il utilise la base ASP.NET MVC pour ne garder que les controllers qui répondent aux requêtes.

Les applications ASP.NET peuvent être construites en utilisant le design pattern Builder qui va permettre de rajouter et de configurer des fonctionnalités suivant son cas d’utilisation de l’application web.
Dans le cas d’ASP.NET, la classe IApplicationBuilder permet de rajouter des fonctionnalités à l’application et de les configurer.

Avec ASP.NET 6, quelques améliorations et changements ont été faits pour simplifier le code nécessaire pour créer une API. C’est dans ce cadre que sont apparues les API minimales.

Le but de cet article est de passer en revue les fonctionnalités les plus importantes des API minimales. L’objectif n’est pas de paraphraser la documentation officielle mais d’avoir rapidement une idée des caractéristiques et fonctionnalités des API minimales.

On peut créer une API minimale en exécutant avec le CLI .NET:

dotnet new web -o <nom du projet>

On obtient une application dont la quantité de code est très réduite:

var builder = WebApplication.CreateBuilder(args);     # Instanciation de WebApplicationBuilder
var app = builder.Build();                            # Instanciation de l'application web

app.MapGet("/", () => "Hello World!");                # Définition d'une réponse à la route GET à l'adresse "/"

app.Run();                                            # Exécution de l'API

Ce code permet d’implémenter en peu de code une API capable de répondre à une requête GET à l’adresse "/". Peu de lignes sont nécessaires pour implémenter l’API, il n’y a pas de lignes using pour indiquer les namespaces utilisés à cause de la fonctionnalité des namespaces implicites (C# 10).

Minimal API vs MVC

A partir de .NET 6 et ASP.NET 6, un effort de simplification a été fait pour ne pas être obligé d’utiliser MVC pour construire des applications web et des API. Que ce soit une application web ou une API, l’approche modulaire en utilisant le design pattern Builder (avec WebApplicationBuilder) permet de rendre une application ASP.NET facilement modifiable et rend aisé l’ajout de nouvelles fonctionnalités. L’intérêt étant d’avoir une application simple si on le désire, qui pourra facilement être perfectionnée par la suite suivant les besoins.

Par exemple, avec le CLI .NET, plusieurs possibilités pour créer une application ASP.NET:

  • dotnet new web pour créer une application web simple sous la forme d’une API minimale comme l’exemple précédent
  • dotnet new webapi pour créer une API avec des controllers.
  • dotnet new webapp ou dotnet new razor pour créer une application web avec des pages Razor.
  • dotnet new mvc pour créer une application web MVC (i.e. Model-View-Controller)
  • dotnet new angular ou dotnet new react pour créer une application Single Page (i.e. SPA), respectivement en Angular ou React.

Tous ces types d’applications ont un point commun, elles utilisent, toutes, la classe WebApplicationBuilder qui utilise le design pattern Builder pour ajouter des fonctionnalités à l’application avec:

WebApplicationbuilder builder = WebApplication.CreateBuilder(args);

Construire l’application avec:

WebApplication app = builder.Build();

Et exécuter l’application avec:

app.Run();

L’ajout de fonctionnalités plus spécifiques au type d’application se fait avec l’objet WebApplicationBuilder ou WebApplication avec l’exécution de fonctions comme:

  • builder.services.AddControllers() pour gérer les controllers dans le cadre d’une API web
  • builder.Services.AddRazorPages() pour rajouter la gestion des pages Razor.

On peut avoir une liste plus exhaustive des configurations possibles sur la page: learn.microsoft.com/fr-fr/dotnet/api/microsoft.aspnetcore.builder.iapplicationbuilder.

Fonctionnalités de l’API minimale

Comme on l’a vu précédemment, l’implémentation des API minimales est réduite de façon à minimiser la quantité de code nécessaire. Suivant son besoin, il faudra se poser la question de savoir si on souhaite implémenter une API minimale ou une application web.

Différences entre une API minimale et une application Web

Dans la documentation, une opposition est faite entre les API minimales et les API utilisant des controllers. De la même façon, on peut croire qu’il y a des grosses différences entre les API minimales, les applications Web utilisant Razor ou le modèle MVC. En réalité, toutes ces types d’application utilisent la même base de composants:

  • Microsoft.NETCore.App correspondant aux assemblies de .NET.
  • Microsoft.AspNetCore.App correspondant aux assemblies ASP.NET.

Le choix du type d’application se fait suivant les middlewares ou la configuration qui est faite par la suite. Il est très bien possible de combiner dans la même application tous les différents types d’applications. On peut très bien partir d’une API minimale qui ne répond qu’à un seul end-point et rajouter la gestion des controllers, puis la gestion des pages Razor etc…

Ainsi la version actuelle d’ASP.NET (version 7) a permis de concilier tous les différents types d’applications en implémentant des comportements différents au moment du routage d’une requête. La configuration de ce routage se fait avec des méthodes d’extensions qui ajoutent des fonctionnalités à l’application, par exemple:

  • MapRazorPages(this IEndpointRouteBuilder endpoints) dans Microsoft.AspNetCore.Mvc.RazorPages rajoute la gestion des pages Razor,
  • AddRazorPages(this IServiceCollection services) dans Microsoft.AspNetCore.Mvc ajoute les services utilisés par les pages Razor.
  • MapControllerRoute(this IEndpointRouteBuilder endpoints, ...) dans Microsoft.AspNetCore.Mvc.Core rajoute la gestion des controllers,
  • AddControllersWithViews(this IServiceCollection services) dans Microsoft.AspNetCore.Mvc pour ajouter les services utilisés par les vues dans le cadre du modèle MVC (Model-View-Controller).

Routing

Un des points clés des API minimales mais aussi des autres types d’applications est le routing. C’est un des composants le plus important qui permet de diriger les requêtes vers l’élément technique qui sera chargé de son exécution: cet élément technique peut être une expression lambda, une fonction, un controller, un middleware technique ou une page statique.

Paramètres dans la route

On peut paramétriser la route en indiquant des arguments, par exemple si on ajoute un paramètre:

app.MapGet("/order/{id}", (string id) => $"Returning order with id: {id}");

Par exemple, pour requêter cette route, on peut utiliser l’URL:

<URL de l'API>/order/FEW3Z

On peut aussi utiliser plusieurs paramètres:

app.MapGet("/client/{lastName}/{firstName}", (string lastName, string firstName) => $"Returning client named: {firstName} {lastName});

Contraintes sur les routes

On peut indiquer des contraintes sur les paramètres d’une route pour limiter les types possibles des paramètres ou définir des réponses différentes suivant l’application de ces contraintes.

D’une façon générale, la contrainte peut être définie avec la syntaxe:

{<nom paramètre>:<contrainte>}

La contrainte peut être sur:

  • Le type du paramètre, par exemple pour indiquer qu’un paramètre doit être un entier, par exemple {orderId:int}.
    D’autres types sont possibles:

    • bool: booléen,
    • datetime: date
    • float: nombre flottant
    • alpha: chaîne de caractères ne contenant que les caractères alphabétiques (de A à Z non sensible à la casse).
  • Taille d’une chaîne de caractères:
    • minlength(<taille minimum de la chaîne>): par exemple {firstName:minlength(2)}
    • maxlength(<taille maximum de la chaîne>): par exemple {firstName:maxlength(128)}
  • Expression régulière: regex(<expression régulière>)
  • Indiquer que le paramètre est indispensable avec l’indication required: {firstName:required}.

Une liste exhaustive des types est présentée sur: learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints.

MapGet

Dans le cadre des API minimales, les fonctions les plus immédiates pour implémenter des end-points sont:

  • EndpointRouteBuilderExtensions.MapGet() pour répondre à une requête GET,
  • EndpointRouteBuilderExtensions.MapPost() pour répondre à une requête POST,
  • EndpointRouteBuilderExtensions.MapPut() pour répondre à une requête PUT,
  • EndpointRouteBuilderExtensions.MapDelete() pour répondre à une requête DELETE,
  • EndpointRouteBuilderExtensions.MapPatch() pour répondre à une requête PATCH,
  • EndpointRouteBuilderExtensions.MapMethods() pour répondre à plusieurs types de requêtes
  • etc…

Ces méthodes permettent d’implémenter facilement une réponse à une requête, par exemple EndpointRouteBuilderExtensions.MapGet() permet de répondre à une requête GET. On indique le chemin de la route et le code à exécuter lorsque la route est sollicitée. Ce code peut être indiqué avec un delegate:

app.MapGet("/", delegate () { return "This is a GET response"; });

Par suite, une expression lambda:

app.MapGet("/", () => "This is a GET response");

Avec une expression lambda asynchrone:

app.MapGet("/", async () => { await Task.Run<string>(() => "This is a GET response"); });

Les syntaxes sont similaires pour MapPost(), MapPut() et MapDelete().

Avec la méthode MapMethods() , on peut indiquer les méthodes auxquelles le end-point doit répondre sous forme d’une liste de chaines de caractères, par exemple:

app.MapMethods("/", new List<string> { "GET", "PATCH" }, () => "This is a GET response");

Codes statut HTTP

On peut renvoyer des codes de statut HTTP en réponse avec la classe Microsoft.AspNetCore.Http.Results, par exemple:

app.MapGet("/", () => {
  return Results.Ok("This is a GET response");     // Code 200
});

D’autres codes de réponse sont possibles comme:

  • 400 (Bad request): Results.BadRequest()
  • 401 (utilisateur non authentifié): Results.NotAuthorized()
  • 404 (ressource non trouvée): Results.NotFound()
  • 403 (accès refusé): Results.Forbid()
  • 201 (Created): Results.Created()
  • etc…

La classe Results permet aussi de renvoyer directement un code avec:

Results.StatusCode(<code sous forme d'entier>);

D’autres types de retour sont aussi possibles:

  • Chaîne de caractères: Results.Text(<string à renvoyer>)
  • JSON: Results.Json(new { FirstName = "Douglas", LastName = "Crockford" })
  • Flux: Results.Stream(...)
  • Une redirection d’URL: Results.Redirect("/newURL")
  • Un fichier (dans le cas d’un téléchargement): Results.File(<chemin du fichier>)

Injection de dépendances

L’injection de dépendances est aussi supportée pour les API minimales. Comme pour les applications ASP.NET, un moteur d’injection de dépendances est nativement fourni. Pour configurer des objets à injecter, il faut utiliser le membre WebApplicationBuilder.Services de type IServiceCollection qui dispose de quelques méthodes pour effectuer cette configuration suivant la durée de vie voulue des objets:

  • Transient (i.e. éphémère): les objets enregistrés de cette façon sont instanciés à chaque fois qu’ils sont injectés.
  • Scoped: la même instance de l’objet sera utilisée dans le cadre d’une même requête HTTP. Ainsi une nouvelle instance est créée pour chaque requête à l’API.
  • Singleton: les objets de ce type sont créés une seule fois et la même instance est utilisée pendant toute la durée de vie.

Il existe plusieurs façons de configurer un objet à injecter. Ces différentes méthodes correspondent à des surcharges différentes des méthodes utilisées pour configurer ces objets:

  • On indique seulement le type de l’objet lors de la configuration. L’objet sera identifié par ce type lorsqu’il doit être injecté.
  • On indique un type par lequel l’objet sera identifié et le type réel de l’objet à injecter. Le type réel de l’objet doit dériver du type utilisé pour l’identifier. Lors de l’injection, l’objet sera identifié par le type utilisé pour l’identification de l’objet.
  • On indique une interface par laquelle l’objet sera identifié et le type réel de l’object à injecter. Le type réel de l’objet doit satisfaire l’interface utilisée pour l’identifier. Lors de l’injection, l’objet sera identifié par l’interface.
  • Enfin il est possible d’utiliser des factories pour instancier l’objet. On indique l’interface avec laquelle l’objet sera identifié. Lors de l’injection, l’objet sera identifiée par l’interface et la factory sera appelée pour instancier l’objet.

Pour chaque durée de vie, la méthode pour configurer l’objet dans le moteur d’injection de dépendances est:

  • Transient: IServiceCollection.AddTransient()
  • Scoped: IServiceCollection.AddScoped()
  • Singleton: IServiceCollection.AddSingleton()

Par exemple, si on considère la classe et interface suivantes:

public interface IServiceToInject
{
  string InnerMember { get; }
}

public class ServiceToInject: IServiceToInject
{
  public string InnerMember => "Inner value";
}

On peut enregistrer le service de cette façon:

builder.Services.AddTransient<IServiceToInject, ServiceToInject>();

Lors de la configuration d’une route, on peut utiliser le service par injection, par exemple:

app.MapGet("/order/{id}", (string id, IServiceToInject instance) => $"This ID is: {id} and Inner member value: {instance.InnerMember}");

Si on requête l’API à l’adresse https://localhost:7120/order/ALDSE3XD

Le retour sera:

This ID is: ALDSE3XD and Inner member value: Inner value

Middlewares

Dans une application ASP.NET, les middlewares correspondent à des portions de code qui peuvent être exécutées lorsqu’une requête HTTP est reçue par une application ASP.NET Core. Ces portions de code sont exécutées successivement. Lorsqu’un middleware écrit une réponse correspondant à la requête, les middlewares suivants ne sont plus exécutés. Ainsi lorsqu’une requête HTTP parvient à l’API web, les portions de code correspondant aux middlewares vont être exécutées successivement jusqu’à ce qu’un des middlewares écrive la réponse. L’appel successif des différents middlewares s’appelle un pipeline de middlewares. Les middlewares sont ordonnés dans le pipeline et ils sont exécutés dans le même ordre.

Comme les API minimales sont des applications ASP.NET, de nombreux middlewares sont directement disponibles. Pour les ajouter au pipeline de middlewares, il faut généralement utiliser une méthode d’extension avec IApplicationBuilder.

CORS

Par exemple, pour configurer l’activation des requêtes multi-origines (i.e. Cross-Origin Resource Sharing ou CORS), on peut utiliser la méthode d’extension:
Microsoft.AspNetCore.Builder.WebApplication.UserCors().

Pour résumer, par sécurité les browsers empêchent un même script d’effectuer des requêtes HTTP vers des origines différentes. Si une requête est effectuée vers une origine différente, par défaut, la requête sera bloquée par le browser. Dans le cas où des ressources nécessitent des requêtes dans une origine différente, il faut activer des requêtes multi-origines (CORS) de façon à relâcher un élément de sécurité du browser. Cette activation se fait par le serveur répondant à la requête en indiquant les origines vers lesquelles le browser doit autoriser des requêtes multi-origines. Ces indications se font en ajoutant dans le header de la réponse le champ:
Access-Control-Allow-Origin avec la valeur correspondant à l’origine vers laquelle autoriser les requêtes.
Pour une explication plus complète, voir Cross-Origin Resource Sharing (CORS).

Dans le cadre d’une API, c’est donc cette API qui doit effectuer l’ajout du champ Access-Control-Allow-Origin. Le middleware CORS permet d’effectuer ce traitement. Ainsi si la requête reçue par l’API contient dans le header le champ: Origin avec une adresse à autoriser ou la wildcard "*" alors la réponse contiendra un champ Access-Control-Allow-Origin avec l’adresse autorisée si la configuration le permet.

Ainsi, sans activation du CORS, avec l’implémentation suivante:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/order/{id}", (string id) => $"Order ID is: {id}");
app.Run();

Si on effectue une requête GET https://localhost:7120/order/45345 sans champ particulier dans le header, on obtient la réponse suivante:

Order ID is: 45345

Si on rajoute le champ Origin dans le header de la requête:

Origin http://otherorigin.com

Pas de changement dans la réponse de l’API. A ce stade il n’y a pas d’activation du CORS.

Si on modifie l’implémentation de l’API:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
  options.AddDefaultPolicy(
    policy =>
    {
      policy.WithOrigins("http://otherorigin.com");
    });
});

var app = builder.Build();
app.UseCors();
app.MapGet("/order/{id}", (string id) => $"Order ID is: {id}");

app.Run();

Si on effectue une requête sans champ Origin, la réponse de l’API ne comporte pas de champ particulier.
Si on rajoute le champ Origin dans la requête:

Origin http://otherorigin.com

La réponse de l’API comporte 2 champs supplémentaires:

Access-Control-Allow-Origin http://otherorigin.com
Vary Origin

A ce stade le CORS est activé pour http://otherorigin.com.

Dans le cas où on veut activer le CORS pour toutes les URL, on peut configurer l’API en utilisant une wildcard "*":

builder.Services.AddCors(options =>
{
  options.AddDefaultPolicy(
    policy =>
    {
      policy.WithOrigins("*");
    });
});

Dans ce cas, quelque soit l’URL indiquée dans le header de la requête:

Origin http://xyz.com/code

La réponse de l'API comportera le champ:

Access-Control-Allow-Origin *

Autres middlewares

D'autres middlewares sont disponibles:

Pour avoir une liste plus exhaustive des middlewares disponibles voir learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis#aspnet-core-middleware.

Le code de cet article se trouve sur: github.com/msoft/minimal-api-example.

Références

Leave a Reply