Creación de una API web de ASP.NET con ASP.NET Core
Publicado: 2022-03-11Introducción
Hace varios años, obtuve el libro "Pro ASP.NET Web API". Este artículo es la derivación de las ideas de este libro, un poco de CQRS y mi propia experiencia en el desarrollo de sistemas cliente-servidor.
En este artículo, cubriré:
- Cómo crear una API REST desde cero usando .NET Core, EF Core, AutoMapper y XUnit
- Cómo estar seguro de que la API funciona después de los cambios
- Cómo simplificar al máximo el desarrollo y soporte del sistema REST API
¿Por qué ASP.NET Core?
ASP.NET Core ofrece muchas mejoras con respecto a ASP.NET MVC/Web API. En primer lugar, ahora es un marco y no dos. Realmente me gusta porque es conveniente y hay menos confusión. En segundo lugar, tenemos contenedores DI y de registro sin bibliotecas adicionales, lo que me ahorra tiempo y me permite concentrarme en escribir mejor código en lugar de elegir y analizar las mejores bibliotecas.
¿Qué son los procesadores de consultas?
Un procesador de consultas es un enfoque cuando toda la lógica comercial relacionada con una entidad del sistema se encapsula en un servicio y cualquier acceso o acción con esta entidad se realiza a través de este servicio. Este servicio suele llamarse {EntityPluralName}QueryProcessor. Si es necesario, un procesador de consultas incluye métodos CRUD (crear, leer, actualizar, eliminar) para esta entidad. Dependiendo de los requisitos, es posible que no se implementen todos los métodos. Para dar un ejemplo específico, echemos un vistazo a ChangePassword. Si el método de un procesador de consultas requiere datos de entrada, solo se deben proporcionar los datos requeridos. Por lo general, para cada método, se crea una clase de consulta separada y, en casos simples, es posible (pero no deseable) reutilizar la clase de consulta.
Nuestra puntería
En este artículo, le mostraré cómo crear una API para un sistema de administración de costos pequeños, incluida la configuración básica para la autenticación y el control de acceso, pero no entraré en el subsistema de autenticación. Cubriré toda la lógica comercial del sistema con pruebas modulares y crearé al menos una prueba de integración para cada método API en un ejemplo de una entidad.
Requisitos para el sistema desarrollado: El usuario puede agregar, editar, eliminar sus gastos y puede ver solo sus gastos.
El código completo de este sistema está disponible en Github.
Entonces, comencemos a diseñar nuestro pequeño pero muy útil sistema.
Capas de API
El diagrama muestra que el sistema tendrá cuatro capas:
- Base de datos - Aquí almacenamos datos y nada más, sin lógica.
- DAL: para acceder a los datos, usamos el patrón Unidad de trabajo y, en la implementación, usamos el ORM EF Core con código primero y patrones de migración.
- Lógica comercial: para encapsular la lógica comercial, usamos procesadores de consultas, solo esta capa procesa la lógica comercial. La excepción son las validaciones más simples como campos obligatorios, que se ejecutarán mediante filtros en la API.
- REST API: la interfaz real a través de la cual los clientes pueden trabajar con nuestra API se implementará a través de ASP.NET Core. Las configuraciones de ruta están determinadas por los atributos.
Además de las capas descritas, tenemos varios conceptos importantes. El primero es la separación de modelos de datos. El modelo de datos del cliente se utiliza principalmente en la capa de API REST. Convierte consultas en modelos de dominio y viceversa de un modelo de dominio a un modelo de datos de cliente, pero los modelos de consulta también se pueden usar en procesadores de consultas. La conversión se realiza mediante AutoMapper.
Estructura del proyecto
Usé VS 2017 Professional para crear el proyecto. Normalmente comparto el código fuente y las pruebas en diferentes carpetas. Es cómodo, se ve bien, las pruebas en CI se ejecutan convenientemente, y parece que Microsoft recomienda hacerlo de esta manera:
Descripción del Proyecto:
Proyecto | Descripción |
---|---|
Gastos | Proyecto para controladores, mapeo entre modelo de dominio y modelo API, configuración API |
Gastos.Api.Común | En este punto, hay clases de excepción recopiladas que son interpretadas de cierta manera por los filtros para devolver códigos HTTP correctos con errores al usuario. |
Gastos.Api.Modelos | Proyecto para modelos API |
Gastos.Datos.Acceso | Proyecto de interfaces e implementación del patrón Unidad de Trabajo |
Modelo.de.datos.de.gastos | Proyecto para modelo de dominio |
Gastos.Consultas | Proyecto para procesadores de consultas y clases específicas de consultas |
Gastos.Seguridad | Proyecto de interfaz e implementación del contexto de seguridad del usuario actual |
Referencias entre proyectos:
Gastos creados a partir de la plantilla:
Otros proyectos en la carpeta src por plantilla:
Todos los proyectos en la carpeta de pruebas por plantilla:
Implementación
Este artículo no describirá la parte asociada con la interfaz de usuario, aunque está implementada.
El primer paso fue desarrollar un modelo de datos que se ubica en el ensamblado Expenses.Data.Model
:
La clase Expense
contiene los siguientes 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 clase admite la "eliminación suave" por medio del atributo IsDeleted
y contiene todos los datos para un gasto de un usuario en particular que nos serán útiles en el futuro.
Las clases User
, Role
y UserRole
se refieren al subsistema de acceso; este sistema no pretende ser el sistema del año y la descripción de este subsistema no es objeto de este artículo; por lo tanto, se omitirán el modelo de datos y algunos detalles de la implementación. El sistema de organización de acceso puede ser reemplazado por uno más perfecto sin cambiar la lógica del negocio.
A continuación, se implementó la plantilla Unidad de Trabajo en el ensamblado Expenses.Data.Access
, se muestra la estructura de este proyecto:
Las siguientes bibliotecas son necesarias para el ensamblaje:
-
Microsoft.EntityFrameworkCore.SqlServer
Es necesario implementar un contexto EF
que automáticamente encontrará las asignaciones en una carpeta 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); } } }
El mapeo se realiza a través de la clase 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(); } }
La asignación a las clases está en la carpeta Maps
y la asignación para Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Interfaz 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; }
Su implementación es un contenedor 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; } }
No se utilizará la interfaz ITransaction
implementada en esta aplicación:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Su implementación simplemente envuelve la transacción 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(); } }
También en esta etapa, para las pruebas unitarias, se necesita la interfaz ISecurityContext
, que define el usuario actual de la API (el proyecto es Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
A continuación, debe definir la interfaz y la implementación del procesador de consultas, que contendrá toda la lógica comercial para trabajar con costos, en nuestro caso, IExpensesQueryProcessor
y 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(); } }
El siguiente paso es configurar el ensamblado Expenses.Queries.Tests
. Instalé las siguientes bibliotecas:
- MOQ
- Afirmaciones fluidas
Luego, en el ensamblado Expenses.Queries.Tests
, definimos el accesorio para las pruebas unitarias y describimos nuestras pruebas unitarias:
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>(); }
Después de describir las pruebas unitarias, se describe la implementación de un procesador de consultas:
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(); } }
Una vez que la lógica de negocios está lista, empiezo a escribir las pruebas de integración de la API para determinar el contrato de la API.
El primer paso es preparar un proyecto Expenses.Api.IntegrationTests
- Instalar paquetes nuget:
- Afirmaciones fluidas
- MOQ
- Microsoft.AspNetCore.TestHost
- Configurar una estructura de proyecto
- Cree una CollectionDefinition con la ayuda de la cual determinamos el recurso que se creará al comienzo de cada ejecución de prueba y se destruirá al final de cada ejecución de prueba.
[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 Nombre de usuario = “admin”; public const string Contraseña = “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 la conveniencia de trabajar con solicitudes HTTP
en las pruebas de integración, escribí un asistente:
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; } }
En esta etapa, necesito definir un contrato de API REST para cada entidad, lo escribiré para los gastos de API REST:
URL | Método | Tipo de cuerpo | Tipo de resultado | Descripción |
---|---|---|---|---|
Gastos | OBTENER | - | Resultado de datos<Modelo de gastos> | Obtenga todos los gastos con el posible uso de filtros y clasificadores en un parámetro de consulta "comandos" |
Gastos/{id} | OBTENER | - | modelo de gastos | Obtener un gasto por id |
Gastos | CORREO | CreateExpenseModel | modelo de gastos | Crear nuevo registro de gastos |
Gastos/{id} | PONER | Actualizar modelo de gastos | modelo de gastos | Actualizar un gasto existente |
Cuando solicita una lista de costos, puede aplicar varios comandos de filtrado y clasificación utilizando la biblioteca AutoQueryable. Una consulta de ejemplo con filtrado y clasificación:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
El valor de un parámetro de los comandos de decodificación es take=25&amount>=12&orderbydesc=date
. Entonces podemos encontrar partes de paginación, filtrado y clasificación en la consulta. Todas las opciones de consulta son muy similares a la sintaxis de OData, pero lamentablemente, OData aún no está listo para .NET Core, por lo que estoy usando otra biblioteca útil.
La parte inferior muestra todos los modelos utilizados en esta 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; } }
Los modelos CreateExpenseModel
y UpdateExpenseModel
usan atributos de anotación de datos para realizar comprobaciones simples en el nivel de la API REST a través de atributos.
A continuación, para cada método HTTP
, se crea una carpeta separada en el proyecto y los archivos en ella se crean mediante fixture para cada método HTTP
compatible con el recurso:
Implementación de la prueba de integración para obtener una lista de gastos:
[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(); } }
Implementación de la prueba de integración para obtener los datos de gastos 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); } }
Implementación de la prueba de integración para la creación de un gasto:
[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; } }
Implementación de la prueba de integración para cambiar un gasto:
[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); } }
Implementación de la prueba de integración para la eliminación de un gasto:
[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(); } }
En este punto, hemos definido completamente el contrato de API REST y ahora puedo comenzar a implementarlo sobre la base de ASP.NET Core.
Implementación de API
Preparar los gastos del proyecto. Para esto, necesito instalar las siguientes bibliotecas:
- Asignador automático
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Diseño
- Microsoft.EntityFrameworkCore.Herramientas
- Swashbuckle.AspNetCore
Después de eso, debe comenzar a crear la migración inicial para la base de datos abriendo Package Manager Console, cambiando al proyecto Expenses.Data.Access
(porque el contexto EF
se encuentra allí) y ejecutando el comando Add-Migration InitialCreate
:
En el siguiente paso, prepare el archivo de configuración appsettings.json con anticipación, que después de la preparación aún deberá copiarse en el proyecto Expenses.Api.IntegrationTests
porque desde allí ejecutaremos la API de la instancia de prueba.
{ "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 sección de registro se crea automáticamente. Agregué la sección Data
para almacenar la cadena de conexión a la base de datos y mi clave de ApplicationInsights
.
Configuración de la aplicación
Debes configurar diferentes servicios disponibles en nuestra aplicación:
Activación de ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Registre sus servicios a través de una llamada: ContainerSetup.Setup(services, Configuration);
ContainerSetup
es una clase creada para que no tengamos que almacenar todos los registros de servicios en la clase Startup
. La clase se encuentra en la carpeta IoC del proyecto 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); } } }
Casi todo el código de esta clase habla por sí solo, pero me gustaría profundizar un poco más en el 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 la clase auxiliar para encontrar todas las asignaciones entre modelos y entidades y viceversa, y obtiene la interfaz IMapper
para crear el envoltorio IAutoMapper
que se usará en los controladores. No hay nada especial en este contenedor, solo proporciona una interfaz conveniente para los métodos de 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 AutoMapper, se utiliza la clase auxiliar, cuya tarea es buscar asignaciones para clases de espacio de nombres específicas. Todas las asignaciones se encuentran en la carpeta Expenses/Maps:
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); } }
Es importante no olvidarse de Swagger
para obtener una excelente descripción de la API para otros desarrolladores de ASP.net:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
El método Startup.Configure
agrega una llamada al método InitDatabase
, que migra automáticamente la base de datos hasta la última migración:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
se activa solo si la aplicación se ejecuta en el entorno de desarrollo y no requiere autenticación para acceder a ella:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
A continuación, conectamos la autenticación (los detalles se pueden encontrar en el repositorio):
ConfigureAuthentication(app);
En este punto, puede ejecutar pruebas de integración y asegurarse de que todo esté compilado pero nada funcione e ir al controlador ExpensesController
.
Nota: Todos los controladores se encuentran en la carpeta Expenses/Server y se dividen condicionalmente en dos carpetas: Controllers y RestApi. En la carpeta, los controladores son controladores que funcionan como controladores en el viejo y bueno MVC, es decir, devuelven el marcado y en RestApi, controladores REST.
Debe crear la clase Expenses/Server/RestApi/ExpensesController y heredarla de la clase Controller:
public class ExpensesController : Controller { }
A continuación, configure el enrutamiento del tipo ~ / api / Expenses
marcando la clase con el atributo [Route ("api / [controller]")]
.
Para acceder a la lógica de negocios y al mapeador, debe inyectar los siguientes servicios:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
En esta etapa, puede comenzar a implementar métodos. El primer método es obtener una lista de gastos:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
La implementación del método es muy simple, obtenemos una consulta a la base de datos que está mapeada en el IQueryable <ExpenseModel>
de ExpensesQueryProcessor
, que a su vez devuelve como resultado.
El atributo personalizado aquí es QueryableResult
, que usa la biblioteca AutoQueryable
para gestionar la paginación, el filtrado y la clasificación en el lado del servidor. El atributo se encuentra en la carpeta Expenses/Filters
. Como resultado, este filtro devuelve datos de tipo DataResult <ExpenseModel>
al cliente 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}); } }
Además, veamos la implementación del método Post, creando un flujo:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Aquí, debe prestar atención al atributo ValidateModel
, que realiza una validación simple de los datos de entrada de acuerdo con los atributos de anotación de datos y esto se realiza a través de las comprobaciones MVC integradas.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Código completo 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); } }
Conclusión
Comenzaré con los problemas: el principal problema es la complejidad de la configuración inicial de la solución y la comprensión de las capas de la aplicación, pero con la creciente complejidad de la aplicación, la complejidad del sistema casi no cambia, lo cual es un gran más cuando acompaña a un sistema de este tipo. Y es muy importante que tengamos una API para la cual haya un conjunto de pruebas de integración y un conjunto completo de pruebas unitarias para la lógica empresarial. La lógica empresarial está completamente separada de la tecnología de servidor utilizada y se puede probar por completo. Esta solución es adecuada para sistemas con una API compleja y una lógica empresarial compleja.
Si está buscando crear una aplicación Angular que consuma su API, consulte Angular 5 y ASP.NET Core del compañero Toptaler Pablo Albella.