Tworzenie internetowego interfejsu API ASP.NET za pomocą ASP.NET Core
Opublikowany: 2022-03-11Wstęp
Kilka lat temu dostałem książkę „Pro ASP.NET Web API”. Ten artykuł jest pochodną pomysłów z tej książki, trochę CQRS i mojego własnego doświadczenia w tworzeniu systemów klient-serwer.
W tym artykule omówię:
- Jak utworzyć interfejs API REST od podstaw przy użyciu .NET Core, EF Core, AutoMapper i XUnit
- Jak mieć pewność, że API działa po zmianach?
- Jak maksymalnie uprościć rozwój i obsługę systemu REST API
Dlaczego ASP.NET Core?
ASP.NET Core zapewnia wiele ulepszeń w stosunku do interfejsu API ASP.NET MVC/Web. Po pierwsze, jest to teraz jeden framework, a nie dwa. Bardzo mi się podoba, ponieważ jest wygodny i jest mniej zamieszania. Po drugie, mamy kontenery na logowanie i DI bez dodatkowych bibliotek, co oszczędza mi czas i pozwala skoncentrować się na pisaniu lepszego kodu zamiast na wybieraniu i analizowaniu najlepszych bibliotek.
Czym są procesory zapytań?
Procesor zapytań to podejście, w którym cała logika biznesowa odnosząca się do jednej jednostki systemu jest zawarta w jednej usłudze, a każdy dostęp lub działania z tą jednostką są wykonywane za pośrednictwem tej usługi. Ta usługa jest zwykle nazywana {EntityPluralName}QueryProcessor. W razie potrzeby procesor zapytań zawiera metody CRUD (tworzenie, odczytywanie, aktualizowanie, usuwanie) dla tej jednostki. W zależności od wymagań nie wszystkie metody mogą zostać zaimplementowane. Aby podać konkretny przykład, spójrzmy na ChangePassword. Jeśli metoda procesora zapytań wymaga danych wejściowych, należy podać tylko wymagane dane. Zwykle dla każdej metody tworzona jest osobna klasa zapytania, aw prostych przypadkach możliwe jest (ale nie pożądane) ponowne użycie klasy zapytania.
Nasz cel
W tym artykule pokażę, jak zrobić API dla małego systemu zarządzania kosztami, w tym podstawowe ustawienia uwierzytelniania i kontroli dostępu, ale nie będę wchodził w podsystem uwierzytelniania. Obejmę całą logikę biznesową systemu testami modułowymi oraz stworzę przynajmniej jeden test integracyjny dla każdej metody API na przykładzie jednego podmiotu.
Wymagania dla rozwijanego systemu: Użytkownik może dodawać, edytować, usuwać swoje wydatki oraz widzieć tylko ich wydatki.
Cały kod tego systemu jest dostępny na Github.
Zacznijmy więc projektować nasz mały, ale bardzo użyteczny system.
Warstwy API
Z diagramu wynika, że system będzie miał cztery warstwy:
- Baza danych - Tutaj przechowujemy dane i nic więcej, bez logiki.
- DAL — Aby uzyskać dostęp do danych, używamy wzorca Unit of Work, a w implementacji używamy ORM EF Core z kodem i wzorcami migracji.
- Logika biznesowa - do hermetyzacji logiki biznesowej używamy procesorów zapytań, tylko ta warstwa przetwarza logikę biznesową. Wyjątkiem jest najprostsza walidacja np. pola obowiązkowe, która zostanie wykonana za pomocą filtrów w API.
- REST API — rzeczywisty interfejs, przez który klienci mogą pracować z naszym API, zostanie zaimplementowany za pośrednictwem ASP.NET Core. Konfiguracje tras są określane przez atrybuty.
Oprócz opisanych warstw mamy kilka ważnych pojęć. Pierwszym z nich jest separacja modeli danych. Model danych klienta jest używany głównie w warstwie REST API. Konwertuje zapytania na modele domeny i odwrotnie z modelu domeny na model danych klienta, ale modele zapytań mogą być również używane w procesorach zapytań. Konwersja odbywa się za pomocą AutoMappera.
Struktura projektu
Do stworzenia projektu użyłem VS 2017 Professional. Zazwyczaj udostępniam kod źródłowy i testy w różnych folderach. Jest wygodny, wygląda dobrze, testy w CI przebiegają wygodnie i wygląda na to, że Microsoft rekomenduje to w ten sposób:
Opis Projektu:
Projekt | Opis |
---|---|
Wydatki | Projekt dla kontrolerów, mapowanie pomiędzy modelem domeny a modelem API, konfiguracja API |
Expenses.Api.Common | W tym momencie gromadzone są klasy wyjątków, które są interpretowane w określony sposób przez filtry, aby zwracały użytkownikowi poprawne kody HTTP z błędami |
Expenses.Api.Models | Projekt dla modeli API |
Wydatki.Dostęp do danych | Projekt interfejsów i implementacja wzorca Jednostki Pracy |
Wydatki.Dane.Model | Projekt dla modelu domeny |
Wydatki. Zapytania | Projekt dla procesorów zapytań i klas specyficznych dla zapytań |
Wydatki.Bezpieczeństwo | Projekt interfejsu i implementacji kontekstu bezpieczeństwa bieżącego użytkownika |
Referencje pomiędzy projektami:
Wydatki utworzone z szablonu:
Inne projekty w folderze src według szablonu:
Wszystkie projekty w folderze testy według szablonu:
Realizacja
W tym artykule nie zostanie opisana część związana z interfejsem użytkownika, chociaż jest ona zaimplementowana.
Pierwszym krokiem było opracowanie modelu danych, który znajduje się w zestawie Expenses.Data.Model
:
Klasa Expense
zawiera następujące atrybuty:
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; } }
Ta klasa obsługuje „miękkie usuwanie” za pomocą atrybutu IsDeleted
i zawiera wszystkie dane dotyczące jednego wydatku konkretnego użytkownika, które przydadzą się nam w przyszłości.
Klasy User
, Role
i UserRole
odnoszą się do podsystemu dostępu; system ten nie pretenduje do miana systemu roku i opis tego podsystemu nie jest celem tego artykułu; dlatego model danych i niektóre szczegóły implementacji zostaną pominięte. System organizacji dostępu można zastąpić doskonalszym bez zmiany logiki biznesowej.
Następnie szablon Unit of Work został zaimplementowany w zestawie Expenses.Data.Access
, pokazano strukturę tego projektu:
Do montażu wymagane są następujące biblioteki:
-
Microsoft.EntityFrameworkCore.SqlServer
Konieczne jest zaimplementowanie kontekstu EF
, który automatycznie znajdzie mapowania w określonym folderze:
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); } } }
Mapowanie odbywa się za pośrednictwem klasy 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(); } }
Mapowanie do klas znajduje się w folderze Maps
, a mapowanie dla Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Interfejs 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; }
Jego implementacja jest opakowaniem dla 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; } }
Zaimplementowany w tej aplikacji interfejs ITransaction
nie będzie używany:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Jego implementacja po prostu obejmuje transakcję 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(); } }
Również na tym etapie do testów jednostkowych potrzebny jest interfejs ISecurityContext
, który definiuje aktualnego użytkownika API (projekt to Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
Następnie musisz zdefiniować interfejs i implementację procesora zapytań, który będzie zawierał całą logikę biznesową do pracy z kosztami – w naszym przypadku IExpensesQueryProcessor
i 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(); } }
Następnym krokiem jest skonfigurowanie zestawu Expenses.Queries.Tests
. Zainstalowałem następujące biblioteki:
- Moq
- Płynne potwierdzenia
Następnie w zestawie Expenses.Queries.Tests
definiujemy oprawę dla testów jednostkowych i opisujemy nasze testy jednostkowe:
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>(); }
Po opisaniu testów jednostkowych opisana jest implementacja procesora zapytań:
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(); } }
Gdy logika biznesowa jest już gotowa, zaczynam pisać testy integracji API w celu ustalenia kontraktu API.
Pierwszym krokiem jest przygotowanie projektu Expenses.Api.IntegrationTests
- Zainstaluj pakiety nuget:
- Płynne potwierdzenia
- Moq
- Microsoft.AspNetCore.TestHost
- Skonfiguruj strukturę projektu
- Utwórz CollectionDefinition, za pomocą którego określamy zasób, który zostanie utworzony na początku każdego uruchomienia testu i zostanie zniszczony na końcu każdego uruchomienia testu.
[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 Nazwa użytkownika = „admin”; public const string Hasło = „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; } } } ~~~
Dla wygody pracy z żądaniami HTTP
w testach integracyjnych napisałem helper:
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; } }
Na tym etapie muszę zdefiniować kontrakt na REST API dla każdego podmiotu, napiszę go na wydatki na REST API:
URL | metoda | Typ ciała | Typ wyniku | Opis |
---|---|---|---|---|
Koszt | DOSTWAĆ | - | Wynik danych<Model wydatków> | Uzyskaj wszystkie wydatki z możliwym użyciem filtrów i sorterów w parametrze zapytania "polecenia" |
Wydatki/{id} | DOSTWAĆ | - | Model wydatków | Uzyskaj wydatek według identyfikatora |
Wydatki | POCZTA | Utwórz model kosztów | Model wydatków | Utwórz nowy rekord wydatków |
Wydatki/{id} | WKŁADAĆ | Aktualizuj model wydatków | Model wydatków | Zaktualizuj istniejący wydatek |
Gdy żądasz listy kosztów, możesz zastosować różne polecenia filtrowania i sortowania za pomocą biblioteki AutoQueryable. Przykładowe zapytanie z filtrowaniem i sortowaniem:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Wartość parametru polecenia dekodowania to take=25&amount>=12&orderbydesc=date
. Możemy więc znaleźć w zapytaniu części stronicowania, filtrowania i sortowania. Wszystkie opcje zapytania są bardzo podobne do składni OData, ale niestety OData nie jest jeszcze gotowy dla .NET Core, więc korzystam z innej przydatnej biblioteki.
Na dole pokazane są wszystkie modele używane w tym 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; } }
Modele CreateExpenseModel
i UpdateExpenseModel
używają atrybutów adnotacji danych do wykonywania prostych sprawdzeń na poziomie REST API za pomocą atrybutów.
Następnie dla każdej metody HTTP
tworzony jest osobny folder w projekcie, a pliki w nim tworzone są według osprzętu dla każdej obsługiwanej przez zasób metody HTTP
:
Wykonanie testu integracyjnego do uzyskania zestawienia wydatków:
[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(); } }
Implementacja testu integracyjnego w celu uzyskania danych o wydatkach po 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); } }
Wykonanie testu integracyjnego do tworzenia wydatku:
[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; } }
Wdrożenie testu integracyjnego do zmiany wydatku:
[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); } }
Wdrożenie testu integracyjnego do usunięcia wydatku:
[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(); } }
W tym momencie mamy już w pełni zdefiniowany kontrakt REST API i teraz mogę zacząć go wdrażać w oparciu o ASP.NET Core.
Implementacja API
Przygotuj projekt Wydatki. W tym celu muszę zainstalować następujące biblioteki:
- AutoMapowanie
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Narzędzia
- Swashbuckle.AspNetCore
Następnie musisz rozpocząć tworzenie początkowej migracji dla bazy danych, otwierając konsolę Menedżera pakietów, przełączając się do projektu Expenses.Data.Access
(ponieważ znajduje się tam kontekst EF
) i uruchamiając polecenie Add-Migration InitialCreate
:
W kolejnym kroku przygotuj wcześniej plik konfiguracyjny appsettings.json, który po przygotowaniu jeszcze trzeba będzie skopiować do projektu Expenses.Api.IntegrationTests
, ponieważ stamtąd uruchomimy API instancji testowej.
{ "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" } }
Sekcja logowania jest tworzona automatycznie. Dodałem sekcję Data
, aby przechowywać parametry połączenia do bazy danych i mój klucz ApplicationInsights
.
Konfiguracja aplikacji
Musisz skonfigurować różne usługi dostępne w naszej aplikacji:
Włączanie ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Zarejestruj swoje usługi za pośrednictwem połączenia: ContainerSetup.Setup(services, Configuration);
ContainerSetup
to klasa stworzona, dzięki czemu nie musimy przechowywać wszystkich rejestracji usług w klasie Startup
. Klasa znajduje się w folderze IoC projektu 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); } } }
Prawie cały kod w tej klasie mówi sam za siebie, ale chciałbym zagłębić się w metodę ConfigureAutoMapper
.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Ta metoda używa klasy pomocnika, aby znaleźć wszystkie mapowania między modelami i jednostkami i na odwrót, i pobiera interfejs IMapper
do utworzenia opakowania IAutoMapper
, które będzie używane w kontrolerach. W tym opakowaniu nie ma nic szczególnego — po prostu zapewnia wygodny interfejs do metod 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); } }
Do konfiguracji AutoMappera wykorzystywana jest klasa pomocnicza, której zadaniem jest wyszukiwanie mapowań dla określonych klas przestrzeni nazw. Wszystkie mapowania znajdują się w folderze 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); } }
Ważne jest, aby nie zapomnieć o Swagger
, aby uzyskać doskonały opis API dla innych programistów ASP.net:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
Metoda Startup.Configure
dodaje wywołanie do metody InitDatabase
, która automatycznie migruje bazę danych do ostatniej migracji:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
jest włączony tylko wtedy, gdy aplikacja działa w środowisku programistycznym i nie wymaga uwierzytelniania, aby uzyskać do niej dostęp:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Następnie łączymy uwierzytelnianie (szczegóły w repozytorium):
ConfigureAuthentication(app);
W tym momencie możesz uruchomić testy integracyjne i upewnić się, że wszystko jest skompilowane, ale nic nie działa i przejść do kontrolera ExpensesController
.
Uwaga: Wszystkie kontrolery znajdują się w folderze Expenses/Server i są warunkowo podzielone na dwa foldery: Controllers i RestApi. W folderze kontrolery to kontrolery, które działają jako kontrolery w starym dobrym MVC — tj. zwracają znacznik, aw RestApi — kontrolery REST.
Należy utworzyć klasę Expenses/Server/RestApi/ExpensesController i odziedziczyć ją z klasy Controller:
public class ExpensesController : Controller { }
Następnie skonfiguruj routing typu ~ / api / Expenses
, zaznaczając klasę atrybutem [Route ("api / [controller]")]
.
Aby uzyskać dostęp do logiki biznesowej i mapera, musisz wstrzyknąć następujące usługi:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
Na tym etapie możesz zacząć wdrażać metody. Pierwsza metoda to uzyskanie listy wydatków:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
Implementacja metody jest bardzo prosta, otrzymujemy zapytanie do bazy danych, które jest zmapowane w IQueryable <ExpenseModel>
z ExpensesQueryProcessor
, które z kolei wraca w wyniku.
Atrybut niestandardowy tutaj to QueryableResult
, który używa biblioteki AutoQueryable
do obsługi stronicowania, filtrowania i sortowania po stronie serwera. Atrybut znajduje się w folderze Expenses/Filters
. W rezultacie ten filtr zwraca dane typu DataResult <ExpenseModel>
do klienta 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}); } }
Przyjrzyjmy się również implementacji metody Post, tworzącej przepływ:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
W tym miejscu należy zwrócić uwagę na atrybut ValidateModel
, który wykonuje prostą walidację danych wejściowych zgodnie z atrybutami adnotacji danych i odbywa się to za pomocą wbudowanych kontroli MVC.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Pełny kod 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); } }
Wniosek
Zacznę od problemów: Głównym problemem jest złożoność początkowej konfiguracji rozwiązania i zrozumienie warstw aplikacji, ale wraz ze wzrostem złożoności aplikacji złożoność systemu jest prawie niezmieniona, co jest dużym plus, gdy towarzyszy takiemu systemowi. I bardzo ważne jest, że mamy API, dla którego jest zestaw testów integracyjnych oraz komplet testów jednostkowych dla logiki biznesowej. Logika biznesowa jest całkowicie oddzielona od używanej technologii serwerowej i może być w pełni przetestowana. To rozwiązanie doskonale nadaje się do systemów ze złożonym API i złożoną logiką biznesową.
Jeśli chcesz zbudować aplikację Angular, która wykorzystuje Twój interfejs API, sprawdź Angular 5 i ASP.NET Core innego Toptalera Pablo Albella.