Создание веб-API ASP.NET с помощью ASP.NET Core
Опубликовано: 2022-03-11Введение
Несколько лет назад я получил книгу «Pro ASP.NET Web API». Эта статья является ответвлением идей из этой книги, небольшого CQRS и моего собственного опыта разработки клиент-серверных систем.
В этой статье я расскажу:
- Как создать REST API с нуля с помощью .NET Core, EF Core, AutoMapper и XUnit
- Как убедиться, что API работает после изменений
- Как максимально упростить разработку и поддержку системы REST API
Почему ASP.NET Core?
ASP.NET Core предоставляет множество улучшений по сравнению с ASP.NET MVC/Web API. Во-первых, теперь это один фреймворк, а не два. Мне это очень нравится, потому что это удобно и меньше путаницы. Во-вторых, у нас есть контейнеры логирования и внедрения зависимостей без каких-либо дополнительных библиотек, что экономит мне время и позволяет сосредоточиться на написании лучшего кода, а не на выборе и анализе лучших библиотек.
Что такое процессоры запросов?
Процессор запросов — это подход, когда вся бизнес-логика, относящаяся к одной сущности системы, инкапсулирована в один сервис и любые обращения или действия с этим объектом выполняются через этот сервис. Эта служба обычно называется {EntityPluralName}QueryProcessor. При необходимости обработчик запросов включает методы CRUD (создание, чтение, обновление, удаление) для этой сущности. В зависимости от требований не все методы могут быть реализованы. Чтобы привести конкретный пример, давайте взглянем на ChangePassword. Если для метода обработчика запросов требуются входные данные, то должны быть предоставлены только требуемые данные. Обычно для каждого метода создается отдельный класс запроса, а в простых случаях возможно (но нежелательно) повторное использование класса запроса.
Наша цель
В этой статье я покажу вам, как сделать API для небольшой системы управления затратами, включая базовые настройки аутентификации и контроля доступа, но не буду вдаваться в подсистему аутентификации. Я покрою модульными тестами всю бизнес-логику системы и создам как минимум один интеграционный тест для каждого метода API на примере одной сущности.
Требования к разрабатываемой системе: Пользователь может добавлять, редактировать, удалять свои расходы и видеть только свои расходы.
Весь код этой системы доступен на Github.
Итак, приступим к проектированию нашей небольшой, но очень полезной системы.
Слои API
На схеме видно, что система будет иметь четыре слоя:
- База данных — Здесь мы храним данные и ничего более, никакой логики.
- DAL — для доступа к данным мы используем шаблон Unit of Work, а в реализации мы используем ORM EF Core с кодом в первую очередь и шаблонами миграции.
- Бизнес-логика — для инкапсуляции бизнес-логики мы используем обработчики запросов, только этот слой обрабатывает бизнес-логику. Исключение составляют простейшие проверки, такие как обязательные поля, которые будут выполняться с помощью фильтров в API.
- REST API. Фактический интерфейс, через который клиенты могут работать с нашим API, будет реализован через ASP.NET Core. Конфигурации маршрута определяются атрибутами.
В дополнение к описанным слоям у нас есть несколько важных понятий. Во-первых, это разделение моделей данных. Модель данных клиента в основном используется на уровне REST API. Он преобразует запросы в модели предметной области и наоборот из модели предметной области в модель данных клиента, но модели запросов также можно использовать в обработчиках запросов. Преобразование выполняется с помощью AutoMapper.
Структура проекта
Я использовал VS 2017 Professional для создания проекта. Обычно я делюсь исходным кодом и тестами в разных папках. Это удобно, это выглядит хорошо, тесты в CI запускаются удобно, и кажется, что Microsoft рекомендует делать это так:
Описание Проекта:
Проект | Описание |
---|---|
Расходы | Проект для контроллеров, сопоставление между моделью предметной области и моделью API, конфигурация API |
Расходы.Api.Общие | На этом этапе собираются классы исключений, которые определенным образом интерпретируются фильтрами для возврата пользователю правильных HTTP-кодов с ошибками. |
Расходы.Api.Models | Проект для моделей API |
Расходы.Данные.Доступ | Проект интерфейсов и реализация паттерна Unit of Work |
Расходы.Данные.Модель | Проект для доменной модели |
Расходы.Запросы | Проект для обработчиков запросов и классов, специфичных для запросов |
Расходы.Безопасность | Проект интерфейса и реализации контекста безопасности текущего пользователя |
Ссылки между проектами:
Расходы, созданные из шаблона:
Другие проекты в папке src по шаблону:
Все проекты в папке с тестами по шаблону:
Реализация
В этой статье не будет описываться часть, связанная с пользовательским интерфейсом, хотя она и реализована.
Первым шагом была разработка модели данных, которая находится в сборке Expenses.Data.Model
:
Класс Expense
содержит следующие атрибуты:
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; } }
Этот класс поддерживает «мягкое удаление» посредством атрибута IsDeleted
и содержит все данные за один расход конкретного пользователя, которые нам пригодятся в будущем.
Классы User
, Role
и UserRole
относятся к подсистеме доступа; эта система не претендует на звание системы года и описание этой подсистемы не является целью данной статьи; поэтому модель данных и некоторые детали реализации будут опущены. Систему организации доступа можно заменить на более совершенную без изменения бизнес-логики.
Далее был реализован шаблон Unit of Work в сборке Expenses.Data.Access
, показана структура этого проекта:
Для сборки необходимы следующие библиотеки:
-
Microsoft.EntityFrameworkCore.SqlServer
Необходимо реализовать контекст EF
, который будет автоматически находить сопоставления в определенной папке:
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); } } }
Сопоставление выполняется через класс 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(); } }
Сопоставление с классами находится в папке « Maps
» и сопоставление для « Expenses
»:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Интерфейс 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; }
Его реализация представляет собой оболочку для 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; } }
Интерфейс ITransaction
, реализованный в этом приложении, использоваться не будет:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Его реализация просто оборачивает транзакцию 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(); } }
Также на данном этапе для юнит-тестов нужен интерфейс ISecurityContext
, определяющий текущего пользователя API (проект — Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
Далее нужно определить интерфейс и реализацию обработчика запросов, который будет содержать всю бизнес-логику для работы с затратами — в нашем случае IExpensesQueryProcessor
и 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(); } }
Следующим шагом является настройка сборки Expenses.Queries.Tests
. Я установил следующие библиотеки:
- Минимальный заказ
- FluentAssertions
Затем в сборке Expenses.Queries.Tests
мы определяем фикстуру для модульных тестов и описываем наши модульные тесты:
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>(); }
После описания модульных тестов описывается реализация обработчика запросов:
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(); } }
Когда бизнес-логика готова, я начинаю писать интеграционные тесты API для определения контракта API.
Первым шагом является подготовка проекта Expenses.Api.IntegrationTests
.
- Установите пакеты NuGet:
- FluentAssertions
- Минимальный заказ
- Microsoft.AspNetCore.TestHost
- Настройка структуры проекта
- Создадим CollectionDefinition, с помощью которого определяем ресурс, который будет создаваться в начале каждого запуска теста и уничтожаться в конце каждого запуска теста.
[CollectionDefinition("ApiCollection")] public class DbCollection : ICollectionFixture<ApiServer> { } ~~~ And define our test server and the client to it with the already authenticated user by default:
открытый класс ApiServer: IDisposable { public const string Имя пользователя = «admin»; общедоступная константная строка Password = «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; } } } ~~~
Для удобства работы с HTTP
запросами в интеграционных тестах я написал хелпер:
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; } }
На этом этапе мне нужно определить контракт REST API для каждой сущности, я напишу его для расходов на REST API:
URL-адрес | Метод | Телосложение | Тип результата | Описание |
---|---|---|---|---|
Расход | ПОЛУЧАТЬ | - | DataResult<ExpenseModel> | Получить все расходы с возможным использованием фильтров и сортировщиков в параметре запроса "команды" |
Расходы/{id} | ПОЛУЧАТЬ | - | ExpenseModel | Получить расход по id |
Расходы | СООБЩЕНИЕ | CreateExpenseModel | ExpenseModel | Создать новую запись о расходах |
Расходы/{id} | ПОМЕЩАТЬ | Упдейкспенсемодел | ExpenseModel | Обновить существующий расход |
Когда вы запрашиваете список затрат, вы можете применять различные команды фильтрации и сортировки с помощью библиотеки AutoQueryable. Пример запроса с фильтрацией и сортировкой:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Значение параметра команды декодирования: take=25&amount>=12&orderbydesc=date
. Таким образом, мы можем найти в запросе элементы подкачки, фильтрации и сортировки. Все параметры запроса очень похожи на синтаксис OData, но, к сожалению, OData еще не готов для .NET Core, поэтому я использую другую полезную библиотеку.
Внизу показаны все модели, используемые в этом 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; } }
Модели CreateExpenseModel
и UpdateExpenseModel
используют атрибуты аннотаций данных для выполнения простых проверок на уровне REST API через атрибуты.
Далее для каждого метода HTTP
создается отдельная папка в проекте и файлы в ней создаются фикстурой для каждого метода HTTP
, который поддерживается ресурсом:
Реализация интеграционного теста для получения списка расходов:
[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(); } }
Реализация интеграционного теста для получения данных о расходах по 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); } }
Реализация интеграционного теста для создания расхода:
[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; } }
Реализация интеграционного теста для изменения расхода:
[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); } }
Реализация интеграционного теста для удаления расхода:
[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(); } }
На данный момент мы полностью определили контракт REST API, и теперь я могу приступить к его реализации на основе ASP.NET Core.
Реализация API
Подготовить проект Расходы. Для этого мне нужно установить следующие библиотеки:
- Автокартограф
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Tools
- Swashbuckle.AspNetCore
После этого нужно приступить к созданию начальной миграции для БД, открыв Консоль диспетчера пакетов, переключившись на проект Expenses.Data.Access
(потому что там лежит контекст EF
) и выполнив команду Add-Migration InitialCreate
:
На следующем шаге заранее подготовьте конфигурационный файл appsettings.json, который после подготовки еще нужно будет скопировать в проект Expenses.Api.IntegrationTests
, потому что оттуда мы будем запускать API тестового экземпляра.
{ "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" } }
Раздел журнала создается автоматически. Я добавил раздел Data
для хранения строки подключения к базе данных и моего ключа ApplicationInsights
.
Конфигурация приложения
Вы должны настроить различные сервисы, доступные в нашем приложении:
Включение ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Зарегистрируйте свои услуги через вызов: ContainerSetup.Setup(services, Configuration);
ContainerSetup
— это класс, созданный для того, чтобы нам не приходилось хранить все регистрации служб в классе Startup
. Класс находится в папке IoC проекта 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); } } }
Почти весь код в этом классе говорит сам за себя, но я хотел бы немного подробнее остановиться на методе ConfigureAutoMapper
.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Этот метод использует вспомогательный класс для поиска всех сопоставлений между моделями и сущностями и наоборот и получает интерфейс IMapper
для создания оболочки IAutoMapper
, которая будет использоваться в контроллерах. В этой оболочке нет ничего особенного — она просто предоставляет удобный интерфейс для методов 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); } }
Для настройки AutoMapper используется вспомогательный класс, задачей которого является поиск сопоставлений для конкретных классов пространства имен. Все сопоставления находятся в папке Расходы/Карты:
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); } }
Важно не забыть про Swagger
, чтобы получить отличное описание API для других разработчиков ASP.net:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
Метод Startup.Configure
добавляет вызов метода InitDatabase
, который автоматически мигрирует базу данных до последней миграции:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
включается, только если приложение работает в среде разработки и не требует аутентификации для доступа к нему:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Далее подключаем аутентификацию (подробности можно посмотреть в репозитории):
ConfigureAuthentication(app);
На этом этапе можно запустить интеграционные тесты и убедиться, что все скомпилировано, но ничего не работает и перейти к контроллеру ExpensesController
.
Примечание: Все контроллеры находятся в папке Расходы/Сервер и условно разделены на две папки: Controllers и RestApi. В папке контроллеры — это контроллеры, которые в старом добром MVC работают как контроллеры — т. е. возвращают разметку, а в RestApi — REST-контроллеры.
Вы должны создать класс Expenses/Server/RestApi/ExpensesController и наследовать его от класса Controller:
public class ExpensesController : Controller { }
Далее настройте маршрутизацию типа ~ / api / Expenses
, пометив класс атрибутом [Route ("api / [controller]")]
.
Чтобы получить доступ к бизнес-логике и картографу, вам необходимо внедрить следующие сервисы:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
На этом этапе можно приступать к реализации методов. Первый способ – получить список расходов:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
Реализация метода очень проста, мы получаем запрос к базе данных, который отображается в IQueryable <ExpenseModel>
от ExpensesQueryProcessor
, который в свою очередь возвращает результат.
Пользовательским атрибутом здесь является QueryableResult
, который использует библиотеку AutoQueryable
для обработки разбиения по страницам, фильтрации и сортировки на стороне сервера. Атрибут находится в папке Expenses/Filters
. В результате этот фильтр возвращает клиенту API данные типа DataResult <ExpenseModel>
.
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}); } }
Также рассмотрим реализацию метода Post, создающего поток:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Здесь следует обратить внимание на атрибут ValidateModel
, который выполняет простую проверку входных данных в соответствии с атрибутами аннотаций данных и делается это посредством встроенных проверок MVC.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Полный код 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); } }
Заключение
Начну с проблем: Основная проблема это сложность первоначальной настройки решения и понимания слоев приложения, но с ростом сложности приложения сложность системы практически не меняется, что является большим плюс при сопровождении такой системы. И очень важно, что у нас есть API, для которого есть набор интеграционных тестов и полный набор юнит-тестов для бизнес-логики. Бизнес-логика полностью отделена от используемой серверной технологии и может быть полностью протестирована. Это решение хорошо подходит для систем со сложным API и сложной бизнес-логикой.
Если вы хотите создать приложение Angular, использующее ваш API, ознакомьтесь с Angular 5 и ASP.NET Core от товарища по Toptaler Пабло Альбеллы.