Construire une API Web ASP.NET avec ASP.NET Core
Publié: 2022-03-11introduction
Il y a plusieurs années, j'ai reçu le livre "Pro ASP.NET Web API". Cet article est le fruit des idées de ce livre, d'un petit CQRS et de ma propre expérience dans le développement de systèmes client-serveur.
Dans cet article, je couvrirai :
- Comment créer une API REST à partir de zéro à l'aide de .NET Core, EF Core, AutoMapper et XUnit
- Comment être sûr que l'API fonctionne après les modifications
- Comment simplifier au maximum le développement et le support du système API REST
Pourquoi ASP.NET Core ?
ASP.NET Core fournit de nombreuses améliorations par rapport à l'API ASP.NET MVC/Web. Premièrement, il s'agit désormais d'un cadre et non de deux. Je l'aime beaucoup parce que c'est pratique et qu'il y a moins de confusion. Deuxièmement, nous avons des conteneurs de journalisation et DI sans aucune bibliothèque supplémentaire, ce qui me fait gagner du temps et me permet de me concentrer sur l'écriture d'un meilleur code au lieu de choisir et d'analyser les meilleures bibliothèques.
Que sont les processeurs de requête ?
Un processeur de requêtes est une approche lorsque toute la logique métier relative à une entité du système est encapsulée dans un service et que tout accès ou action avec cette entité est effectué via ce service. Ce service est généralement appelé {EntityPluralName}QueryProcessor. Si nécessaire, un processeur de requêtes inclut des méthodes CRUD (create, read, update, delete) pour cette entité. Selon les besoins, toutes les méthodes peuvent ne pas être mises en œuvre. Pour donner un exemple précis, examinons ChangePassword. Si la méthode d'un processeur de requête nécessite des données d'entrée, seules les données requises doivent être fournies. Habituellement, pour chaque méthode, une classe de requête distincte est créée et, dans des cas simples, il est possible (mais pas souhaitable) de réutiliser la classe de requête.
Notre but
Dans cet article, je vais vous montrer comment créer une API pour un petit système de gestion des coûts, y compris les paramètres de base pour l'authentification et le contrôle d'accès, mais je n'entrerai pas dans le sous-système d'authentification. Je vais couvrir toute la logique métier du système avec des tests modulaires et créer au moins un test d'intégration pour chaque méthode API sur un exemple d'une entité.
Exigences pour le système développé : L'utilisateur peut ajouter, modifier, supprimer ses dépenses et ne voir que ses dépenses.
L'intégralité du code de ce système est disponible sur Github.
Alors, commençons à concevoir notre système petit mais très utile.
Couches API
Le diagramme montre que le système aura quatre couches :
- Base de données - Ici, nous stockons des données et rien de plus, pas de logique.
- DAL - Pour accéder aux données, nous utilisons le modèle d'unité de travail et, dans l'implémentation, nous utilisons l'ORM EF Core avec le code d'abord et les modèles de migration.
- Logique métier - pour encapsuler la logique métier, nous utilisons des processeurs de requêtes, seule cette couche traite la logique métier. L'exception est la validation la plus simple comme les champs obligatoires, qui seront exécutés au moyen de filtres dans l'API.
- API REST - L'interface réelle à travers laquelle les clients peuvent travailler avec notre API sera implémentée via ASP.NET Core. Les configurations de route sont déterminées par des attributs.
En plus des couches décrites, nous avons plusieurs concepts importants. Le premier est la séparation des modèles de données. Le modèle de données client est principalement utilisé dans la couche API REST. Il convertit les requêtes en modèles de domaine et vice versa d'un modèle de domaine à un modèle de données client, mais les modèles de requête peuvent également être utilisés dans les processeurs de requêtes. La conversion est effectuée à l'aide d'AutoMapper.
Structure du projet
J'ai utilisé VS 2017 Professional pour créer le projet. Je partage généralement le code source et les tests sur différents dossiers. C'est confortable, ça a l'air bien, les tests en CI s'exécutent facilement, et il semble que Microsoft recommande de procéder ainsi :
Description du projet:
Projet | La description |
---|---|
Dépenses | Projet pour contrôleurs, mappage entre modèle de domaine et modèle d'API, configuration d'API |
Dépenses.Api.Common | À ce stade, il existe des classes d'exceptions collectées qui sont interprétées d'une certaine manière par des filtres pour renvoyer des codes HTTP corrects avec des erreurs à l'utilisateur. |
Expenses.Api.Models | Projet pour les modèles d'API |
Dépenses.Accès.aux.données | Projet d'interfaces et mise en place du pattern Unit of Work |
Dépenses.Données.Modèle | Projet de modèle de domaine |
Dépenses.Requêtes | Projet pour les processeurs de requêtes et les classes spécifiques aux requêtes |
Dépenses.Sécurité | Projet d'interface et de mise en place du contexte de sécurité de l'utilisateur courant |
Références entre projets :
Dépenses créées à partir du modèle :
Autres projets dans le dossier src par modèle :
Tous les projets du dossier tests par template :
Mise en œuvre
Cet article ne décrira pas la partie associée à l'interface utilisateur, bien qu'elle soit implémentée.
La première étape a été de développer un modèle de données qui se trouve dans l'assembly Expenses.Data.Model
:
La classe Expense
contient les attributs suivants :
public class Expense { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public virtual User User { get; set; } public bool IsDeleted { get; set; } }
Cette classe prend en charge la "suppression logicielle" au moyen de l'attribut IsDeleted
et contient toutes les données pour une dépense d'un utilisateur particulier qui nous seront utiles à l'avenir.
Les classes User
, Role
et UserRole
font référence au sous-système d'accès ; ce système ne prétend pas être le système de l'année et la description de ce sous-système n'est pas l'objet de cet article ; par conséquent, le modèle de données et certains détails de la mise en œuvre seront omis. Le système d'organisation des accès peut être remplacé par un autre plus parfait sans changer la logique métier.
Ensuite, le modèle d'unité de travail a été implémenté dans l'assembly Expenses.Data.Access
, la structure de ce projet est affichée :
Les bibliothèques suivantes sont requises pour l'assemblage :
-
Microsoft.EntityFrameworkCore.SqlServer
Il faut implémenter un contexte EF
qui trouvera automatiquement les mappings dans un dossier spécifique :
public class MainDbContext : DbContext { public MainDbContext(DbContextOptions<MainDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { var mappings = MappingsHelper.GetMainMappings(); foreach (var mapping in mappings) { mapping.Visit(modelBuilder); } } }
Le mappage est effectué via la classe MappingsHelper
:
public static class MappingsHelper { public static IEnumerable<IMap> GetMainMappings() { var assemblyTypes = typeof(UserMap).GetTypeInfo().Assembly.DefinedTypes; var mappings = assemblyTypes // ReSharper disable once AssignNullToNotNullAttribute .Where(t => t.Namespace != null && t.Namespace.Contains(typeof(UserMap).Namespace)) .Where(t => typeof(IMap).GetTypeInfo().IsAssignableFrom(t)); mappings = mappings.Where(x => !x.IsAbstract); return mappings.Select(m => (IMap) Activator.CreateInstance(m.AsType())).ToArray(); } }
Le mapping vers les classes se trouve dans le dossier Maps
, et le mapping pour Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Interface IUnitOfWork
:
public interface IUnitOfWork : IDisposable { ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot); void Add<T>(T obj) where T: class ; void Update<T>(T obj) where T : class; void Remove<T>(T obj) where T : class; IQueryable<T> Query<T>() where T : class; void Commit(); Task CommitAsync(); void Attach<T>(T obj) where T : class; }
Son implémentation est un wrapper pour EF DbContext
:
public class EFUnitOfWork : IUnitOfWork { private DbContext _context; public EFUnitOfWork(DbContext context) { _context = context; } public DbContext Context => _context; public ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot) { return new DbTransaction(_context.Database.BeginTransaction(isolationLevel)); } public void Add<T>(T obj) where T : class { var set = _context.Set<T>(); set.Add(obj); } public void Update<T>(T obj) where T : class { var set = _context.Set<T>(); set.Attach(obj); _context.Entry(obj).State = EntityState.Modified; } void IUnitOfWork.Remove<T>(T obj) { var set = _context.Set<T>(); set.Remove(obj); } public IQueryable<T> Query<T>() where T : class { return _context.Set<T>(); } public void Commit() { _context.SaveChanges(); } public async Task CommitAsync() { await _context.SaveChangesAsync(); } public void Attach<T>(T newUser) where T : class { var set = _context.Set<T>(); set.Attach(newUser); } public void Dispose() { _context = null; } }
L'interface ITransaction
implémentée dans cette application ne sera pas utilisée :
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Son implémentation encapsule simplement la transaction EF
:
public class DbTransaction : ITransaction { private readonly IDbContextTransaction _efTransaction; public DbTransaction(IDbContextTransaction efTransaction) { _efTransaction = efTransaction; } public void Commit() { _efTransaction.Commit(); } public void Rollback() { _efTransaction.Rollback(); } public void Dispose() { _efTransaction.Dispose(); } }
Toujours à ce stade, pour les tests unitaires, l'interface ISecurityContext
est nécessaire, qui définit l'utilisateur actuel de l'API (le projet est Expenses.Security
) :
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
Ensuite, vous devez définir l'interface et l'implémentation du processeur de requêtes, qui contiendra toute la logique métier pour travailler avec les coûts - dans notre cas, IExpensesQueryProcessor
et ExpensesQueryProcessor
:
public interface IExpensesQueryProcessor { IQueryable<Expense> Get(); Expense Get(int id); Task<Expense> Create(CreateExpenseModel model); Task<Expense> Update(int id, UpdateExpenseModel model); Task Delete(int id); } public class ExpensesQueryProcessor : IExpensesQueryProcessor { public IQueryable<Expense> Get() { throw new NotImplementedException(); } public Expense Get(int id) { throw new NotImplementedException(); } public Task<Expense> Create(CreateExpenseModel model) { throw new NotImplementedException(); } public Task<Expense> Update(int id, UpdateExpenseModel model) { throw new NotImplementedException(); } public Task Delete(int id) { throw new NotImplementedException(); } }
L'étape suivante consiste à configurer l'assembly Expenses.Queries.Tests
. J'ai installé les librairies suivantes :
- Moq
- Assertions Fluent
Ensuite, dans l'assembly Expenses.Queries.Tests
, nous définissons le montage pour les tests unitaires et décrivons nos tests unitaires :
public class ExpensesQueryProcessorTests { private Mock<IUnitOfWork> _uow; private List<Expense> _expenseList; private IExpensesQueryProcessor _query; private Random _random; private User _currentUser; private Mock<ISecurityContext> _securityContext; public ExpensesQueryProcessorTests() { _random = new Random(); _uow = new Mock<IUnitOfWork>(); _expenseList = new List<Expense>(); _uow.Setup(x => x.Query<Expense>()).Returns(() => _expenseList.AsQueryable()); _currentUser = new User{Id = _random.Next()}; _securityContext = new Mock<ISecurityContext>(MockBehavior.Strict); _securityContext.Setup(x => x.User).Returns(_currentUser); _securityContext.Setup(x => x.IsAdministrator).Returns(false); _query = new ExpensesQueryProcessor(_uow.Object, _securityContext.Object); } [Fact] public void GetShouldReturnAll() { _expenseList.Add(new Expense{UserId = _currentUser.Id}); var result = _query.Get().ToList(); result.Count.Should().Be(1); } [Fact] public void GetShouldReturnOnlyUserExpenses() { _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get().ToList(); result.Count().Should().Be(1); result[0].UserId.Should().Be(_currentUser.Id); } [Fact] public void GetShouldReturnAllExpensesForAdministrator() { _securityContext.Setup(x => x.IsAdministrator).Returns(true); _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get(); result.Count().Should().Be(2); } [Fact] public void GetShouldReturnAllExceptDeleted() { _expenseList.Add(new Expense { UserId = _currentUser.Id }); _expenseList.Add(new Expense { UserId = _currentUser.Id, IsDeleted = true}); var result = _query.Get(); result.Count().Should().Be(1); } [Fact] public void GetShouldReturnById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); var result = _query.Get(expense.Id); result.Should().Be(expense); } [Fact] public void GetShouldThrowExceptionIfExpenseOfOtherUser() { var expense = new Expense { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfItemIsNotFoundById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); Action get = () => { _query.Get(_random.Next()); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfUserIsDeleted() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id, IsDeleted = true}; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public async Task CreateShouldSaveNew() { var model = new CreateExpenseModel { Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now }; var result = await _query.Create(model); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); result.UserId.Should().Be(_currentUser.Id); _uow.Verify(x => x.Add(result)); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task UpdateShouldUpdateFields() { var user = new Expense {Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); var model = new UpdateExpenseModel { Comment = _random.Next().ToString(), Description = _random.Next().ToString(), Amount = _random.Next(), Date = DateTime.Now }; var result = await _query.Update(user.Id, model); result.Should().Be(user); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); _uow.Verify(x => x.CommitAsync()); } [Fact] public void UpdateShoudlThrowExceptionIfItemIsNotFound() { Action create = () => { var result = _query.Update(_random.Next(), new UpdateExpenseModel()).Result; }; create.ShouldThrow<NotFoundException>(); } [Fact] public async Task DeleteShouldMarkAsDeleted() { var user = new Expense() { Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); await _query.Delete(user.Id); user.IsDeleted.Should().BeTrue(); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task DeleteShoudlThrowExceptionIfItemIsNotBelongTheUser() { var expense = new Expense() { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action execute = () => { _query.Delete(expense.Id).Wait(); }; execute.ShouldThrow<NotFoundException>(); } [Fact] public void DeleteShoudlThrowExceptionIfItemIsNotFound() { Action execute = () => { _query.Delete(_random.Next()).Wait(); }; execute.ShouldThrow<NotFoundException>(); }
Une fois les tests unitaires décrits, l'implémentation d'un processeur de requêtes est décrite :
public class ExpensesQueryProcessor : IExpensesQueryProcessor { private readonly IUnitOfWork _uow; private readonly ISecurityContext _securityContext; public ExpensesQueryProcessor(IUnitOfWork uow, ISecurityContext securityContext) { _uow = uow; _securityContext = securityContext; } public IQueryable<Expense> Get() { var query = GetQuery(); return query; } private IQueryable<Expense> GetQuery() { var q = _uow.Query<Expense>() .Where(x => !x.IsDeleted); if (!_securityContext.IsAdministrator) { var userId = _securityContext.User.Id; q = q.Where(x => x.UserId == userId); } return q; } public Expense Get(int id) { var user = GetQuery().FirstOrDefault(x => x.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } return user; } public async Task<Expense> Create(CreateExpenseModel model) { var item = new Expense { UserId = _securityContext.User.Id, Amount = model.Amount, Comment = model.Comment, Date = model.Date, Description = model.Description, }; _uow.Add(item); await _uow.CommitAsync(); return item; } public async Task<Expense> Update(int id, UpdateExpenseModel model) { var expense = GetQuery().FirstOrDefault(x => x.Id == id); if (expense == null) { throw new NotFoundException("Expense is not found"); } expense.Amount = model.Amount; expense.Comment = model.Comment; expense.Description = model.Description; expense.Date = model.Date; await _uow.CommitAsync(); return expense; } public async Task Delete(int id) { var user = GetQuery().FirstOrDefault(u => u.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } if (user.IsDeleted) return; user.IsDeleted = true; await _uow.CommitAsync(); } }
Une fois la logique métier prête, je commence à écrire les tests d'intégration d'API pour déterminer le contrat d'API.
La première étape consiste à préparer un projet Expenses.Api.IntegrationTests
- Installez les packages de nuget :
- Assertions Fluent
- Moq
- Microsoft.AspNetCore.TestHostMicrosoft.AspNetCore.TestHost
- Mettre en place une structure de projet
- Créez une CollectionDefinition à l'aide de laquelle nous déterminons la ressource qui sera créée au début de chaque test et qui sera détruite à la fin de chaque test.
[CollectionDefinition("ApiCollection")] public class DbCollection : ICollectionFixture<ApiServer> { } ~~~ And define our test server and the client to it with the already authenticated user by default:
public class ApiServer : IDisposable { public const string Nom d'utilisateur = "admin" ; chaîne const publique Mot de passe = "admin" ;
private IConfigurationRoot _config; public ApiServer() { _config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); Server = new TestServer(new WebHostBuilder().UseStartup<Startup>()); Client = GetAuthenticatedClient(Username, Password); } public HttpClient GetAuthenticatedClient(string username, string password) { var client = Server.CreateClient(); var response = client.PostAsync("/api/Login/Authenticate", new JsonContent(new LoginModel {Password = password, Username = username})).Result; response.EnsureSuccessStatusCode(); var data = JsonConvert.DeserializeObject<UserWithTokenModel>(response.Content.ReadAsStringAsync().Result); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + data.Token); return client; } public HttpClient Client { get; private set; } public TestServer Server { get; private set; } public void Dispose() { if (Client != null) { Client.Dispose(); Client = null; } if (Server != null) { Server.Dispose(); Server = null; } } } ~~~
Pour faciliter l'utilisation des requêtes HTTP
dans les tests d'intégration, j'ai écrit un assistant :
public class HttpClientWrapper { private readonly HttpClient _client; public HttpClientWrapper(HttpClient client) { _client = client; } public HttpClient Client => _client; public async Task<T> PostAsync<T>(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } public async Task PostAsync(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); } public async Task<T> PutAsync<T>(string url, object body) { var response = await _client.PutAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } }
A ce stade, je dois définir un contrat d'API REST pour chaque entité, je vais le rédiger pour les dépenses de l'API REST :
URL | Méthode | Type de corps | Type de résultat | La description |
---|---|---|---|---|
Frais | AVOIR | - | DataResult<ExpenseModel> | Obtenez toutes les dépenses avec l'utilisation possible de filtres et de trieurs dans un paramètre de requête "commandes" |
Dépenses/{id} | AVOIR | - | Modèle de dépenses | Obtenir une dépense par identifiant |
Dépenses | PUBLIER | Créer un modèle de dépenses | Modèle de dépenses | Créer un nouveau dossier de dépenses |
Dépenses/{id} | METTRE | Mettre à jour le modèle de dépenses | Modèle de dépenses | Mettre à jour une dépense existante |
Lorsque vous demandez une liste de coûts, vous pouvez appliquer diverses commandes de filtrage et de tri à l'aide de la bibliothèque AutoQueryable. Un exemple de requête avec filtrage et tri :

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Une valeur de paramètre de commandes de décodage est take=25&amount>=12&orderbydesc=date
. Nous pouvons donc trouver des parties de pagination, de filtrage et de tri dans la requête. Toutes les options de requête sont très similaires à la syntaxe OData, mais malheureusement, OData n'est pas encore prêt pour .NET Core, j'utilise donc une autre bibliothèque utile.
Le bas montre tous les modèles utilisés dans cette API :
public class DataResult<T> { public T[] Data { get; set; } public int Total { get; set; } } public class ExpenseModel { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public string Username { get; set; } } public class CreateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } } public class UpdateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } }
Les modèles CreateExpenseModel
et UpdateExpenseModel
utilisent des attributs d'annotation de données pour effectuer des vérifications simples au niveau de l'API REST via des attributs.
Ensuite, pour chaque méthode HTTP
, un dossier séparé est créé dans le projet et les fichiers qu'il contient sont créés par fixture pour chaque méthode HTTP
prise en charge par la ressource :
Mise en place du test d'intégration pour obtenir une liste de dépenses :
[Collection("ApiCollection")] public class GetListShould { private readonly ApiServer _server; private readonly HttpClient _client; public GetListShould(ApiServer server) { _server = server; _client = server.Client; } public static async Task<DataResult<ExpenseModel>> Get(HttpClient client) { var response = await client.GetAsync($"api/Expenses"); response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync(); var items = JsonConvert.DeserializeObject<DataResult<ExpenseModel>>(responseText); return items; } [Fact] public async Task ReturnAnyList() { var items = await Get(_client); items.Should().NotBeNull(); } }
Implémentation du test d'intégration pour obtenir les données de dépenses par identifiant :
[Collection("ApiCollection")] public class GetItemShould { private readonly ApiServer _server; private readonly HttpClient _client; private Random _random; public GetItemShould(ApiServer server) { _server = server; _client = _server.Client; _random = new Random(); } [Fact] public async Task ReturnItemById() { var item = await new PostShould(_server).CreateNew(); var result = await GetById(_client, item.Id); result.Should().NotBeNull(); } public static async Task<ExpenseModel> GetById(HttpClient client, int id) { var response = await client.GetAsync(new Uri($"api/Expenses/{id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject<ExpenseModel>(result); } [Fact] public async Task ShouldReturn404StatusIfNotFound() { var response = await _client.GetAsync(new Uri($"api/Expenses/-1", UriKind.Relative)); response.StatusCode.ShouldBeEquivalentTo(HttpStatusCode.NotFound); } }
Mise en place du test d'intégration pour la création d'une dépense :
[Collection("ApiCollection")] public class PostShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private Random _random; public PostShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task<ExpenseModel> CreateNew() { var requestItem = new CreateExpenseModel() { Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now.AddMinutes(-15), Description = _random.Next().ToString() }; var createdItem = await _client.PostAsync<ExpenseModel>("api/Expenses", requestItem); createdItem.Id.Should().BeGreaterThan(0); createdItem.Amount.Should().Be(requestItem.Amount); createdItem.Comment.Should().Be(requestItem.Comment); createdItem.Date.Should().Be(requestItem.Date); createdItem.Description.Should().Be(requestItem.Description); createdItem.Username.Should().Be("admin admin"); return createdItem; } }
Mise en place du test d'intégration pour modifier une dépense :
[Collection("ApiCollection")] public class PutShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private readonly Random _random; public PutShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task UpdateExistingItem() { var item = await new PostShould(_server).CreateNew(); var requestItem = new UpdateExpenseModel { Date = DateTime.Now, Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString() }; await _client.PutAsync<ExpenseModel>($"api/Expenses/{item.Id}", requestItem); var updatedItem = await GetItemShould.GetById(_client.Client, item.Id); updatedItem.Date.Should().Be(requestItem.Date); updatedItem.Description.Should().Be(requestItem.Description); updatedItem.Amount.Should().Be(requestItem.Amount); updatedItem.Comment.Should().Contain(requestItem.Comment); } }
Mise en place du test d'intégration pour la suppression d'une dépense :
[Collection("ApiCollection")] public class DeleteShould { private readonly ApiServer _server; private readonly HttpClient _client; public DeleteShould(ApiServer server) { _server = server; _client = server.Client; } [Fact] public async Task DeleteExistingItem() { var item = await new PostShould(_server).CreateNew(); var response = await _client.DeleteAsync(new Uri($"api/Expenses/{item.Id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); } }
À ce stade, nous avons entièrement défini le contrat d'API REST et je peux maintenant commencer à l'implémenter sur la base d'ASP.NET Core.
Implémentation de l'API
Préparer le projet Dépenses. Pour cela, j'ai besoin d'installer les librairies suivantes :
- AutoMappeur
- AutoQueryable.AspNetCore.FilterAutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCoreMicrosoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServerMicrosoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Tools
- Swashbuckle.AspNetCore
Après cela, vous devez commencer à créer la migration initiale pour la base de données en ouvrant la console du gestionnaire de packages, en passant au projet Expenses.Data.Access
(car le contexte EF
s'y trouve) et en exécutant la commande Add-Migration InitialCreate
:
Dans l'étape suivante, préparez à l'avance le fichier de configuration appsettings.json, qui après la préparation devra encore être copié dans le projet Expenses.Api.IntegrationTests
car à partir de là, nous exécuterons l'API de l'instance de test.
{ "Logging": { "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } }, "Data": { "main": "Data Source=.; Initial Catalog=expenses.main; Integrated Security=true; Max Pool Size=1000; Min Pool Size=12; Pooling=True;" }, "ApplicationInsights": { "InstrumentationKey": "Your ApplicationInsights key" } }
La section de journalisation est créée automatiquement. J'ai ajouté la section Data
pour stocker la chaîne de connexion à la base de données et ma clé ApplicationInsights
.
Paramétrage des applications
Vous devez configurer différents services disponibles dans notre application :
Activation d' ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Enregistrez vos services via un appel : ContainerSetup.Setup(services, Configuration);
ContainerSetup
est une classe créée pour que nous n'ayons pas à stocker toutes les inscriptions de service dans la classe Startup
. La classe se trouve dans le dossier IoC du projet Expenses :
public static class ContainerSetup { public static void Setup(IServiceCollection services, IConfigurationRoot configuration) { AddUow(services, configuration); AddQueries(services); ConfigureAutoMapper(services); ConfigureAuth(services); } private static void ConfigureAuth(IServiceCollection services) { services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddScoped<ITokenBuilder, TokenBuilder>(); services.AddScoped<ISecurityContext, SecurityContext>(); } private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); } private static void AddUow(IServiceCollection services, IConfigurationRoot configuration) { var connectionString = configuration["Data:main"]; services.AddEntityFrameworkSqlServer(); services.AddDbContext<MainDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped<IUnitOfWork>(ctx => new EFUnitOfWork(ctx.GetRequiredService<MainDbContext>())); services.AddScoped<IActionTransactionHelper, ActionTransactionHelper>(); services.AddScoped<UnitOfWorkFilterAttribute>(); } private static void AddQueries(IServiceCollection services) { var exampleProcessorType = typeof(UsersQueryProcessor); var types = (from t in exampleProcessorType.GetTypeInfo().Assembly.GetTypes() where t.Namespace == exampleProcessorType.Namespace && t.GetTypeInfo().IsClass && t.GetTypeInfo().GetCustomAttribute<CompilerGeneratedAttribute>() == null select t).ToArray(); foreach (var type in types) { var interfaceQ = type.GetTypeInfo().GetInterfaces().First(); services.AddScoped(interfaceQ, type); } } }
Presque tout le code de cette classe parle de lui-même, mais j'aimerais entrer un peu plus dans la méthode ConfigureAutoMapper
.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Cette méthode utilise la classe d'assistance pour trouver tous les mappages entre les modèles et les entités et vice versa et obtient l'interface IMapper
pour créer le wrapper IAutoMapper
qui sera utilisé dans les contrôleurs. Il n'y a rien de spécial à propos de ce wrapper - il fournit simplement une interface pratique pour les méthodes AutoMapper
.
public class AutoMapperAdapter : IAutoMapper { private readonly IMapper _mapper; public AutoMapperAdapter(IMapper mapper) { _mapper = mapper; } public IConfigurationProvider Configuration => _mapper.ConfigurationProvider; public T Map<T>(object objectToMap) { return _mapper.Map<T>(objectToMap); } public TResult[] Map<TSource, TResult>(IEnumerable<TSource> sourceQuery) { return sourceQuery.Select(x => _mapper.Map<TResult>(x)).ToArray(); } public IQueryable<TResult> Map<TSource, TResult>(IQueryable<TSource> sourceQuery) { return sourceQuery.ProjectTo<TResult>(_mapper.ConfigurationProvider); } public void Map<TSource, TDestination>(TSource source, TDestination destination) { _mapper.Map(source, destination); } }
Pour configurer AutoMapper, la classe d'assistance est utilisée, dont la tâche est de rechercher des mappages pour des classes d'espace de noms spécifiques. Tous les mappages se trouvent dans le dossier Dépenses/Cartes :
public static class AutoMapperConfigurator { private static readonly object Lock = new object(); private static MapperConfiguration _configuration; public static MapperConfiguration Configure() { lock (Lock) { if (_configuration != null) return _configuration; var thisType = typeof(AutoMapperConfigurator); var configInterfaceType = typeof(IAutoMapperTypeConfigurator); var configurators = thisType.GetTypeInfo().Assembly.GetTypes() .Where(x => !string.IsNullOrWhiteSpace(x.Namespace)) // ReSharper disable once AssignNullToNotNullAttribute .Where(x => x.Namespace.Contains(thisType.Namespace)) .Where(x => x.GetTypeInfo().GetInterface(configInterfaceType.Name) != null) .Select(x => (IAutoMapperTypeConfigurator)Activator.CreateInstance(x)) .ToArray(); void AggregatedConfigurator(IMapperConfigurationExpression config) { foreach (var configurator in configurators) { configurator.Configure(config); } } _configuration = new MapperConfiguration(AggregatedConfigurator); return _configuration; } } }
All mappings must implement a specific interface:
public interface IAutoMapperTypeConfigurator { void Configure(IMapperConfigurationExpression configuration); }
An example of mapping from entity to model:
public class ExpenseMap : IAutoMapperTypeConfigurator { public void Configure(IMapperConfigurationExpression configuration) { var map = configuration.CreateMap<Expense, ExpenseModel>(); map.ForMember(x => x.Username, x => x.MapFrom(y => y.User.FirstName + " " + y.User.LastName)); } }
Also, in the Startup.ConfigureServices
method, authentication through JWT Bearer tokens is configured:
services.AddAuthorization(auth => { auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser().Build()); });
And the services registered the implementation of ISecurityContext
, which will actually be used to determine the current user:
public class SecurityContext : ISecurityContext { private readonly IHttpContextAccessor _contextAccessor; private readonly IUnitOfWork _uow; private User _user; public SecurityContext(IHttpContextAccessor contextAccessor, IUnitOfWork uow) { _contextAccessor = contextAccessor; _uow = uow; } public User User { get { if (_user != null) return _user; var username = _contextAccessor.HttpContext.User.Identity.Name; _user = _uow.Query<User>() .Where(x => x.Username == username) .Include(x => x.Roles) .ThenInclude(x => x.Role) .FirstOrDefault(); if (_user == null) { throw new UnauthorizedAccessException("User is not found"); } return _user; } } public bool IsAdministrator { get { return User.Roles.Any(x => x.Role.Name == Roles.Administrator); } } }
Also, we changed the default MVC registration a little in order to use a custom error filter to convert exceptions to the right error codes:
services.AddMvc(options => { options.Filters.Add(new ApiExceptionFilter()); });
Implementing the ApiExceptionFilter
filter:
public class ApiExceptionFilter : ExceptionFilterAttribute { public override void OnException(ExceptionContext context) { if (context.Exception is NotFoundException) { // handle explicit 'known' API errors var ex = context.Exception as NotFoundException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; } else if (context.Exception is BadRequestException) { // handle explicit 'known' API errors var ex = context.Exception as BadRequestException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else if (context.Exception is UnauthorizedAccessException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; } else if (context.Exception is ForbiddenException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } base.OnException(context); } }
Il est important de ne pas oublier Swagger
, afin d'obtenir une excellente description de l'API pour les autres développeurs ASP.net :
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
La méthode Startup.Configure
ajoute un appel à la méthode InitDatabase
, qui migre automatiquement la base de données jusqu'à la dernière migration :
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
est activé uniquement si l'application s'exécute dans l'environnement de développement et ne nécessite pas d'authentification pour y accéder :
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Ensuite, nous connectons l'authentification (les détails peuvent être trouvés dans le référentiel):
ConfigureAuthentication(app);
À ce stade, vous pouvez exécuter des tests d'intégration et vous assurer que tout est compilé mais que rien ne fonctionne et accéder au contrôleur ExpensesController
.
Remarque : Tous les contrôleurs se trouvent dans le dossier Expenses/Server et sont conditionnellement divisés en deux dossiers : Controllers et RestApi. Dans le dossier, les contrôleurs sont des contrôleurs qui fonctionnent comme des contrôleurs dans l'ancien bon MVC, c'est-à-dire qui renvoient le balisage, et dans RestApi, des contrôleurs REST.
Vous devez créer la classe Expenses/Server/RestApi/ExpensesController et l'hériter de la classe Controller :
public class ExpensesController : Controller { }
Ensuite, configurez le routage du type ~ / api / Expenses
en marquant la classe avec l'attribut [Route ("api / [controller]")]
.
Pour accéder à la logique métier et au mappeur, vous devez injecter les services suivants :
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
À ce stade, vous pouvez commencer à mettre en œuvre des méthodes. La première méthode consiste à obtenir une liste des dépenses :
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
L'implémentation de la méthode est très simple, nous obtenons une requête vers la base de données qui est mappée dans le IQueryable <ExpenseModel>
de ExpensesQueryProcessor
, qui à son tour renvoie le résultat.
L'attribut personnalisé ici est QueryableResult
, qui utilise la bibliothèque AutoQueryable
pour gérer la pagination, le filtrage et le tri côté serveur. L'attribut se trouve dans le dossier Expenses/Filters
. Par conséquent, ce filtre renvoie des données de type DataResult <ExpenseModel>
au client API.
public class QueryableResult : ActionFilterAttribute { public override void OnActionExecuted(ActionExecutedContext context) { if (context.Exception != null) return; dynamic query = ((ObjectResult)context.Result).Value; if (query == null) throw new Exception("Unable to retreive value of IQueryable from context result."); Type entityType = query.GetType().GenericTypeArguments[0]; var commands = context.HttpContext.Request.Query.ContainsKey("commands") ? context.HttpContext.Request.Query["commands"] : new StringValues(); var data = QueryableHelper.GetAutoQuery(commands, entityType, query, new AutoQueryableProfile {UnselectableProperties = new string[0]}); var total = System.Linq.Queryable.Count(query); context.Result = new OkObjectResult(new DataResult{Data = data, Total = total}); } }
Examinons également l'implémentation de la méthode Post, en créant un flux :
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Ici, vous devez faire attention à l'attribut ValidateModel
, qui effectue une validation simple des données d'entrée conformément aux attributs d'annotation de données et cela se fait via les vérifications MVC intégrées.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Code complet de ExpensesController
:
[Route("api/[controller]")] public class ExpensesController : Controller { private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; } [HttpGet] [QueryableResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; } [HttpGet("{id}")] public ExpenseModel Get(int id) { var item = _query.Get(id); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPut("{id}")] [ValidateModel] public async Task<ExpenseModel> Put(int id, [FromBody]UpdateExpenseModel requestModel) { var item = await _query.Update(id, requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpDelete("{id}")] public async Task Delete(int id) { await _query.Delete(id); } }
Conclusion
Je vais commencer par les problèmes : le problème principal est la complexité de la configuration initiale de la solution et la compréhension des couches de l'application, mais avec la complexité croissante de l'application, la complexité du système est presque inchangée, ce qui est un gros plus lorsqu'il accompagne un tel système. Et il est très important que nous disposions d'une API pour laquelle il existe un ensemble de tests d'intégration et un ensemble complet de tests unitaires pour la logique métier. La logique métier est complètement séparée de la technologie serveur utilisée et peut être entièrement testée. Cette solution est bien adaptée aux systèmes avec une API complexe et une logique métier complexe.
Si vous cherchez à créer une application Angular qui consomme votre API, consultez Angular 5 et ASP.NET Core par son collègue Toptaler Pablo Albella.