Avec la sortie de la version 2.1 d’ASP.Net Core, nous avons à notre disposition un nouvel attribut nommé ApiController
. Comme l’indique le préfixe Api et la façon dont il est déclaré (voir le code ci-dessous), il s’agit d’un attribut destiné à être utilisé uniquement sur une classe et plus spécifiquement sur un contrôleur d’un service Web API. Cet attribut n’est aucunement utile sur un contrôleur renvoyant des vues Razor.
[AttributeUsage(AttributeTargets.Class, AllowMultiple=false, Inherited=true)]
public class ApiControllerAttribute : ControllerAttribute, IFilterMetadata, IApiBehaviorMetadata
Dans cet article nous allons discuter des avantages obtenus lorsqu’on utilise l’attribut ApiController
sur notre contrôleur.
Ce qu’on fait généralement
Dans le code ci-dessous on voit à quoi ressemble généralement un contrôleur Web API avant la sortie de la version 2.1 d’ASP.Net Core :
[Route("api/[controller]")]
public class PeopleController : ControllerBase
{
private readonly IPeopleService peopleService;
public PeopleController(IPeopleService peopleService)
{
this.peopleService = peopleService ?? throw new ArgumentNullException(nameof(peopleService));
}
[HttpGet]
public async Task<ActionResult<List<Person>>> Get()
{
var people = await this.peopleService.GetAllAsync();
return people;
}
[HttpGet("{id:int}", Name = "GetById")]
public async Task<ActionResult<Person>> Get(int id)
{
var person = await this.peopleService.GetAsync(id);
if (person == null)
{
return NotFound();
}
return person;
}
[HttpPost]
public async Task<ActionResult<Person>> Post([FromBody]PersonDto dto)
{
if(!this.ModelState.IsValid)
{
return this.BadRequest(this.ModelState);
}
var person = new Person
{
LastName = dto.LastName,
FirstName = dto.FirstName,
Gender = dto.Gender
};
await this.peopleService.CreateAsync(person);
return this.CreatedAtRoute("GetById", new { person.Id }, person);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Put(int id, [FromBody]PersonDto dto)
{
if (!this.ModelState.IsValid)
{
return this.BadRequest(this.ModelState);
}
var person = await this.peopleService.GetAsync(id);
if(person == null)
{
return NotFound();
}
person.FirstName = dto.FirstName;
person.LastName = dto.LastName;
person.Gender = dto.Gender;
await this.peopleService.UpdateAsync(person);
return this.NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var person = await this.peopleService.GetAsync(id);
if (person == null)
{
return NotFound();
}
await this.peopleService.DeleteAsync(person);
return this.NoContent();
}
}
Notre classe PersonDto
ressemble au code ci-dessous :
public class PersonDto
{
[Required, StringLength(20, MinimumLength = 2)]
public string LastName { get; set; }
[Required, StringLength(20, MinimumLength = 2)]
public string FirstName { get; set; }
public Gender? Gender { get; set; }
}
Eviter la duplication de code
Une première bonne raison d’utiliser cet attribut est le fait qu’il nous aide à éviter la duplication de code. Si on observe les actions Post
et Put
de notre contrôleur exemple PeopleController
, nous constatons que les 3 premières lignes de ces actions sont complètement identiques à savoir qu’elles contiennent les instructions ci-dessous :
if (!this.ModelState.IsValid)
{
return this.BadRequest(this.ModelState);
}
La condition this.ModelState.IsValid
renvoie true
si le modèle reçu en paramètre de l’action est valide à savoir que les règles de validations appliquées en utilisant les attributs d’annotations de données tels que Required
ou la méthode Validate
de l’interface IValidatableObject
(si elle est impélmentée) sont vérifiées avec succès.
Dans notre contrôleur, les propriétés FirstName
et LastName
sont requises et chacune doit avoir son nombre de caractère compris entre 2 et 20 caractères. Si ces propriétés ou l’une d’entre elles ne respectent pas les règles définies, nous renvoyons le code HTTP 400grâce à la méthode BadRequest
provenant de la classe de base ControllerBase
. Ainsi l’utilisateur de notre service Web API recevra en retour le contenu JSON suivant s’il ne respecte pas les règles de validation :
{
"LastName": [
"The LastName field is required."
],
"FirstName": [
"The field FirstName must be a string with a minimum length of 2 and a maximum length of 20."
]
}
Pour notre seul contrôleur nous répétons ce bloc de code deux fois dans la même classe pour cause nous devons être sûr que les données à la création et à la modification d’une entité sont valides avant de poursuivre tout traitement. Dans le cadre d’un vrai projet, pour X entités nous aurons à répéter ces 3 lignes dans les différentes actions où des saisies utilisateurs doivent subir une vérification.
En décorant notre contrôleur avec l’attribut ApiController
nous n’aurons plus besoin d’écrire et de dupliquer ce bloc de vérification à différents endroits. La vérification est automatiquement gérée par l’attribut. Si les données ne sont pas valides, il arrête l’exécution de l’action et renvoie le même code d’erreur HTTP 400.
Si vous avez déjà développé des API REST avec la technologie ASP.Net Web API ou avec une version antérieure à la version 2.1 d’ASP.Net Core et que vous n’aimez pas trop vous répétez alors vous avez peut-être eu à définir un filtre permettant de faire exactement ce que fait l’attribut ApiController
. Dans mon cas, mon filtre avec ASP.Net Web API 2 ressemble au code ci-dessous :
public class ModelStateValidationFilter : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)
{
if (!actionContext.ModelState.IsValid)
{
actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState);
}
}
}
Si vous êtes en train de migrer votre WEB API vers la version 2.1 d’ASP.Net Core alors le précédent filtre ne sert plus à rien, c’est du code mort et moins de code signifie moins de tests à écrire et aussi moins de bugs susceptibles d’être traités sur la partie concernant la vérification des données au niveau des actions de nos contrôleurs.
Bénéficier de nouvelles conventions par défaut sur les types des paramètres des actions
Toujours en observant les actions Post
et Put
nous remarquons que les paramètres de ces méthodes sont décorés de l’attribut FromBody
. Cela pour spécifier que les données des paramètres concernés proviennent du corps de la requête HTTP. En l’absence de cet attribut ASP.Net Core essayera de faire la liaison de données en recherchant les données à travers la chaîne de paramètres de la requête HTTP. Généralement les données des méthodes HTTP Post
et Put
sont representées par une structure complexe comme une classe alors pourquoi ne pas détecter ces types complexes et automatiquement décider que la liaison de données doit se faire en regardant le corps de la requête HTTP ? Cela nous évitera de polluer la déclaration des méthodes de nos actions avec des attributs tels que FromBody
. L’attribut ApiController
permet de bénéficier de certaines conventions.
Voici les conventions appliquées si ASP.Net Core détecte la présence de l’attribut ApiController
:
- L’attribut
FromBody
est automatiquement appliqué si le paramètre de l’action est un type complexe. Certains types complexes bénéficient d’une exception comme le typeIFormCollection
. - L’attribut
FromForm
est automatiquement appliqué au paramètre s’il est du typeIFormFile
ouIFormFileCollection
. - L’attribut
FromRoute
est utilisé si le nom du paramètre de la méthode de l’action correspond au nom d’un paramètre du template défini comme route. - L’attribut
FromQuery
généralement pour les types simples commeint
,string
etc..
En appliquant l’attribut ApiController
sur notre contrôleur, l’attribut FromBody
peut être retiré sur chacune des actions Post
et Put
:
[HttpPost]
public async Task<ActionResult<Person>> Post(PersonDto dto)
{
// [...]
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Put(int id, PersonDto dto)
{
// [...]
}
Une petite contrainte
La seule contrainte à l’utilisation de l’attribut ApiController
est que nous sommes obligés de passer par les attributs de routing pour définir les templates de nos routes. Cela veut tout simplement dire que les templates appliqués globalement en faisant appel à la méthode app.UseMvc()
ou app.UseMvcWithDefaultRoute()
n’auront aucun effet sur le contrôleur.
Personnellement cela ne me pose aucun problème de passer par les attributs de routing. Comme dit plus haut ce nouvel attribut a été créé pour le développement de contrôleurs utilisés par les services Web API et souvent ces derniers suivent plus ou moins l’architecture REST. Grosso modo si nous devons respecter les conventions REST, les templates tels que /api/people/{id:int}/addresses/{int:addressId}/ctiy/{int:cityId}
ne sont pas facilement mis en place en passant par un ou des templates uniques globaux utilisés au travers des méthodes app.UseMvc()
ou app.UseMvcWithDefaultRoute()
. La plupart des API REST développés avec ASP.Net Core ou ASP.Net Web API utilisent le plus souvent les attributs de routing. Alors forcer son utilisation doit juste être considérée, à mon avis, comme une petite contrainte qu’on aura vite oubliée.
La loi du « tout ou rien » n’existe pas
Les deux avantages cités jusqu’ici ne suivent pas la loi du « tout ou rien ». Certaines options automatiques de l’attribut ApiController
peuvent être désactivées ainsi vous pouvez opter pour l’un ou l’autre des avantages apportés par l’attribut.
Si par exemple vous n’aimez pas le deuxième avantage concernant l’application automatique des conventions sur la liaison des données, ASP.Net Core vous permet de désactiver cela en allant dans la classe Startup
puis dans la méthode ConfigureServices
et en y ajoutant les lignes suivantes :
services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressInferBindingSourcesForParameters = false;
});
Il existe aussi la propriété SuppressModelStateInvalidFilter
. Il s’agit de l’option permettant de désactiver l’automatisme appliqué sur la validation des données reçues utilisateur.
Version finale de notre contrôleur
En appliquant l’attribut ApiController
, voici à quoi ressemble la version finale de notre contrôleur utilisé comme exemple dans cet article :
[Route("api/[controller]")]
[ApiController]
public class PeopleController : ControllerBase
{
private readonly IPeopleService peopleService;
public PeopleController(IPeopleService peopleService)
{
this.peopleService = peopleService ?? throw new ArgumentNullException(nameof(peopleService));
}
[HttpGet]
public async Task<ActionResult<List<Person>>> Get()
{
var people = await this.peopleService.GetAllAsync();
return people;
}
[HttpGet("{id:int}", Name = "GetById")]
public async Task<ActionResult<Person>> Get(int id)
{
var person = await this.peopleService.GetAsync(id);
if (person == null)
{
return NotFound();
}
return person;
}
[HttpPost]
public async Task<ActionResult<Person>> Post(PersonDto dto)
{
var person = new Person
{
LastName = dto.LastName,
FirstName = dto.FirstName,
Gender = dto.Gender
};
await this.peopleService.CreateAsync(person);
return this.CreatedAtRoute("GetById", new { person.Id }, person);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Put(int id, PersonDto dto)
{
var person = await this.peopleService.GetAsync(id);
if(person == null)
{
return NotFound();
}
person.FirstName = dto.FirstName;
person.LastName = dto.LastName;
person.Gender = dto.Gender;
await this.peopleService.UpdateAsync(person);
return this.NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var person = await this.peopleService.GetAsync(id);
if (person == null)
{
return NotFound();
}
await this.peopleService.DeleteAsync(person);
return this.NoContent();
}
}
Des améliorations à venir dans la version 2.2
L’équipe d’ASP.Net Core a sorti le plan de route de ce qui nous attend pour la prochaine version 2.2 dont la RTM (Release To Manufacturer) est prévue avant la fin de l’année. Comme l’explique le plan de route, la version actuelle de ApiController
n’affecte que notre façon de développer nos Web API. Dans la version 2.2, il est prévu d’élargir les fonctionnalités de cet attribut en y intégrant d’autres conventions par défaut ainsi cela lui permettra de participer à la production des métadonnées pour les standards Swagger/OpenAPI
Si nous prenons l’exemple de l’action Get(int id)
de notre contrôleur exemple, nous avons en retour :
- soit une instance de la classe
Person
- soit un code HTTP 404.
Ainsi les métadonnées qu’on va y appliquer pour caractériser les retours sont actuellement définies comme suit :
[HttpGet("{id:int}", Name = "GetById")]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType(typeof(Person), (int)HttpStatusCode.OK)]
public async Task<ActionResult<Person>> Get(int id)
{
var person = await this.peopleService.GetAsync(id);
if (person == null)
{
return NotFound();
}
return person;
}
L’attribut ProducesResponseType
permet de spécifier quels sont les types de réponse susceptibles d’être obtenus de la part de notre action. L’utilisation de ces genres d’attributs ne sera plus utile bientôt vu que de nouvelles conventions par défaut seront intégrées dans la version 2.2 d’ASP.Net Core. Cela contribuera à rendre le code moins volumineux et présenter juste l’essentiel.