ASP.NET Core ile bir ASP.NET Web API'si oluşturma

Yayınlanan: 2022-03-11

Tanıtım

Birkaç yıl önce “Pro ASP.NET Web API” kitabını aldım. Bu makale, bu kitaptaki fikirlerin, biraz CQRS'nin ve istemci-sunucu sistemleri geliştirme konusundaki kendi deneyimimin bir ürünüdür.

Bu yazıda şunları ele alacağım:

  • .NET Core, EF Core, AutoMapper ve XUnit kullanarak sıfırdan bir REST API nasıl oluşturulur
  • Değişikliklerden sonra API'nin çalıştığından nasıl emin olunur?
  • REST API sisteminin geliştirilmesini ve desteklenmesini mümkün olduğunca basitleştirme

Neden ASP.NET Core?

ASP.NET Core, ASP.NET MVC/Web API üzerinde birçok iyileştirme sağlar. İlk olarak, artık iki değil bir çerçevedir. Kullanışlı olduğu ve daha az kafa karışıklığı olduğu için gerçekten beğendim. İkinci olarak, herhangi bir ek kitaplık içermeyen günlük kaydı ve DI kapsayıcılarımız var, bu da bana zaman kazandırıyor ve en iyi kitaplıkları seçip analiz etmek yerine daha iyi kod yazmaya konsantre olmamı sağlıyor.

Sorgu İşlemcileri Nelerdir?

Bir sorgu işlemcisi, sistemin bir varlığıyla ilgili tüm iş mantığının tek bir hizmette toplandığı ve bu varlıkla herhangi bir erişim veya eylemin bu hizmet aracılığıyla gerçekleştirildiği bir yaklaşımdır. Bu hizmete genellikle {EntityPluralName}QueryProcessor adı verilir. Gerekirse, bir sorgu işlemcisi bu varlık için CRUD (oluşturma, okuma, güncelleme, silme) yöntemlerini içerir. Gereksinimlere bağlı olarak, tüm yöntemler uygulanmayabilir. Spesifik bir örnek vermek için ChangePassword'e bir göz atalım. Bir sorgu işlemcisinin yöntemi girdi verileri gerektiriyorsa, yalnızca gerekli veriler sağlanmalıdır. Genellikle, her yöntem için ayrı bir sorgu sınıfı oluşturulur ve basit durumlarda, sorgu sınıfını yeniden kullanmak mümkündür (ancak istenmez).

Amacımız

Bu yazıda, kimlik doğrulama ve erişim kontrolü için temel ayarlar da dahil olmak üzere küçük bir maliyet yönetim sistemi için nasıl API yapılacağını göstereceğim ancak kimlik doğrulama alt sistemine girmeyeceğim. Sistemin tüm iş mantığını modüler testler ile ele alacağım ve bir varlık örneği üzerinde her API yöntemi için en az bir entegrasyon testi oluşturacağım.

Geliştirilen sistem için gereksinimler: Kullanıcı giderlerini ekleyebilir, düzenleyebilir, silebilir ve sadece giderlerini görebilir.

Bu sistemin tüm kodu Github'da mevcuttur.

O halde küçük ama çok kullanışlı sistemimizi tasarlamaya başlayalım.

API Katmanları

API katmanlarını gösteren bir diyagram.

Diyagram, sistemin dört katmanı olacağını göstermektedir:

  • Veritabanı - Burada verileri saklıyoruz ve daha fazlasını değil, mantık yok.
  • DAL - Verilere erişmek için İş Birimi şablonunu kullanıyoruz ve uygulamada, ORM EF Çekirdeği'ni önce kod ve geçiş desenleriyle kullanıyoruz.
  • İş mantığı - iş mantığını kapsüllemek için sorgu işlemcileri kullanıyoruz, yalnızca bu katman iş mantığını işler. İstisna, API'deki filtreler aracılığıyla yürütülecek zorunlu alanlar gibi en basit doğrulamadır.
  • REST API - İstemcilerin API'mizle çalışabileceği gerçek arabirim, ASP.NET Core aracılığıyla uygulanacaktır. Rota konfigürasyonları öznitelikler tarafından belirlenir.

Tanımlanan katmanlara ek olarak, birkaç önemli kavramımız var. Birincisi, veri modellerinin ayrılmasıdır. İstemci veri modeli esas olarak REST API katmanında kullanılır. Sorguları etki alanı modellerine ve bunun tersini etki alanı modelinden istemci veri modeline dönüştürür, ancak sorgu modelleri sorgu işlemcilerinde de kullanılabilir. Dönüşüm, AutoMapper kullanılarak yapılır.

Proje Yapısı

Projeyi oluşturmak için VS 2017 Professional kullandım. Genelde kaynak kodu ve testleri farklı klasörlerde paylaşıyorum. Rahat, iyi görünüyor, CI'deki testler uygun şekilde çalışıyor ve görünüşe göre Microsoft bunu şu şekilde yapmanızı öneriyor:

VS 2017 Professional'da klasör yapısı.

Proje Açıklaması:

proje Tanım
Masraflar Denetleyiciler için proje, etki alanı modeli ile API modeli arasında eşleme, API yapılandırması
Giderler.Api.Ortak Bu noktada, kullanıcıya hatalı HTTP kodlarını döndürmek için filtreler tarafından belirli bir şekilde yorumlanan toplanan istisna sınıfları vardır.
Giderler.Api.Modeller API modelleri için proje
Giderler.Veri.Erişim Arayüzler için proje ve İş Birimi modelinin uygulanması
Giderler.Veri.Model Alan modeli için proje
Giderler.Sorgular Sorgu işlemcileri ve sorguya özel sınıflar için proje
Giderler.Güvenlik Geçerli kullanıcının güvenlik bağlamının arayüzü ve uygulaması için proje

Projeler arası referanslar:

Projeler arasındaki referansları gösteren diyagram.

Şablondan oluşturulan giderler:

Şablondan oluşturulan giderlerin listesi.

Şablona göre src klasöründeki diğer projeler:

Şablona göre src klasöründeki diğer projelerin listesi.

Şablona göre testler klasöründeki tüm projeler:

Testler klasöründeki projelerin şablona göre listesi.

uygulama

Bu makale, uygulanmış olmasına rağmen, kullanıcı arayüzü ile ilişkili kısmı açıklamayacaktır.

İlk adım, Expenses.Data.Model derlemesinde bulunan bir veri modeli geliştirmekti:

Roller arasındaki ilişkinin şeması

Expense sınıfı aşağıdaki öznitelikleri içerir:

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

Bu sınıf, IsDeleted özniteliği aracılığıyla "yazılımla silmeyi" destekler ve belirli bir kullanıcının gelecekte bizim için yararlı olacak tek bir harcaması için tüm verileri içerir.

User , Role ve UserRole sınıfları erişim alt sistemine başvurur; bu sistem yılın sistemiymiş gibi davranmaz ve bu alt sistemin açıklaması bu makalenin amacı değildir; bu nedenle, veri modeli ve uygulamanın bazı ayrıntıları atlanacaktır. Erişim organizasyonu sistemi, iş mantığını değiştirmeden daha mükemmel bir sistemle değiştirilebilir.

Daha sonra, Expenses.Data.Access montajında ​​İş Birimi şablonu uygulandı, bu projenin yapısı gösterilmektedir:

Expenses.Data.Access proje yapısı

Montaj için aşağıdaki kütüphaneler gereklidir:

  • Microsoft.EntityFrameworkCore.SqlServer

Eşlemeleri belirli bir klasörde otomatik olarak bulan bir EF bağlamı uygulamak gerekir:

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

Eşleme, MappingsHelper sınıfı aracılığıyla yapılır:

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

Sınıflara eşleme Maps klasöründedir ve Expenses için eşleme:

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

Arayüz 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; }

Uygulaması, 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; } }

Bu uygulamada uygulanan ITransaction arabirimi kullanılmayacaktır:

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

Uygulanması basitçe EF işlemini tamamlar:

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

Ayrıca bu aşamada, birim testleri için API'nin mevcut kullanıcısını tanımlayan ISecurityContext arayüzüne ihtiyaç vardır (proje Expenses.Security ):

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

Ardından, maliyetlerle çalışmak için tüm iş mantığını içerecek olan sorgu işlemcisinin arabirimini ve uygulamasını tanımlamanız gerekir - bizim durumumuzda IExpensesQueryProcessor ve 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(); } }

Sonraki adım, Expenses.Queries.Tests derlemesini yapılandırmaktır. Aşağıdaki kitaplıkları kurdum:

  • Adedi
  • FluentAssertions

Daha sonra Expenses.Queries.Tests derlemesinde, birim testleri için fikstürü tanımlarız ve birim testlerimizi açıklarız:

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

Birim testleri açıklandıktan sonra, bir sorgu işlemcisinin uygulanması açıklanır:

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

İş mantığı hazır olduktan sonra API sözleşmesini belirlemek için API entegrasyon testlerini yazmaya başlıyorum.

İlk adım bir proje hazırlamaktır Expenses.Api.IntegrationTests

  1. Nuget paketlerini kurun:
    • FluentAssertions
    • Adedi
    • Microsoft.AspNetCore.TestHost
  2. Bir proje yapısı kurun
    proje yapısı
  3. Her test çalışmasının başında oluşturulacak ve her test çalışmasının sonunda yok edilecek kaynağı belirlediğimiz bir CollectionDefinition oluşturun.
 [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 Kullanıcı adı = “admin”; public const string Parola = “yönetici”;

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

Entegrasyon testlerinde HTTP istekleriyle çalışmanın rahatlığı için bir yardımcı yazdım:

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

Bu aşamada her varlık için bir REST API sözleşmesi tanımlamam gerekiyor, bunu REST API giderleri için yazacağım:

URL Yöntem vücut tipi Sonuç türü Tanım
gider ELDE ETMEK - DataResult<GiderModeli> "Komutlar" sorgu parametresinde olası filtre ve sıralayıcı kullanımıyla tüm masrafları alın
Giderler/{id} ELDE ETMEK - Gider Modeli Kimliğe göre gider alın
Masraflar İLETİ CreateExpenseModel Gider Modeli Yeni gider kaydı oluştur
Giderler/{id} KOYMAK GüncelleGiderModeli Gider Modeli Mevcut bir gideri güncelleme

Maliyet listesi talep ettiğinizde, Otomatik Sorgulanabilir kitaplığı kullanarak çeşitli filtreleme ve sıralama komutları uygulayabilirsiniz. Filtreleme ve sıralama içeren örnek bir sorgu:

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

Bir kod çözme komutları parametre değeri, take=25&amount>=12&orderbydesc=date . Böylece sorguda sayfalama, filtreleme ve sıralama bölümlerini bulabiliriz. Tüm sorgu seçenekleri OData sözdizimine çok benzer, ancak ne yazık ki OData henüz .NET Core için hazır değil, bu yüzden başka bir yararlı kitaplık kullanıyorum.

Alt kısım, bu API'de kullanılan tüm modelleri gösterir:

 public class DataResult<T> { public T[] Data { get; set; } public int Total { get; set; } } public class ExpenseModel { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public string Username { get; set; } } public class CreateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } } public class UpdateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } }

CreateExpenseModel ve UpdateExpenseModel modelleri, öznitelikler aracılığıyla REST API düzeyinde basit kontroller gerçekleştirmek için veri açıklama özniteliklerini kullanır.

Ardından, her HTTP yöntemi için projede ayrı bir klasör oluşturulur ve içindeki dosyalar, kaynak tarafından desteklenen her HTTP yöntemi için fikstür tarafından oluşturulur:

Gider klasörü yapısı

Masrafların bir listesini almak için entegrasyon testinin uygulanması:

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

Harcama verilerini kimliğe göre almak için entegrasyon testinin uygulanması:

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

Gider oluşturmak için entegrasyon testinin uygulanması:

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

Bir gideri değiştirmek için entegrasyon testinin uygulanması:

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

Masrafın kaldırılması için entegrasyon testinin uygulanması:

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

Bu noktada REST API sözleşmesini tam olarak tanımladık ve artık ASP.NET Core bazında uygulamaya başlayabilirim.

API Uygulaması

Proje Giderlerini hazırlayın. Bunun için aşağıdaki kütüphaneleri kurmam gerekiyor:

  • Otomatik Haritalayıcı
  • AutoQueryable.AspNetCore.Filter
  • Microsoft.ApplicationInsights.AspNetCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.SqlServer.Design
  • Microsoft.EntityFrameworkCore.Tools
  • Swashbuckle.AspNetCore

Bundan sonra, Paket Yöneticisi Konsolunu açarak, Expenses.Data.Access projesine geçerek (çünkü EF bağlamı oradadır) ve Add-Migration InitialCreate komutunu çalıştırarak veritabanı için ilk geçişi oluşturmaya başlamanız gerekir:

Paket yöneticisi konsolu

Bir sonraki adımda, appsettings.json yapılandırma dosyasını önceden hazırlayın, bu hazırlıktan sonra yine Expenses.Api.IntegrationTests projesine kopyalanması gerekecek çünkü oradan test örneği API'sini çalıştıracağız.

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

Kayıt bölümü otomatik olarak oluşturulur. Bağlantı dizesini veritabanına ve ApplicationInsights anahtarıma depolamak için Data bölümünü ekledim.

Uygulama Yapılandırması

Uygulamamızda bulunan farklı hizmetleri yapılandırmanız gerekir:

ApplicationInsights Açma: services.AddApplicationInsightsTelemetry(Configuration);

Hizmetlerinizi bir çağrı yoluyla kaydedin: ContainerSetup.Setup(services, Configuration);

ContainerSetup , tüm hizmet kayıtlarını Startup ​​sınıfında depolamamız gerekmediği için oluşturulmuş bir sınıftır. Sınıf, Giderler projesinin IoC klasöründe bulunur:

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

Bu sınıftaki hemen hemen tüm kodlar kendisi için konuşur, ancak ConfigureAutoMapper yöntemine biraz daha girmek istiyorum.

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

Bu yöntem, modeller ve varlıklar arasındaki tüm eşlemeleri bulmak için yardımcı sınıfı kullanır ve bunun tersi de geçerlidir ve denetleyicilerde kullanılacak IAutoMapper sarmalayıcısını oluşturmak için IMapper arabirimini alır. Bu sarmalayıcının özel bir yanı yoktur; yalnızca AutoMapper yöntemlerine uygun bir arabirim sağlar.

 public class AutoMapperAdapter : IAutoMapper { private readonly IMapper _mapper; public AutoMapperAdapter(IMapper mapper) { _mapper = mapper; } public IConfigurationProvider Configuration => _mapper.ConfigurationProvider; public T Map<T>(object objectToMap) { return _mapper.Map<T>(objectToMap); } public TResult[] Map<TSource, TResult>(IEnumerable<TSource> sourceQuery) { return sourceQuery.Select(x => _mapper.Map<TResult>(x)).ToArray(); } public IQueryable<TResult> Map<TSource, TResult>(IQueryable<TSource> sourceQuery) { return sourceQuery.ProjectTo<TResult>(_mapper.ConfigurationProvider); } public void Map<TSource, TDestination>(TSource source, TDestination destination) { _mapper.Map(source, destination); } }

AutoMapper'ı yapılandırmak için, görevi belirli ad alanı sınıfları için eşlemeleri aramak olan yardımcı sınıf kullanılır. Tüm eşlemeler Giderler/Haritalar klasöründe bulunur:

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

Diğer ASP.net geliştiricileri için mükemmel bir API açıklaması elde etmek için Swagger unutmamak önemlidir:

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

API Belgeleri

Startup.Configure yöntemi, veritabanını son geçişe kadar otomatik olarak geçiren InitDatabase yöntemine bir çağrı ekler:

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

Swagger , yalnızca uygulama geliştirme ortamında çalışıyorsa ve ona erişmek için kimlik doğrulama gerektirmiyorsa açılır:

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

Ardından, kimlik doğrulamayı bağlarız (detaylar depoda bulunabilir):

ConfigureAuthentication(app);

Bu noktada, entegrasyon testleri çalıştırabilir ve her şeyin derlendiğinden ancak hiçbir şeyin çalışmadığından emin olabilir ve ExpensesController denetleyicisine gidebilirsiniz.

Not: Tüm denetleyiciler Giderler/Sunucu klasöründe bulunur ve koşullu olarak iki klasöre ayrılır: Denetleyiciler ve RestApi. Klasörde, denetleyiciler, eski iyi MVC'de denetleyiciler olarak çalışan denetleyicilerdir; yani, işaretlemeyi döndürür ve RestApi'de REST denetleyicileri.

Expenses/Server/RestApi/ExpensesController sınıfını oluşturmalı ve onu Controller sınıfından devralmalısınız:

 public class ExpensesController : Controller { }

Ardından, sınıfı [Route ("api / [controller]")] özniteliği ile işaretleyerek ~ / api / Expenses türünün yönlendirmesini yapılandırın.

İş mantığına ve eşleyiciye erişmek için aşağıdaki hizmetleri enjekte etmeniz gerekir:

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

Bu aşamada, yöntemleri uygulamaya başlayabilirsiniz. İlk yöntem, bir gider listesi elde etmektir:

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

Yöntemin uygulanması çok basittir, ExpensesQueryProcessor IQueryable <ExpenseModel> içinde eşlenen veritabanına bir sorgu alıyoruz, bu da sonuç olarak geri dönüyor.

Buradaki özel öznitelik, sunucu tarafında sayfalama, filtreleme ve sıralamayı işlemek için AutoQueryable kitaplığını kullanan QueryableResult . Öznitelik, Expenses/Filters klasöründe bulunur. Sonuç olarak, bu filtre API istemcisine DataResult <ExpenseModel> verileri döndürür.

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

Ayrıca, bir akış oluşturarak Post yönteminin uygulanmasına bakalım:

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

Burada, veri açıklama özniteliklerine göre giriş verilerinin basit doğrulamasını gerçekleştiren ValidateModel özniteliğine dikkat etmelisiniz ve bu, yerleşik MVC kontrolleri aracılığıyla yapılır.

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

ExpensesController tam kodu:

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

Çözüm

Sorunlarla başlayacağım: Asıl sorun, çözümün ilk yapılandırmasının karmaşıklığı ve uygulamanın katmanlarının anlaşılmasıdır, ancak uygulamanın artan karmaşıklığı ile sistemin karmaşıklığı neredeyse değişmez, bu büyük bir sorundur. artı böyle bir sisteme eşlik ederken. İş mantığı için bir dizi entegrasyon testi ve eksiksiz bir birim testi seti içeren bir API'ye sahip olmamız çok önemlidir. İş mantığı, kullanılan sunucu teknolojisinden tamamen ayrılmıştır ve tamamen test edilebilir. Bu çözüm, karmaşık bir API'ye ve karmaşık iş mantığına sahip sistemler için çok uygundur.

API'nizi tüketen bir Angular uygulaması oluşturmak istiyorsanız, Toptaler Pablo Albella'dan Angular 5 ve ASP.NET Core'a göz atın.