Criando uma API Web ASP.NET com ASP.NET Core

Publicados: 2022-03-11

Introdução

Vários anos atrás, recebi o livro “Pro ASP.NET Web API”. Este artigo é o desdobramento das ideias deste livro, um pouco de CQRS e minha própria experiência no desenvolvimento de sistemas cliente-servidor.

Neste artigo, estarei cobrindo:

  • Como criar uma API REST do zero usando .NET Core, EF Core, AutoMapper e XUnit
  • Como ter certeza de que a API funciona após as alterações
  • Como simplificar ao máximo o desenvolvimento e o suporte do sistema REST API

Por que ASP.NET Core?

O ASP.NET Core oferece muitas melhorias em relação ao ASP.NET MVC/API da Web. Em primeiro lugar, agora é um quadro e não dois. Eu realmente gosto porque é conveniente e há menos confusão. Em segundo lugar, temos contêineres de log e DI sem bibliotecas adicionais, o que me economiza tempo e me permite me concentrar em escrever um código melhor em vez de escolher e analisar as melhores bibliotecas.

O que são processadores de consulta?

Um processador de consultas é uma abordagem quando toda a lógica de negócios relacionada a uma entidade do sistema é encapsulada em um serviço e qualquer acesso ou ação com essa entidade é realizado por meio desse serviço. Esse serviço geralmente é chamado de {EntityPluralName}QueryProcessor. Se necessário, um processador de consultas inclui métodos CRUD (criar, ler, atualizar, excluir) para essa entidade. Dependendo dos requisitos, nem todos os métodos podem ser implementados. Para dar um exemplo específico, vamos dar uma olhada em ChangePassword. Se o método de um processador de consulta exigir dados de entrada, apenas os dados necessários devem ser fornecidos. Normalmente, para cada método, é criada uma classe de consulta separada e, em casos simples, é possível (mas não desejável) reutilizar a classe de consulta.

Nosso foco

Neste artigo, mostrarei como fazer uma API para um sistema de gerenciamento de pequenos custos, incluindo configurações básicas para autenticação e controle de acesso, mas não entrarei no subsistema de autenticação. Abordarei toda a lógica de negócios do sistema com testes modulares e criarei pelo menos um teste de integração para cada método de API em um exemplo de uma entidade.

Requisitos para o sistema desenvolvido: O usuário pode adicionar, editar, deletar seus gastos e pode ver apenas seus gastos.

Todo o código deste sistema está disponível no Github.

Então, vamos começar a projetar nosso sistema pequeno, mas muito útil.

Camadas de API

Um diagrama mostrando as camadas da API.

O diagrama mostra que o sistema terá quatro camadas:

  • Banco de dados - Aqui armazenamos dados e nada mais, sem lógica.
  • DAL - Para acessar os dados, usamos o padrão Unit of Work e, na implementação, usamos o ORM EF Core com code first e padrões de migração.
  • Lógica de negócios - para encapsular a lógica de negócios, usamos processadores de consulta, somente essa camada processa a lógica de negócios. A exceção são as validações mais simples como campos obrigatórios, que serão executados por meio de filtros na API.
  • API REST - A interface real por meio da qual os clientes podem trabalhar com nossa API será implementada por meio do ASP.NET Core. As configurações de rota são determinadas por atributos.

Além das camadas descritas, temos vários conceitos importantes. A primeira é a separação dos modelos de dados. O modelo de dados do cliente é usado principalmente na camada da API REST. Ele converte consultas em modelos de domínio e vice-versa de um modelo de domínio em um modelo de dados de cliente, mas os modelos de consulta também podem ser usados ​​em processadores de consulta. A conversão é feita usando o AutoMapper.

Estrutura do projeto

Usei o VS 2017 Professional para criar o projeto. Eu costumo compartilhar o código-fonte e testes em pastas diferentes. É confortável, parece bom, os testes no CI são executados convenientemente e parece que a Microsoft recomenda fazer desta forma:

Estrutura de pastas no VS 2017 Professional.

Descrição do Projeto:

Projeto Descrição
Despesas Projeto para controllers, mapeamento entre modelo de domínio e modelo de API, configuração de API
Despesas.Api.Comum Neste ponto, são coletadas classes de exceção que são interpretadas de uma determinada maneira por filtros para retornar códigos HTTP corretos com erros para o usuário
Despesas.Api.Modelos Projeto para modelos de API
Despesas.Dados.Acesso Projeto de interfaces e implementação do padrão Unit of Work
Despesas.Dados.Modelo Projeto para modelo de domínio
Despesas.Consultas Projeto para processadores de consulta e classes específicas de consulta
Despesas.Segurança Projeto para a interface e implementação do contexto de segurança do usuário atual

Referências entre projetos:

Diagrama mostrando referências entre projetos.

Despesas criadas a partir do modelo:

Lista de despesas criadas a partir do modelo.

Outros projetos na pasta src por template:

Lista de outros projetos na pasta src por modelo.

Todos os projetos na pasta de testes por modelo:

Lista de projetos na pasta de testes por modelo.

Implementação

Este artigo não descreverá a parte associada à interface do usuário, embora esteja implementada.

O primeiro passo foi desenvolver um modelo de dados que está localizado no assembly Expenses.Data.Model :

Diagrama da relação entre os papéis

A classe Expense contém os seguintes atributos:

 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; } }

Esta classe suporta “exclusão suave” por meio do atributo IsDeleted e contém todos os dados para uma despesa de um determinado usuário que será útil para nós no futuro.

As classes User , Role e UserRole referem-se ao subsistema de acesso; este sistema não pretende ser o sistema do ano e a descrição deste subsistema não é objecto deste artigo; portanto, o modelo de dados e alguns detalhes da implementação serão omitidos. O sistema de organização de acesso pode ser substituído por um mais perfeito sem alterar a lógica do negócio.

Em seguida, o template Unit of Work foi implementado na montagem Expenses.Data.Access , a estrutura deste projeto é mostrada:

Estrutura do projeto Despesas.Dados.Acesso

As seguintes bibliotecas são necessárias para a montagem:

  • Microsoft.EntityFrameworkCore.SqlServer

É necessário implementar um contexto EF que encontre automaticamente os mapeamentos em uma pasta específica:

 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); } } }

O mapeamento é feito através da 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(); } }

O mapeamento para as classes está na pasta Maps e o mapeamento para 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; }

Sua implementação é um wrapper para 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; } }

A interface ITransaction implementada nesta aplicação não será utilizada:

 public interface ITransaction : IDisposable { void Commit(); void Rollback(); }

Sua implementação simplesmente envolve a transação 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(); } }

Também nesta etapa, para os testes unitários, é necessária a interface ISecurityContext , que define o usuário atual da API (o projeto é Expenses.Security ):

 public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }

Em seguida, você precisa definir a interface e a implementação do processador de consultas, que conterá toda a lógica de negócios para trabalhar com custos - no nosso caso, IExpensesQueryProcessor e 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(); } }

A próxima etapa é configurar o assembly Expenses.Queries.Tests . Instalei as seguintes bibliotecas:

  • Quantidade mínima
  • Declarações Fluentes

Em seguida, na montagem Expenses.Queries.Tests , definimos o acessório para testes de unidade e descrevemos nossos testes de unidade:

 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>(); }

Após a descrição dos testes de unidade, a implementação de um processador de consultas é descrita:

 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(); } }

Assim que a lógica de negócios estiver pronta, começo a escrever os testes de integração da API para determinar o contrato da API.

O primeiro passo é preparar um projeto Expenses.Api.IntegrationTests

  1. Instale pacotes nuget:
    • Declarações Fluentes
    • Quantidade mínima
    • Microsoft.AspNetCore.TestHost
  2. Configurar uma estrutura de projeto
    Estrutura do projeto
  3. Crie uma CollectionDefinition com a ajuda da qual determinamos o recurso que será criado no início de cada execução de teste e será destruído no final de cada execução de teste.
 [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 Nome de usuário = “admin”; public const string Senha = “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; } } } ~~~

Para a conveniência de trabalhar com solicitações HTTP em testes de integração, escrevi um auxiliar:

 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; } }

Nesta fase, preciso definir um contrato de API REST para cada entidade, vou escrevê-lo para as despesas da API REST:

URL Método Tipo de corpo Tipo de resultado Descrição
Despesa PEGAR - DataResult<ExpenseModel> Obtenha todas as despesas com possível uso de filtros e classificadores em um parâmetro de consulta "comandos"
Despesas/{id} PEGAR - Modelo de despesas Obter uma despesa por ID
Despesas PUBLICAR CreateExpenseModel Modelo de despesas Criar novo registro de despesas
Despesas/{id} POR UpdateExpenseModel Modelo de despesas Atualizar uma despesa existente

Ao solicitar uma lista de custos, você pode aplicar vários comandos de filtragem e classificação usando a biblioteca AutoQueryable. Um exemplo de consulta com filtragem e classificação:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date

Um valor de parâmetro de comandos de decodificação é take=25&amount>=12&orderbydesc=date . Assim, podemos encontrar partes de paginação, filtragem e classificação na consulta. Todas as opções de consulta são muito semelhantes à sintaxe do OData, mas, infelizmente, o OData ainda não está pronto para o .NET Core, então estou usando outra biblioteca útil.

A parte inferior mostra todos os modelos usados ​​nesta 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; } }

Os modelos CreateExpenseModel e UpdateExpenseModel usam atributos de anotação de dados para realizar verificações simples no nível da API REST por meio de atributos.

Em seguida, para cada método HTTP , uma pasta separada é criada no projeto e os arquivos nela são criados por fixture para cada método HTTP suportado pelo recurso:

Estrutura de pastas de despesas

Implementação do teste de integração para obter uma lista de despesas:

 [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(); } }

Implementação do teste de integração para obtenção dos dados de despesas por id:

 [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); } }

Implementação do teste de integração para criação de uma despesa:

 [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; } }

Implementação do teste de integração para alteração de uma despesa:

 [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); } }

Implementação do teste de integração para remoção de uma despesa:

 [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(); } }

Neste ponto, definimos totalmente o contrato da API REST e agora posso começar a implementá-lo com base no ASP.NET Core.

Implementação da API

Preparar as despesas do projeto. Para isso, preciso instalar as seguintes bibliotecas:

  • AutoMapper
  • AutoQueryable.AspNetCore.Filter
  • Microsoft.ApplicationInsights.AspNetCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.SqlServer.Design
  • Microsoft.EntityFrameworkCore.Tools
  • Swashbuckle.AspNetCore

Depois disso, você precisa começar a criar a migração inicial para o banco de dados abrindo o Package Manager Console, alternando para o projeto Expenses.Data.Access (porque o contexto do EF está lá) e executando o comando Add-Migration InitialCreate :

Console do gerenciador de pacotes

Na próxima etapa, prepare antecipadamente o arquivo de configuração appsettings.json, que após a preparação ainda precisará ser copiado para o projeto Expenses.Api.IntegrationTests , pois a partir daí executaremos a API da instância de teste.

 { "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" } }

A seção de registro é criada automaticamente. Adicionei a seção Data para armazenar a cadeia de conexão ao banco de dados e minha chave ApplicationInsights .

Configuração do aplicativo

Você deve configurar diferentes serviços disponíveis em nosso aplicativo:

Ativação do ApplicationInsights : services.AddApplicationInsightsTelemetry(Configuration);

Registre seus serviços através de uma chamada: ContainerSetup.Setup(services, Configuration);

ContainerSetup é uma classe criada para que não tenhamos que armazenar todos os registros de serviço na classe Startup . A aula está localizada na pasta IoC do projeto 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); } } }

Quase todo o código nesta classe fala por si, mas eu gostaria de entrar um pouco mais no método ConfigureAutoMapper .

 private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }

Este método usa a classe auxiliar para encontrar todos os mapeamentos entre modelos e entidades e vice-versa e obtém a interface IMapper para criar o wrapper IAutoMapper que será usado nos controladores. Não há nada de especial neste wrapper—ele apenas fornece uma interface conveniente para os métodos 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); } }

Para configurar o AutoMapper, é utilizada a classe auxiliar, cuja tarefa é procurar mapeamentos para classes de namespace específicas. Todos os mapeamentos estão localizados na pasta Despesas/Mapas:

 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); } }

É importante não esquecer Swagger , para obter uma excelente descrição da API para outros desenvolvedores ASP.net:

 services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); }); 

Documentação da API

O método Startup.Configure adiciona uma chamada ao método InitDatabase , que migra automaticamente o banco de dados até a última migração:

 private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }

O Swagger é ativado apenas se o aplicativo for executado no ambiente de desenvolvimento e não exigir autenticação para acessá-lo:

 app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });

Em seguida, conectamos a autenticação (os detalhes podem ser encontrados no repositório):

ConfigureAuthentication(app);

Neste ponto, você pode executar testes de integração e certificar-se de que tudo está compilado, mas nada funciona e ir para o controlador ExpensesController .

Nota: Todos os controllers estão localizados na pasta Expenses/Server e são divididos condicionalmente em duas pastas: Controllers e RestApi. Na pasta, os controladores são controladores que funcionam como controladores no velho e bom MVC—ou seja, retornam a marcação e em RestApi, controladores REST.

Você deve criar a classe Expenses/Server/RestApi/ExpensesController e herdá-la da classe Controller:

 public class ExpensesController : Controller { }

Em seguida, configure o roteamento do tipo ~ / api / Expenses marcando a classe com o atributo [Route ("api / [controller]")] .

Para acessar a lógica de negócios e o mapeador, você precisa injetar os seguintes serviços:

 private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }

Nesta fase, você pode começar a implementar métodos. O primeiro método é obter uma lista de despesas:

 [HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }

A implementação do método é muito simples, obtemos uma consulta ao banco de dados que é mapeado no IQueryable <ExpenseModel> do ExpensesQueryProcessor , que por sua vez retorna como resultado.

O atributo personalizado aqui é QueryableResult , que usa a biblioteca AutoQueryable para lidar com paginação, filtragem e classificação no lado do servidor. O atributo está localizado na pasta Expenses/Filters . Como resultado, esse filtro retorna dados do tipo DataResult <ExpenseModel> para o cliente da 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}); } }

Além disso, vamos ver a implementação do método Post, criando um fluxo:

 [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }

Aqui, você deve prestar atenção ao atributo ValidateModel , que realiza uma validação simples dos dados de entrada de acordo com os atributos de anotação de dados e isso é feito através das verificações internas do MVC.

 public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }

Código completo do 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); } }

Conclusão

Vou começar pelos problemas: O principal problema é a complexidade da configuração inicial da solução e o entendimento das camadas da aplicação, mas com o aumento da complexidade da aplicação, a complexidade do sistema fica praticamente inalterada, o que é um grande mais ao acompanhar tal sistema. E é muito importante que tenhamos uma API para a qual haja um conjunto de testes de integração e um conjunto completo de testes de unidade para lógica de negócios. A lógica de negócios é completamente separada da tecnologia de servidor usada e pode ser totalmente testada. Esta solução é adequada para sistemas com uma API complexa e lógica de negócios complexa.

Se você deseja criar um aplicativo Angular que consome sua API, confira Angular 5 e ASP.NET Core do colega Toptaler Pablo Albella.