Construirea unui API Web ASP.NET cu ASP.NET Core
Publicat: 2022-03-11Introducere
Acum câțiva ani, am primit cartea „Pro ASP.NET Web API”. Acest articol este rezultatul ideilor din această carte, un mic CQRS și propria mea experiență în dezvoltarea sistemelor client-server.
În acest articol, voi acoperi:
- Cum să creați un API REST de la zero folosind .NET Core, EF Core, AutoMapper și XUnit
- Cum să vă asigurați că API-ul funcționează după modificări
- Cum să simplificați cât mai mult posibil dezvoltarea și suportul sistemului API REST
De ce ASP.NET Core?
ASP.NET Core oferă multe îmbunătățiri față de ASP.NET MVC/Web API. În primul rând, acum este un cadru și nu două. Îmi place foarte mult pentru că este convenabil și există mai puțină confuzie. În al doilea rând, avem containere de înregistrare și DI fără biblioteci suplimentare, ceea ce mă economisește timp și îmi permite să mă concentrez pe scrierea unui cod mai bun în loc să aleg și să analizez cele mai bune biblioteci.
Ce sunt procesoarele de interogări?
Un procesor de interogări este o abordare în care toată logica de afaceri referitoare la o entitate a sistemului este încapsulată într-un singur serviciu și orice acces sau acțiuni cu această entitate sunt efectuate prin intermediul acestui serviciu. Acest serviciu se numește de obicei {EntityPluralName}QueryProcessor. Dacă este necesar, un procesor de interogări include metode CRUD (creare, citire, actualizare, ștergere) pentru această entitate. În funcție de cerințe, nu toate metodele pot fi implementate. Pentru a da un exemplu specific, să aruncăm o privire la ChangePassword. Dacă metoda unui procesor de interogări necesită date de intrare, atunci trebuie furnizate numai datele necesare. De obicei, pentru fiecare metodă, se creează o clasă de interogare separată, iar în cazuri simple, este posibilă (dar nu de dorit) să se refolosească clasa de interogare.
Scopul nostru
În acest articol, vă voi arăta cum să creați un API pentru un sistem mic de management al costurilor, inclusiv setările de bază pentru autentificare și controlul accesului, dar nu voi intra în subsistemul de autentificare. Voi acoperi întreaga logică de business a sistemului cu teste modulare și voi crea cel puțin un test de integrare pentru fiecare metodă API pe un exemplu de o entitate.
Cerințe pentru sistemul dezvoltat: Utilizatorul poate adăuga, edita, șterge cheltuielile sale și poate vedea doar cheltuielile acestora.
Întregul cod al acestui sistem este disponibil pe Github.
Deci, să începem să proiectăm sistemul nostru mic, dar foarte util.
Straturi API
Diagrama arată că sistemul va avea patru straturi:
- Baza de date - Aici stocăm date și nimic mai mult, fără logică.
- DAL - Pentru a accesa datele, folosim modelul Unit of Work și, în implementare, folosim ORM EF Core cu cod primul și modele de migrare.
- Logica de afaceri - pentru a încapsula logica de afaceri, folosim procesoare de interogare, doar acest strat procesează logica de afaceri. Excepție este cea mai simplă validare precum câmpurile obligatorii, care va fi executată prin intermediul filtrelor din API.
- API REST - Interfața reală prin care clienții pot lucra cu API-ul nostru va fi implementată prin ASP.NET Core. Configurațiile rutelor sunt determinate de atribute.
Pe lângă straturile descrise, avem mai multe concepte importante. Prima este separarea modelelor de date. Modelul de date client este utilizat în principal în stratul API REST. Convertește interogările în modele de domeniu și invers de la un model de domeniu la un model de date client, dar modelele de interogare pot fi utilizate și în procesoarele de interogări. Conversia se face folosind AutoMapper.
Structura proiectului
Am folosit VS 2017 Professional pentru a crea proiectul. De obicei, partajez codul sursă și teste pe diferite foldere. Este confortabil, arată bine, testele în CI rulează convenabil și se pare că Microsoft recomandă să o faceți astfel:
Descrierea proiectului:
Proiect | Descriere |
---|---|
Cheltuieli | Proiect pentru controlere, mapare între modelul de domeniu și modelul API, configurarea API |
Cheltuieli.Api.Comun | În acest moment, există clase de excepții colectate care sunt interpretate într-un anumit mod de filtre pentru a returna utilizatorului codurile HTTP corecte cu erori. |
Cheltuieli.Api.Modele | Proiect pentru modele API |
Cheltuieli.Date.Acces | Proiect de interfete si implementare a modelului Unit of Work |
Cheltuieli.Date.Model | Proiect pentru model de domeniu |
Cheltuieli.Interogări | Proiect pentru procesoare de interogări și clase specifice interogărilor |
Cheltuieli.Securitate | Proiect pentru interfata si implementarea contextului de securitate al utilizatorului curent |
Referințe între proiecte:
Cheltuieli create din șablon:
Alte proiecte în folderul src după șablon:
Toate proiectele din folderul de teste după șablon:
Implementarea
Acest articol nu va descrie partea asociată cu interfața de utilizare, deși este implementată.
Primul pas a fost dezvoltarea unui model de date care se află în ansamblul Expenses.Data.Model
:
Clasa de Expense
conține următoarele atribute:
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; } }
Această clasă acceptă „Ștergerea soft” prin intermediul atributului IsDeleted
și conține toate datele pentru o singură cheltuială a unui anumit utilizator care ne vor fi utile în viitor.
Clasele User
, Role
și UserRole
se referă la subsistemul de acces; acest sistem nu se pretinde a fi sistemul anului și descrierea acestui subsistem nu este scopul acestui articol; prin urmare, modelul de date și unele detalii ale implementării vor fi omise. Sistemul de organizare a accesului poate fi înlocuit cu unul mai perfect fără a schimba logica afacerii.
În continuare, șablonul Unitate de lucru a fost implementat în ansamblul Expenses.Data.Access
, se arată structura acestui proiect:
Următoarele biblioteci sunt necesare pentru asamblare:
-
Microsoft.EntityFrameworkCore.SqlServer
Este necesar să implementați un context EF
care va găsi automat mapările într-un folder specific:
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); } } }
Maparea se face prin clasa 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(); } }
Maparea la clase se află în folderul Maps
, iar maparea pentru Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Interfață 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; }
Implementarea sa este un wrapper pentru 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; } }
Interfața ITransaction
implementată în această aplicație nu va fi utilizată:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Implementarea sa completează pur și simplu tranzacția 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(); } }
Tot în această etapă, pentru testele unitare, este necesară interfața ISecurityContext
, care definește utilizatorul actual al API-ului (proiectul este Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
În continuare, trebuie să definiți interfața și implementarea procesorului de interogări, care va conține toată logica de afaceri pentru lucrul cu costuri - în cazul nostru, 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(); } }
Următorul pas este configurarea ansamblului Expenses.Queries.Tests
. Am instalat următoarele biblioteci:
- Moq
- Afirmații fluente
Apoi, în ansamblul Expenses.Queries.Tests
, definim dispozitivul pentru testele unitare și descriem testele noastre unitare:
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>(); }
După ce sunt descrise testele unitare, este descrisă implementarea unui procesor de interogări:
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(); } }
Odată ce logica de afaceri este gata, încep să scriu testele de integrare API pentru a determina contractul API.
Primul pas este pregătirea unui proiect Expenses.Api.IntegrationTests
- Instalați pachetele nuget:
- Afirmații fluente
- Moq
- Microsoft.AspNetCore.TestHost
- Configurați o structură de proiect
- Creați o CollectionDefinition cu ajutorul căreia determinăm resursa care va fi creată la începutul fiecărei rulări de testare și va fi distrusă la sfârșitul fiecărei rulări de testare.
[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 Nume utilizator = “admin”; public const string Parola = „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; } } } ~~~
Pentru confortul de a lucra cu cereri HTTP
în testele de integrare, am scris un ajutor:
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; } }
În această etapă, trebuie să definesc un contract API REST pentru fiecare entitate, îl voi scrie pentru cheltuielile API REST:
URL | Metodă | Tipul corpului | Tip de rezultat | Descriere |
---|---|---|---|---|
Cheltuiala | OBȚINE | - | DataResult<ExpenseModel> | Obțineți toate cheltuielile cu posibila utilizare a filtrelor și sortatoarelor într-un parametru de interogare „comenzi” |
Cheltuieli/{id} | OBȚINE | - | ExpenseModel | Obțineți o cheltuială după id |
Cheltuieli | POST | CreateExpenseModel | ExpenseModel | Creați o nouă înregistrare a cheltuielilor |
Cheltuieli/{id} | A PUNE | ActualizațiExpenseModel | ExpenseModel | Actualizați o cheltuială existentă |
Când solicitați o listă de costuri, puteți aplica diverse comenzi de filtrare și sortare folosind biblioteca AutoQueryable. Un exemplu de interogare cu filtrare și sortare:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Valoarea unui parametru pentru comenzile de decodare este take=25&amount>=12&orderbydesc=date
. Astfel, putem găsi părți de paginare, filtrare și sortare în interogare. Toate opțiunile de interogare sunt foarte asemănătoare cu sintaxa OData, dar, din păcate, OData nu este încă pregătit pentru .NET Core, așa că folosesc o altă bibliotecă utilă.
În partea de jos sunt afișate toate modelele utilizate în acest 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; } }
Modelele CreateExpenseModel
și UpdateExpenseModel
folosesc atribute de adnotare a datelor pentru a efectua verificări simple la nivelul API-ului REST prin atribute.
Apoi, pentru fiecare metodă HTTP
, un folder separat este creat în proiect și fișierele din acesta sunt create de fixture pentru fiecare metodă HTTP
care este acceptată de resursă:
Implementarea testului de integrare pentru obținerea unei liste de cheltuieli:
[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(); } }
Implementarea testului de integrare pentru obținerea datelor de cheltuieli prin 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); } }
Implementarea testului de integrare pentru crearea unei cheltuieli:
[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; } }
Implementarea testului de integrare pentru modificarea unei cheltuieli:
[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); } }
Implementarea testului de integrare pentru eliminarea unei cheltuieli:
[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(); } }
În acest moment, am definit complet contractul REST API și acum pot începe să-l implementez pe baza ASP.NET Core.
Implementarea API
Pregătiți proiectul Cheltuieli. Pentru aceasta, trebuie să instalez următoarele biblioteci:
- AutoMapper
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Tools
- Swashbuckle.AspNetCore
După aceea, trebuie să începeți să creați migrarea inițială pentru baza de date prin deschiderea Consolei Manager de pachete, trecând la proiectul Expenses.Data.Access
(deoarece contextul EF
se află acolo) și rulând comanda Add-Migration InitialCreate
:
În pasul următor, pregătiți în prealabil fișierul de configurare appsettings.json, care după pregătire va mai trebui copiat în proiectul Expenses.Api.IntegrationTests
deoarece de acolo vom rula API-ul instanței de testare.
{ "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" } }
Secțiunea de logare este creată automat. Am adăugat secțiunea Data
pentru a stoca șirul de conexiune la baza de date și cheia mea ApplicationInsights
.
Configurarea aplicației
Trebuie să configurați diferite servicii disponibile în aplicația noastră:
Activarea ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Înregistrați-vă serviciile printr-un apel: ContainerSetup.Setup(services, Configuration);
ContainerSetup
este o clasă creată, astfel încât să nu fie nevoie să stocăm toate înregistrările de servicii în clasa Startup
. Clasa se află în folderul IoC al proiectului Cheltuieli:
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); } } }
Aproape tot codul din această clasă vorbește de la sine, dar aș dori să intru puțin mai mult în metoda ConfigureAutoMapper
.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Această metodă folosește clasa helper pentru a găsi toate mapările dintre modele și entități și invers și obține interfața IMapper
pentru a crea învelișul IAutoMapper
care va fi folosit în controlere. Nu este nimic special la acest wrapper - oferă doar o interfață convenabilă pentru metodele 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); } }
Pentru a configura AutoMapper, se folosește clasa helper, a cărei sarcină este să caute mapări pentru anumite clase de spații de nume. Toate mapările se află în folderul Cheltuieli/Hărți:
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); } }
Este important să nu uităm de Swagger
, pentru a obține o descriere excelentă API pentru alți dezvoltatori ASP.net:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
Metoda Startup.Configure
adaugă un apel la metoda InitDatabase
, care migrează automat baza de date până la ultima migrare:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
este pornit numai dacă aplicația rulează în mediul de dezvoltare și nu necesită autentificare pentru a o accesa:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Apoi, conectăm autentificarea (detalii pot fi găsite în depozit):
ConfigureAuthentication(app);
În acest moment, puteți rula teste de integrare și vă asigurați că totul este compilat, dar nimic nu funcționează și mergeți la controlerul ExpensesController
.
Notă: Toate controlerele se află în folderul Cheltuieli/Server și sunt împărțite condiționat în două foldere: Controlere și RestApi. În folder, controlerele sunt controlere care funcționează ca controlere în vechiul MVC bun - adică returnează marcajul, iar în RestApi, controlere REST.
Trebuie să creați clasa Expenses/Server/RestApi/ExpensesController și să o moșteniți din clasa Controller:
public class ExpensesController : Controller { }
Apoi, configurați rutarea tipului ~ / api / Expenses
prin marcarea clasei cu atributul [Route ("api / [controller]")]
.
Pentru a accesa logica de afaceri și mapper, trebuie să injectați următoarele servicii:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
În această etapă, puteți începe implementarea metodelor. Prima metodă este obținerea unei liste de cheltuieli:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
Implementarea metodei este foarte simplă, primim o interogare la baza de date care este mapată în IQueryable <ExpenseModel>
din ExpensesQueryProcessor
, care la rândul său revine ca rezultat.
Atributul personalizat aici este QueryableResult
, care utilizează biblioteca AutoQueryable
pentru a gestiona paginarea, filtrarea și sortarea pe partea de server. Atributul se află în folderul Expenses/Filters
. Ca rezultat, acest filtru returnează date de tip DataResult <ExpenseModel>
către clientul 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}); } }
De asemenea, să ne uităm la implementarea metodei Post, creând un flux:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Aici, ar trebui să acordați atenție atributului ValidateModel
, care efectuează o validare simplă a datelor de intrare în conformitate cu atributele de adnotare a datelor și acest lucru se realizează prin verificările MVC încorporate.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Codul complet al 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); } }
Concluzie
Voi începe cu probleme: principala problemă este complexitatea configurației inițiale a soluției și înțelegerea straturilor aplicației, dar odată cu creșterea complexității aplicației, complexitatea sistemului este aproape neschimbată, ceea ce este un lucru mare. plus atunci când însoţeşte un astfel de sistem. Și este foarte important să avem un API pentru care există un set de teste de integrare și un set complet de teste unitare pentru logica de business. Logica de afaceri este complet separată de tehnologia serverului utilizată și poate fi testată complet. Această soluție este potrivită pentru sistemele cu un API complex și o logică de afaceri complexă.
Dacă doriți să construiți o aplicație Angular care să vă consume API-ul, consultați Angular 5 și ASP.NET Core de la coleg Toptaler Pablo Albella.