socialgekon.com
  • Основен
  • Back-End
  • Уеб Интерфейс
  • Рентабилност И Ефективност
  • Ui Design
Back-End

Изграждане на ASP.NET уеб API с ASP.NET Core

Въведение

Преди няколко години получих книгата „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. Първо, сега това е една рамка, а не две. Много ми харесва, защото е удобен и има по-малко объркване. На второ място, имаме регистриране и DI контейнери без допълнителни библиотеки, което ми спестява време и ми позволява да се концентрирам върху писането на по-добър код, вместо да избирам и анализирам най-добрите библиотеки.



Какво представляват процесорите за заявки?

Процесорът за заявки е подход, когато цялата бизнес логика, свързана с един обект на системата, е капсулирана в една услуга и всеки достъп или действия с този обект се извършват чрез тази услуга. Тази услуга обикновено се нарича {EntityPluralName} QueryProcessor. Ако е необходимо, процесорът за заявки включва CRUD (създаване, четене, актуализиране, изтриване) методи за този обект. В зависимост от изискванията, не всички методи могат да бъдат приложени. За да дадем конкретен пример, нека да разгледаме ChangePassword. Ако методът на процесор на заявки изисква входни данни, тогава трябва да се предоставят само необходимите данни. Обикновено за всеки метод се създава отделен клас на заявка и в прости случаи е възможно (но не е желателно) повторното използване на класа на заявката.

Нашата цел

В тази статия ще ви покажа как да направите API за система за управление на малки разходи, включително основни настройки за удостоверяване и контрол на достъпа, но няма да влизам в подсистемата за удостоверяване. Ще обхвана цялата бизнес логика на системата с модулни тестове и ще създам поне един интеграционен тест за всеки метод на API на пример за един обект.

Изисквания към разработената система: Потребителят може да добавя, редактира, изтрива своите разходи и може да вижда само техните разходи.

Целият код на тази система е достъпен на на Github .

И така, нека започнем да проектираме нашата малка, но много полезна система.

API за слоеве

Диаграма, показваща API слоеве.

Диаграмата показва, че системата ще има четири слоя:

  • База данни - Тук съхраняваме данни и нищо повече, няма логика.
  • DAL - За достъп до данните използваме шаблона Unit of Work и при изпълнението използваме ORM EF Core с код първо и модели на миграция.
  • Бизнес логика - за капсулиране на бизнес логика използваме процесори за заявки, само този слой обработва бизнес логика. Изключението е най-простата проверка като задължителни полета, която ще бъде изпълнена с помощта на филтри в API.
  • REST API - Действителният интерфейс, чрез който клиентите могат да работят с нашия API, ще бъде реализиран чрез ASP.NET Core. Конфигурациите на маршрута се определят от атрибути.

В допълнение към описаните слоеве имаме няколко важни концепции. Първото е разделянето на модели на данни. Моделът на клиентските данни се използва главно в слоя REST API. Той преобразува заявки в модели на домейни и обратно от модел на домейн в модел на клиентски данни, но модели за заявки могат да се използват и в процесори за заявки. Преобразуването се извършва с помощта на AutoMapper.

Структура на проекта

Използвах VS 2017 Professional за създаване на проекта. Обикновено споделям изходния код и тестове в различни папки. Удобно е, изглежда добре, тестовете в CI се изпълняват удобно и изглежда, че Microsoft препоръчва да се направи по този начин:

Структура на папките във VS 2017 Professional.

Описание на проекта:

Проект Описание
Разходи Проект за контролери, картографиране между домейн модел и API модел, API конфигурация
Разходи.Api.Често В този момент има събрани класове изключения, които се интерпретират по определен начин от филтри, за да върнат правилните HTTP кодове с грешки на потребителя
Разходи.Api.Models Проект за API модели
Expenses.Data.Access Проект за интерфейси и изпълнение на модела на единица работа
Разходи.Дата.Модел Проект за модел на домейн
Разходи. Запитвания Проект за процесори за заявки и специфични за заявката класове
Разходи. Сигурност Проект за интерфейс и изпълнение на текущия контекст на защита на потребителя

Препратки между проекти:

Диаграма, показваща препратки между проекти.

Разходи, създадени от шаблона:

Списък на разходите, създадени от шаблона.

Други проекти в папката src по шаблон:

Списък на други проекти в папката 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 класовете се отнасят до подсистемата за достъп; тази система не се представя за система на годината и описанието на тази подсистема не е целта на тази статия; следователно моделът на данни и някои подробности за изпълнението ще бъдат пропуснати. Системата за организация на достъпа може да бъде заменена с по-съвършена, без да се променя бизнес логиката.

След това шаблонът за единица работа беше внедрен в Expenses.Data.Access събрание, структурата на този проект е показана:

Expenses.Data.Access структура на проекта

За сглобяване са необходими следните библиотеки:

  • Microsoft.EntityFrameworkCore.SqlServer

Необходимо е да се приложи EF контекст, който автоматично ще намери съпоставянията в определена папка:

public class MainDbContext : DbContext { public MainDbContext(DbContextOptions 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 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() .ToTable('Expenses') .HasKey(x => x.Id); } }

Интерфейс IUnitOfWork:

public interface IUnitOfWork : IDisposable { ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot); void Add(T obj) where T: class ; void Update(T obj) where T : class; void Remove(T obj) where T : class; IQueryable Query() where T : class; void Commit(); Task CommitAsync(); void Attach(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 obj) where T : class { var set = _context.Set(); set.Add(obj); } public void Update(T obj) where T : class { var set = _context.Set(); set.Attach(obj); _context.Entry(obj).State = EntityState.Modified; } void IUnitOfWork.Remove(T obj) { var set = _context.Set(); set.Remove(obj); } public IQueryable Query() where T : class { return _context.Set(); } public void Commit() { _context.SaveChanges(); } public async Task CommitAsync() { await _context.SaveChangesAsync(); } public void Attach(T newUser) where T : class { var set = _context.Set(); 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 Get(); Expense Get(int id); Task Create(CreateExpenseModel model); Task Update(int id, UpdateExpenseModel model); Task Delete(int id); } public class ExpensesQueryProcessor : IExpensesQueryProcessor { public IQueryable Get() { throw new NotImplementedException(); } public Expense Get(int id) { throw new NotImplementedException(); } public Task Create(CreateExpenseModel model) { throw new NotImplementedException(); } public Task Update(int id, UpdateExpenseModel model) { throw new NotImplementedException(); } public Task Delete(int id) { throw new NotImplementedException(); } }

Следващата стъпка е да конфигурирате Expenses.Queries.Tests монтаж. Инсталирах следните библиотеки:

  • Moq
  • FluentAssertions

След това в Expenses.Queries.Tests сглобяваме, определяме приспособлението за единични тестове и описваме нашите модулни тестове:

public class ExpensesQueryProcessorTests { private Mock _uow; private List _expenseList; private IExpensesQueryProcessor _query; private Random _random; private User _currentUser; private Mock _securityContext; public ExpensesQueryProcessorTests() { _random = new Random(); _uow = new Mock(); _expenseList = new List(); _uow.Setup(x => x.Query()).Returns(() => _expenseList.AsQueryable()); _currentUser = new User{Id = _random.Next()}; _securityContext = new Mock(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(); } [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(); } [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(); } [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(); } [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(); } [Fact] public void DeleteShoudlThrowExceptionIfItemIsNotFound() { Action execute = () => { _query.Delete(_random.Next()).Wait(); }; execute.ShouldThrow(); }

След като се опишат модулните тестове, се описва изпълнението на процесор на заявки:

public class ExpensesQueryProcessor : IExpensesQueryProcessor { private readonly IUnitOfWork _uow; private readonly ISecurityContext _securityContext; public ExpensesQueryProcessor(IUnitOfWork uow, ISecurityContext securityContext) { _uow = uow; _securityContext = securityContext; } public IQueryable Get() { var query = GetQuery(); return query; } private IQueryable GetQuery() { var q = _uow.Query() .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 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 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

  1. Инсталирайте nuget пакети:
    • FluentAssertions
    • Moq
    • Microsoft.AspNetCore.TestHost
  2. Създайте структура на проекта Структура на папката за разходи
  3. Създавам КолекцияОпределение с помощта на която определяме ресурса, който ще бъде създаден в началото на всяко тестово изпълнение и ще бъде унищожен в края на всяко тестово изпълнение.
[CollectionDefinition('ApiCollection')] public class DbCollection : ICollectionFixture { } ~~~ And define our test server and the client to it with the already authenticated user by default:

публичен клас ApiServer: IDisposable {public const string Username = “admin”; публичен const низ Парола = “admin”;

private IConfigurationRoot _config; public ApiServer() { _config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile('appsettings.json') .Build(); Server = new TestServer(new WebHostBuilder().UseStartup()); 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(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 PostAsync(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(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 PutAsync(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(respnoseText); return data; } }

На този етап трябва да дефинирам договор за REST API за всеки обект, ще го напиша за разходите за REST API:

URL Метод Тип тяло Тип резултат Описание
Разход ВЗЕМЕТЕ - DataResult Вземете всички разходи с възможно използване на филтри и сортиращи в параметър на заявката „команди“
Разходи / {id} ВЗЕМЕТЕ - ExpenseModel Вземете разход чрез id
Разходи ПОСТ CreateExpenseModel ExpenseModel Създайте нов разходен запис
Разходи / {id} СЛАГАМ UpdateExpenseModel 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 { 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 Get(HttpClient client) { var response = await client.GetAsync($'api/Expenses'); response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync(); var items = JsonConvert.DeserializeObject(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 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(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 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('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($'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

Подгответе проекта Разходи. За това трябва да инсталирам следните библиотеки:

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

След това трябва да започнете да създавате първоначална миграция за базата данни, като отворите конзолата на Package Manager, превключвайки към Expenses.Data.Access проект (тъй като EF контекстът лежи там) и стартиране на Add-Migration InitialCreate команда:

API документация

В следващата стъпка подгответе предварително конфигурационния файл 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(); services.AddScoped(); services.AddScoped(); } private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient(); } private static void AddUow(IServiceCollection services, IConfigurationRoot configuration) { var connectionString = configuration['Data:main']; services.AddEntityFrameworkSqlServer(); services.AddDbContext(options => options.UseSqlServer(connectionString)); services.AddScoped(ctx => new EFUnitOfWork(ctx.GetRequiredService())); services.AddScoped(); services.AddScoped(); } 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() == 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(); }

Този метод използва помощния клас, за да намери всички съпоставяния между модели и обекти и обратно и получава 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(object objectToMap) { return _mapper.Map(objectToMap); } public TResult[] Map(IEnumerable sourceQuery) { return sourceQuery.Select(x => _mapper.Map(x)).ToArray(); } public IQueryable Map(IQueryable sourceQuery) { return sourceQuery.ProjectTo(_mapper.ConfigurationProvider); } public void Map(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; } } }

Всички съпоставяния трябва да изпълняват специфичен интерфейс:

public interface IAutoMapperTypeConfigurator { void Configure(IMapperConfigurationExpression configuration); }

Пример за картографиране от обект на модел:

public class ExpenseMap : IAutoMapperTypeConfigurator { public void Configure(IMapperConfigurationExpression configuration) { var map = configuration.CreateMap(); map.ForMember(x => x.Username, x => x.MapFrom(y => y.User.FirstName + ' ' + y.User.LastName)); } }

Също така в Startup.ConfigureServices метод, конфигурирането на удостоверяване чрез токени JWT Bearer:

services.AddAuthorization(auth => { auth.AddPolicy('Bearer', new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser().Build()); });

И услугите регистрираха изпълнението на ISecurityContext, което всъщност ще се използва за определяне на текущия потребител:

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

Също така променихме малко регистрацията по подразбиране за MVC, за да използваме персонализиран филтър за грешки, за да преобразуваме изключенията в правилните кодове за грешки:

services.AddMvc(options => { options.Filters.Add(new ApiExceptionFilter()); });

Прилагане на ApiExceptionFilter филтър:

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 за други https://www.toptal.com/api :

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

Startup.Configure метод добавя повикване към InitDatabase метод, който автоматично мигрира базата данни до последната миграция:

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

Swagger е включен само ако приложението работи в средата за разработка и не изисква удостоверяване за достъп до него:

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

След това свързваме удостоверяване (подробности можете да намерите в хранилището):

ConfigureAuthentication(app);

На този етап можете да стартирате тестове за интеграция и да се уверите, че всичко е компилирано, но нищо не работи и отидете на контролера ExpensesController

Забележка: Всички контролери се намират в папката Expenses / Server и условно са разделени на две папки: 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] [QueryableResult] public IQueryable Get() { var result = _query.Get(); var models = _mapper.Map(result); return models; }

Внедряването на метода е много просто, получаваме заявка към базата данни, която е картографирана в IQueryable от ExpensesQueryProcessor, което от своя страна се връща в резултат.

Персонализираният атрибут тук е QueryableResult, който използва AutoQueryable библиотека за обработка на пейджинг, филтриране и сортиране от страна на сървъра. Атрибутът се намира в папката Expenses/Filters. В резултат този филтър връща данни от тип DataResult към клиента на 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}); } }

Също така, нека да разгледаме изпълнението на метода Post, създавайки поток:

[HttpPost] [ValidateModel] public async Task Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map(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 Get() { var result = _query.Get(); var models = _mapper.Map(result); return models; } [HttpGet('{id}')] public ExpenseModel Get(int id) { var item = _query.Get(id); var model = _mapper.Map(item); return model; } [HttpPost] [ValidateModel] public async Task Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map(item); return model; } [HttpPut('{id}')] [ValidateModel] public async Task Put(int id, [FromBody]UpdateExpenseModel requestModel) { var item = await _query.Update(id, requestModel); var model = _mapper.Map(item); return model; } [HttpDelete('{id}')] public async Task Delete(int id) { await _query.Delete(id); } }

Заключение

Ще започна с проблеми: Основният проблем е сложността на първоначалната конфигурация на решението и разбирането на слоевете на приложението, но с нарастващата сложност на приложението сложността на системата е почти непроменена, което е голям плюс, когато придружава такава система. И е много важно да имаме API, за който има набор от тестове за интеграция и пълен набор от модулни тестове за бизнес логика. Бизнес логиката е напълно отделена от използваната сървърна технология и може да бъде напълно тествана. Това решение е подходящо за системи със сложен API и сложна бизнес логика.

Ако искате да създадете Angular приложение, което консумира вашия API, разгледайте Angular 5 и ASP.NET Core от колегата ApeeScapeer Пабло Албела.

Разбиране на основите

Какво е обект за прехвърляне на данни?

Обект за прехвърляне на данни (DTO) е представяне на един или повече обекти в база данни. Един обект на база данни може да бъде представен с или без произволен брой DTO

Какво представлява уеб API?

Уеб API осигурява интерфейс за достъп до бизнес логиката на системата до базата данни и основната логика е капсулирана в API.

Какво е REST API?

Действителният интерфейс, чрез който клиентите могат да работят с уеб API. Работи само по протокол HTTP (s).

Какво е модулно тестване?

Unit testing е съвкупност от малки, специфични, много бързи тестове, обхващащи малка единица код, напр. класове. За разлика от интеграционното тестване, модулното тестване гарантира, че всички аспекти на модула се тестват изолирано от други компоненти на цялостното приложение.

Какво е тестване за интеграция на уеб API?

Интеграционното тестване е набор от тестове спрямо определена крайна точка на API. За разлика от модулното тестване, интеграционното тестване проверява дали всички единици код, които захранват API, работят както се очаква. Тези тестове може да са по-бавни от единичните тестове.

Какво представлява ASP.NET Core?

ASP.NET Core е пренаписване и следващото поколение на ASP.NET 4.x. Той е междуплатформен и съвместим с Windows, Linux и Docker контейнери.

Какво представлява токенът JWT Bearer?

Токенът на носител JWT (JSON Web Token) е обект без гражданство и подписан JSON обект, който се използва широко в съвременните уеб и мобилни приложения за предоставяне на достъп до API. Тези символи съдържат свои собствени претенции и се приемат, докато подписът е валиден.

Какво е Swagger?

Swagger е документ, използван от библиотека, REST API. Самата документация може да се използва и за автоматично генериране на клиент за API за различни платформи.

Ръководство за Monorepos за Front-end Code

Подвижен

Ръководство за Monorepos за Front-end Code
Ръководител на публикации

Ръководител на публикации

Други

Популярни Публикации
ApeeScape стартира Staffing.com, за да насочи напред концерта, свободната практика и икономиката на талантите
ApeeScape стартира Staffing.com, за да насочи напред концерта, свободната практика и икономиката на талантите
Защо вече трябва да надстроите до Java 8
Защо вече трябва да надстроите до Java 8
Stars Realigned: Подобряване на системата за оценяване IMDb
Stars Realigned: Подобряване на системата за оценяване IMDb
Решения, а не изкуство - истинската бизнес стойност на дизайна
Решения, а не изкуство - истинската бизнес стойност на дизайна
Управление на отдалечени фрийлансъри? Тези принципи ще помогнат
Управление на отдалечени фрийлансъри? Тези принципи ще помогнат
 
Видео анализ на машинно обучение: Идентифициране на риби
Видео анализ на машинно обучение: Идентифициране на риби
Проучване на мечото дело на криптовалутния балон
Проучване на мечото дело на криптовалутния балон
Пълното ръководство за филтрите на камерата на iPhone (включително скритите)
Пълното ръководство за филтрите на камерата на iPhone (включително скритите)
Първи стъпки с модули и модулна разработка отпред
Първи стъпки с модули и модулна разработка отпред
Как да прехвърляте снимки от вашия iPhone на друг iPhone или iPad
Как да прехвърляте снимки от вашия iPhone на друг iPhone или iPad
Категории
ПодвиженНаука За Данни И Бази ДанниПродукти Хора И ЕкипиТехнологияСтрелбаUi DesignЖизнен Цикъл На ПродуктаСъвети И ИнструментиУправление На ПроектиРедактиране

© 2023 | Всички Права Запазени

socialgekon.com