Criando uma API Web ASP.NET com ASP.NET Core
Publicados: 2022-03-11Introduçã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
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:
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:
Despesas criadas a partir do modelo:
Outros projetos na pasta src por template:
Todos os 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
:
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:
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
- Instale pacotes nuget:
- Declarações Fluentes
- Quantidade mínima
- Microsoft.AspNetCore.TestHost
- Configurar uma estrutura de projeto
- 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:
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
:
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>(); });
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.