Membangun ASP.NET Web API dengan ASP.NET Core

Diterbitkan: 2022-03-11

pengantar

Beberapa tahun yang lalu, saya mendapatkan buku "Pro ASP.NET Web API". Artikel ini adalah cabang ide dari buku ini, sedikit CQRS, dan pengalaman saya sendiri mengembangkan sistem client-server.

Dalam artikel ini, saya akan membahas:

  • Cara membuat REST API dari awal menggunakan .NET Core, EF Core, AutoMapper, dan XUnit
  • Bagaimana memastikan bahwa API berfungsi setelah perubahan
  • Bagaimana menyederhanakan pengembangan dan dukungan sistem REST API sebanyak mungkin

Mengapa Inti ASP.NET?

ASP.NET Core menyediakan banyak peningkatan pada ASP.NET MVC/Web API. Pertama, sekarang menjadi satu kerangka kerja dan bukan dua. Saya sangat menyukainya karena nyaman dan tidak ada kebingungan. Kedua, kami memiliki wadah logging dan DI tanpa pustaka tambahan, yang menghemat waktu saya dan memungkinkan saya untuk berkonsentrasi menulis kode yang lebih baik daripada memilih dan menganalisis pustaka terbaik.

Apa itu Pemroses Kueri?

Pemroses kueri adalah pendekatan ketika semua logika bisnis yang berkaitan dengan satu entitas sistem dienkapsulasi dalam satu layanan dan setiap akses atau tindakan dengan entitas ini dilakukan melalui layanan ini. Layanan ini biasanya disebut {EntityPluralName}QueryProcessor. Jika perlu, pemroses kueri menyertakan metode CRUD (buat, baca, perbarui, hapus) untuk entitas ini. Tergantung pada persyaratan, tidak semua metode dapat diimplementasikan. Untuk memberikan contoh spesifik, mari kita lihat ChangePassword. Jika metode pemroses kueri memerlukan data masukan, maka hanya data yang diperlukan yang harus disediakan. Biasanya, untuk setiap metode, kelas kueri terpisah dibuat, dan dalam kasus sederhana, dimungkinkan (tetapi tidak diinginkan) untuk menggunakan kembali kelas kueri.

Tujuan kami

Pada artikel ini, saya akan menunjukkan cara membuat API untuk sistem manajemen biaya kecil, termasuk pengaturan dasar untuk otentikasi dan kontrol akses, tetapi saya tidak akan masuk ke subsistem otentikasi. Saya akan membahas seluruh logika bisnis sistem dengan pengujian modular dan membuat setidaknya satu pengujian integrasi untuk setiap metode API pada contoh satu entitas.

Persyaratan untuk sistem yang dikembangkan: Pengguna dapat menambah, mengedit, menghapus pengeluarannya dan hanya dapat melihat pengeluaran mereka.

Seluruh kode sistem ini tersedia di Github.

Jadi, mari kita mulai merancang sistem kita yang kecil tapi sangat berguna.

Lapisan API

Diagram yang menunjukkan lapisan API.

Diagram menunjukkan bahwa sistem akan memiliki empat lapisan:

  • Basis Data - Di sini kami menyimpan data dan tidak lebih, tidak ada logika.
  • DAL - Untuk mengakses data, kami menggunakan pola Unit of Work dan dalam implementasinya kami menggunakan ORM EF Core dengan kode terlebih dahulu dan pola migrasi.
  • Logika bisnis - untuk merangkum logika bisnis, kami menggunakan prosesor kueri, hanya lapisan ini yang memproses logika bisnis. Pengecualian adalah validasi paling sederhana seperti bidang wajib, yang akan dieksekusi melalui filter di API.
  • REST API - Antarmuka aktual di mana klien dapat bekerja dengan API kami akan diimplementasikan melalui ASP.NET Core. Konfigurasi rute ditentukan oleh atribut.

Selain lapisan yang dijelaskan, kami memiliki beberapa konsep penting. Yang pertama adalah pemisahan model data. Model data klien terutama digunakan di lapisan REST API. Ini mengubah kueri ke model domain dan sebaliknya dari model domain ke model data klien, tetapi model kueri juga dapat digunakan dalam pemroses kueri. Konversi dilakukan menggunakan AutoMapper.

Struktur Proyek

Saya menggunakan VS 2017 Professional untuk membuat proyek. Saya biasanya membagikan kode sumber dan pengujian pada folder yang berbeda. Nyaman, terlihat bagus, tes di CI berjalan dengan nyaman, dan tampaknya Microsoft merekomendasikan melakukannya dengan cara ini:

Struktur folder di VS 2017 Professional.

Deskripsi Proyek:

Proyek Keterangan
Pengeluaran Proyek untuk pengontrol, pemetaan antara model domain dan model API, konfigurasi API
Expenses.Api.Common Pada titik ini, ada kelas pengecualian yang dikumpulkan yang ditafsirkan dengan cara tertentu oleh filter untuk mengembalikan kode HTTP yang benar dengan kesalahan kepada pengguna
Beban.Api.Model Proyek untuk model API
Biaya.Data.Akses Proyek untuk antarmuka dan implementasi pola Unit Kerja
Pengeluaran.Data.Model Proyek untuk model domain
Biaya.Pertanyaan Proyek untuk pemroses kueri dan kelas khusus kueri
Biaya. Keamanan Proyek untuk antarmuka dan implementasi konteks keamanan pengguna saat ini

Referensi antar proyek:

Diagram yang menunjukkan referensi antar proyek.

Biaya yang dibuat dari template:

Daftar pengeluaran yang dibuat dari template.

Proyek lain di folder src dengan templat:

Daftar proyek lain di folder src menurut templat.

Semua proyek di folder tes berdasarkan templat:

Daftar proyek di folder tes menurut templat.

Penerapan

Artikel ini tidak akan menjelaskan bagian yang terkait dengan UI, meskipun sudah diterapkan.

Langkah pertama adalah mengembangkan model data yang terletak di assembly Expenses.Data.Model :

Diagram hubungan antar peran

Kelas Expense berisi atribut berikut:

 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; } }

Kelas ini mendukung "penghapusan lunak" melalui atribut IsDeleted dan berisi semua data untuk satu pengeluaran pengguna tertentu yang akan berguna bagi kami di masa mendatang.

Kelas User , Role , dan UserRole mengacu pada subsistem akses; sistem ini tidak berpura-pura menjadi sistem tahun ini dan deskripsi subsistem ini bukanlah tujuan dari artikel ini; oleh karena itu, model data dan beberapa detail implementasi akan dihilangkan. Sistem organisasi akses dapat diganti dengan yang lebih sempurna tanpa mengubah logika bisnis.

Selanjutnya, templat Unit Kerja diimplementasikan dalam perakitan Expenses.Data.Access , struktur proyek ini ditampilkan:

Struktur proyek Expenses.Data.Access

Pustaka berikut diperlukan untuk perakitan:

  • Microsoft.EntityFrameworkCore.SqlServer

Penting untuk menerapkan konteks EF yang secara otomatis akan menemukan pemetaan di folder tertentu:

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

Pemetaan dilakukan melalui kelas 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(); } }

Pemetaan ke kelas ada di folder Maps , dan pemetaan untuk Expenses :

 public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }

Antarmuka 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; }

Implementasinya adalah pembungkus untuk 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; } }

Antarmuka ITransaction yang diimplementasikan dalam aplikasi ini tidak akan digunakan:

 public interface ITransaction : IDisposable { void Commit(); void Rollback(); }

Implementasinya hanya membungkus transaksi 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(); } }

Juga pada tahap ini, untuk pengujian unit, antarmuka ISecurityContext diperlukan, yang mendefinisikan pengguna API saat ini (proyeknya adalah Expenses.Security ):

 public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }

Selanjutnya, Anda perlu menentukan antarmuka dan implementasi prosesor kueri, yang akan berisi semua logika bisnis untuk bekerja dengan biaya—dalam kasus kami, IExpensesQueryProcessor dan 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(); } }

Langkah selanjutnya adalah mengkonfigurasi perakitan Expenses.Queries.Tests . Saya menginstal perpustakaan berikut:

  • moq
  • Pernyataan Lancar

Kemudian di rakitan Expenses.Queries.Tests , kami mendefinisikan perlengkapan untuk pengujian unit dan menjelaskan pengujian unit kami:

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

Setelah pengujian unit dijelaskan, implementasi prosesor kueri dijelaskan:

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

Setelah logika bisnis siap, saya mulai menulis tes integrasi API untuk menentukan kontrak API.

Langkah pertama adalah menyiapkan proyek Expenses.Api.IntegrationTests

  1. Instal paket nuget:
    • Pernyataan Lancar
    • moq
    • Microsoft.AspNetCore.TestHost
  2. Siapkan struktur proyek
    Struktur proyek
  3. Buat CollectionDefinition dengan bantuan yang kami tentukan sumber daya yang akan dibuat pada awal setiap pengujian dan akan dihancurkan pada akhir setiap pengujian.
 [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 Nama pengguna = “admin”; string public const 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; } } } ~~~

Untuk kenyamanan bekerja dengan permintaan HTTP dalam tes integrasi, saya menulis pembantu:

 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; } }

Pada tahap ini, saya perlu mendefinisikan kontrak REST API untuk setiap entitas, saya akan menulisnya untuk biaya REST API:

URL metode Tipe badan Jenis hasil Keterangan
Biaya DAPATKAN - DataResult<ExpenseModel> Dapatkan semua pengeluaran dengan kemungkinan penggunaan filter dan penyortir dalam parameter kueri "perintah"
Pengeluaran/{id} DAPATKAN - Model Pengeluaran Dapatkan pengeluaran dengan id
Pengeluaran POS BuatModel Beban Model Pengeluaran Buat catatan pengeluaran baru
Pengeluaran/{id} TARUH PerbaruiModel Pengeluaran Model Pengeluaran Perbarui pengeluaran yang ada

Saat Anda meminta daftar biaya, Anda bisa menerapkan berbagai perintah pemfilteran dan pengurutan menggunakan pustaka AutoQueryable. Contoh kueri dengan pemfilteran dan pengurutan:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date

Nilai parameter perintah decode adalah take=25&amount>=12&orderbydesc=date . Jadi kita bisa menemukan bagian paging, filtering, dan sorting dalam query. Semua opsi kueri sangat mirip dengan sintaks OData, tetapi sayangnya, OData belum siap untuk .NET Core, jadi saya menggunakan pustaka lain yang bermanfaat.

Bagian bawah menunjukkan semua model yang digunakan dalam API ini:

 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; } }

Model CreateExpenseModel dan UpdateExpenseModel menggunakan atribut anotasi data untuk melakukan pemeriksaan sederhana di tingkat API REST melalui atribut.

Selanjutnya, untuk setiap metode HTTP , folder terpisah dibuat dalam proyek dan file di dalamnya dibuat oleh perlengkapan untuk setiap metode HTTP yang didukung oleh sumber daya:

Struktur folder pengeluaran

Pelaksanaan uji integrasi untuk mendapatkan daftar pengeluaran:

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

Implementasi uji integrasi untuk mendapatkan data pengeluaran dengan 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); } }

Implementasi uji integrasi untuk membuat biaya:

 [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; } }

Implementasi uji integrasi untuk mengubah biaya:

 [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); } }

Implementasi uji integrasi untuk penghapusan biaya:

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

Pada titik ini, kami telah sepenuhnya mendefinisikan kontrak REST API dan sekarang saya dapat mulai mengimplementasikannya berdasarkan ASP.NET Core.

Implementasi API

Siapkan biaya proyek. Untuk ini, saya perlu menginstal perpustakaan berikut:

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

Setelah itu, Anda harus mulai membuat migrasi awal untuk database dengan membuka Package Manager Console, beralih ke proyek Expenses.Data.Access (karena konteks EF ada di sana) dan menjalankan perintah Add-Migration InitialCreate :

Konsol pengelola paket

Pada langkah selanjutnya, siapkan file konfigurasi appsettings.json terlebih dahulu, yang setelah persiapan masih perlu disalin ke proyek Expenses.Api.IntegrationTests karena dari sana, kita akan menjalankan API instance pengujian.

 { "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" } }

Bagian logging dibuat secara otomatis. Saya menambahkan bagian Data untuk menyimpan string koneksi ke database dan kunci ApplicationInsights saya.

Konfigurasi Aplikasi

Anda harus mengonfigurasi berbagai layanan yang tersedia di aplikasi kami:

Mengaktifkan ApplicationInsights : services.AddApplicationInsightsTelemetry(Configuration);

Daftarkan layanan Anda melalui panggilan: ContainerSetup.Setup(services, Configuration);

ContainerSetup adalah kelas yang dibuat sehingga kita tidak perlu menyimpan semua registrasi layanan di kelas Startup . Kelas terletak di folder IoC dari proyek 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); } } }

Hampir semua kode di kelas ini berbicara sendiri, tetapi saya ingin masuk ke metode ConfigureAutoMapper sedikit lebih banyak.

 private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }

Metode ini menggunakan kelas pembantu untuk menemukan semua pemetaan antara model dan entitas dan sebaliknya dan mendapatkan antarmuka IMapper untuk membuat pembungkus IAutoMapper yang akan digunakan di pengontrol. Tidak ada yang istimewa dari pembungkus ini—hanya menyediakan antarmuka yang nyaman untuk metode 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); } }

Untuk mengkonfigurasi AutoMapper, kelas pembantu digunakan, yang tugasnya adalah mencari pemetaan untuk kelas namespace tertentu. Semua pemetaan terletak di folder 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); } }

Penting untuk tidak melupakan Swagger , untuk mendapatkan deskripsi API yang sangat baik untuk pengembang ASP.net lainnya:

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

Dokumentasi API

Metode Startup.Configure menambahkan panggilan ke metode InitDatabase , yang secara otomatis memigrasi database hingga migrasi terakhir:

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

Swagger diaktifkan hanya jika aplikasi berjalan di lingkungan pengembangan dan tidak memerlukan autentikasi untuk mengaksesnya:

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

Selanjutnya, kami menghubungkan otentikasi (detail dapat ditemukan di repositori):

ConfigureAuthentication(app);

Pada titik ini, Anda dapat menjalankan tes integrasi dan memastikan bahwa semuanya dikompilasi tetapi tidak ada yang berhasil dan pergi ke controller ExpensesController .

Catatan: Semua pengontrol terletak di folder Pengeluaran/Server dan secara kondisional dibagi menjadi dua folder: Pengontrol dan RestApi. Dalam folder, pengontrol adalah pengontrol yang berfungsi sebagai pengontrol di MVC lama yang baik—yaitu, mengembalikan markup, dan di RestApi, pengontrol REST.

Anda harus membuat kelas Expenses/Server/RestApi/ExpensesController dan mewarisinya dari kelas Controller:

 public class ExpensesController : Controller { }

Selanjutnya, konfigurasikan perutean tipe ~ / api / Expenses dengan menandai kelas dengan atribut [Route ("api / [controller]")] .

Untuk mengakses logika bisnis dan mapper, Anda perlu menyuntikkan layanan berikut:

 private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }

Pada tahap ini, Anda dapat mulai menerapkan metode. Metode pertama adalah mendapatkan daftar pengeluaran:

 [HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }

Implementasi metode ini sangat sederhana, kami mendapatkan kueri ke database yang dipetakan dalam IQueryable <ExpenseModel> dari ExpensesQueryProcessor , yang pada gilirannya kembali sebagai hasilnya.

Atribut khusus di sini adalah QueryableResult , yang menggunakan pustaka AutoQueryable untuk menangani paging, pemfilteran, dan pengurutan di sisi server. Atribut terletak di folder Expenses/Filters . Akibatnya, filter ini mengembalikan data tipe DataResult <ExpenseModel> ke klien 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}); } }

Juga, mari kita lihat implementasi metode Post, membuat alur:

 [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }

Di sini, Anda harus memperhatikan atribut ValidateModel , yang melakukan validasi sederhana dari data input sesuai dengan atribut anotasi data dan ini dilakukan melalui pemeriksaan MVC bawaan.

 public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }

Kode lengkap dari 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); } }

Kesimpulan

Saya akan mulai dengan masalah: Masalah utamanya adalah kompleksitas konfigurasi awal solusi dan pemahaman lapisan aplikasi, tetapi dengan meningkatnya kompleksitas aplikasi, kompleksitas sistem hampir tidak berubah, yang merupakan masalah besar. ditambah ketika menyertai sistem seperti itu. Dan sangat penting bagi kami untuk memiliki API yang di dalamnya terdapat serangkaian pengujian integrasi dan serangkaian pengujian unit yang lengkap untuk logika bisnis. Logika bisnis benar-benar terpisah dari teknologi server yang digunakan dan dapat diuji sepenuhnya. Solusi ini sangat cocok untuk sistem dengan API yang kompleks dan logika bisnis yang kompleks.

Jika Anda ingin membuat aplikasi Angular yang menggunakan API Anda, lihat Angular 5 dan ASP.NET Core oleh sesama Toptaler Pablo Albella.