Creazione di un'API Web ASP.NET con ASP.NET Core
Pubblicato: 2022-03-11introduzione
Diversi anni fa, ho ricevuto il libro "Pro ASP.NET Web API". Questo articolo è il frutto delle idee di questo libro, un piccolo CQRS e la mia esperienza nello sviluppo di sistemi client-server.
In questo articolo tratterò:
- Come creare un'API REST da zero utilizzando .NET Core, EF Core, AutoMapper e XUnit
- Come essere sicuri che l'API funzioni dopo le modifiche
- Come semplificare il più possibile lo sviluppo e il supporto del sistema API REST
Perché ASP.NET Core?
ASP.NET Core offre molti miglioramenti rispetto all'API ASP.NET MVC/Web. In primo luogo, ora è un framework e non due. Mi piace molto perché è comodo e c'è meno confusione. In secondo luogo, abbiamo contenitori di registrazione e DI senza librerie aggiuntive, il che mi fa risparmiare tempo e mi consente di concentrarmi sulla scrittura di codice migliore invece di scegliere e analizzare le migliori librerie.
Cosa sono i Query Processor?
Un Query Processor è un approccio in cui tutta la logica aziendale relativa a un'entità del sistema è incapsulata in un servizio e qualsiasi accesso o azione con questa entità viene eseguita tramite questo servizio. Questo servizio è generalmente chiamato {EntityPluralName}QueryProcessor. Se necessario, un Query Processor include i metodi CRUD (crea, leggi, aggiorna, elimina) per questa entità. A seconda dei requisiti, non tutti i metodi possono essere implementati. Per fare un esempio specifico, diamo un'occhiata a ChangePassword. Se il metodo di un Query Processor richiede dati di input, devono essere forniti solo i dati richiesti. Di solito, per ogni metodo viene creata una classe di query separata e, in casi semplici, è possibile (ma non auspicabile) riutilizzare la classe di query.
Il nostro scopo
In questo articolo, ti mostrerò come creare un'API per un piccolo sistema di gestione dei costi, comprese le impostazioni di base per l'autenticazione e il controllo degli accessi, ma non entrerò nel sottosistema di autenticazione. Tratterò l'intera logica aziendale del sistema con test modulari e creerò almeno un test di integrazione per ciascun metodo API su un esempio di un'entità.
Requisiti per il sistema sviluppato: L'utente può aggiungere, modificare, eliminare le proprie spese e può vedere solo le proprie spese.
L'intero codice di questo sistema è disponibile su Github.
Quindi, iniziamo a progettare il nostro piccolo ma utilissimo sistema.
Livelli API
Il diagramma mostra che il sistema avrà quattro livelli:
- Database - Qui memorizziamo i dati e nient'altro, nessuna logica.
- DAL - Per accedere ai dati utilizziamo il pattern Unit of Work e, nell'implementazione, utilizziamo ORM EF Core con code first e pattern di migrazione.
- Logica aziendale: per incapsulare la logica aziendale, utilizziamo i processori di query, solo questo livello elabora la logica aziendale. L'eccezione è la convalida più semplice come i campi obbligatori, che verranno eseguiti tramite filtri nell'API.
- API REST: l'interfaccia effettiva attraverso la quale i client possono lavorare con la nostra API verrà implementata tramite ASP.NET Core. Le configurazioni dei percorsi sono determinate dagli attributi.
Oltre ai livelli descritti, abbiamo diversi concetti importanti. Il primo è la separazione dei modelli di dati. Il modello di dati del client viene utilizzato principalmente nel livello dell'API REST. Converte le query in modelli di dominio e viceversa da un modello di dominio a un modello di dati client, ma i modelli di query possono essere utilizzati anche nei processori di query. La conversione viene eseguita utilizzando AutoMapper.
Struttura del progetto
Ho usato VS 2017 Professional per creare il progetto. Di solito condivido il codice sorgente e i test su cartelle diverse. È comodo, ha un bell'aspetto, i test in CI vengono eseguiti comodamente e sembra che Microsoft consigli di farlo in questo modo:
Descrizione del progetto:
Progetto | Descrizione |
---|---|
Spese | Progetto per controller, mappatura tra modello di dominio e modello API, configurazione API |
Spese.Api.Comune | A questo punto vengono raccolte classi di eccezioni che vengono interpretate in un certo modo dai filtri per restituire codici HTTP corretti con errori all'utente |
Spese.Api.Modelli | Progetto per modelli API |
Spese.Accesso.Dati | Progetto per interfacce e implementazione del modello Unit of Work |
Spese.Modello.Dati | Progetto per modello di dominio |
Spese.Query | Progetto per Query Processor e classi specifiche per query |
Spese.Sicurezza | Progetto per l'interfaccia e l'implementazione del contesto di sicurezza dell'utente corrente |
Riferimenti tra progetti:
Spese create dal modello:
Altri progetti nella cartella src per modello:
Tutti i progetti nella cartella dei test per modello:
Implementazione
Questo articolo non descriverà la parte associata all'interfaccia utente, sebbene sia implementata.
Il primo passaggio è stato quello di sviluppare un modello di dati che si trova nell'assieme Expenses.Data.Model
:
La classe Expense
contiene i seguenti attributi:
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; } }
Questa classe supporta la "cancellazione graduale" tramite l'attributo IsDeleted
e contiene tutti i dati per una spesa di un particolare utente che ci saranno utili in futuro.
Le classi User
, Role
e UserRole
fanno riferimento al sottosistema di accesso; questo sistema non pretende di essere il sistema dell'anno e la descrizione di questo sottosistema non è lo scopo di questo articolo; pertanto, il modello dati e alcuni dettagli dell'implementazione verranno omessi. Il sistema di organizzazione degli accessi può essere sostituito da uno più perfetto senza modificare le logiche di business.
Successivamente, il modello Unit of Work è stato implementato nell'assembly Expenses.Data.Access
, viene mostrata la struttura di questo progetto:
Per l'assemblaggio sono necessarie le seguenti librerie:
-
Microsoft.EntityFrameworkCore.SqlServer
È necessario implementare un contesto EF
che troverà automaticamente le mappature in una cartella specifica:
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); } } }
La mappatura viene eseguita tramite la classe 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(); } }
La mappatura alle classi è nella cartella Maps
e la mappatura per le Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Interfaccia 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; }
La sua implementazione è un wrapper per 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; } }
L'interfaccia ITransaction
implementata in questa applicazione non verrà utilizzata:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
La sua implementazione avvolge semplicemente la transazione 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(); } }
Anche in questa fase, per gli unit test, è necessaria l'interfaccia ISecurityContext
, che definisce l'utente corrente dell'API (il progetto è Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
Successivamente, è necessario definire l'interfaccia e l'implementazione del Query Processor, che conterrà tutta la logica aziendale per lavorare con i costi, nel nostro caso, IExpensesQueryProcessor
e 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(); } }
Il passaggio successivo consiste nel configurare l'assembly Expenses.Queries.Tests
. Ho installato le seguenti librerie:
- Moq
- Affermazioni fluide
Quindi nell'assembly Expenses.Queries.Tests
, definiamo il dispositivo per gli unit test e descriviamo i nostri unit test:
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>(); }
Dopo aver descritto gli unit test, viene descritta l'implementazione di un Query Processor:
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(); } }
Una volta che la logica di business è pronta, inizio a scrivere i test di integrazione API per determinare il contratto API.
Il primo passo è preparare un progetto Expenses.Api.IntegrationTests
- Installa i pacchetti Nuget:
- Affermazioni fluide
- Moq
- Microsoft.AspNetCore.TestHost
- Impostare una struttura di progetto
- Crea una CollectionDefinition con l'aiuto della quale determiniamo la risorsa che verrà creata all'inizio di ogni esecuzione di test e verrà distrutta alla fine di ogni esecuzione di test.
[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 Nome utente = “admin”; public const string 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; } } } ~~~
Per comodità di lavorare con le richieste HTTP
nei test di integrazione, ho scritto un aiuto:
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; } }
A questo punto, devo definire un contratto API REST per ogni entità, lo scriverò per le spese API REST:
URL | Metodo | Tipo di corpo | Tipo di risultato | Descrizione |
---|---|---|---|---|
Spese | OTTENERE | - | Risultato dati<Modello di spesa> | Ottieni tutte le spese con il possibile utilizzo di filtri e selezionatori in un parametro di query "comandi" |
Spese/{id} | OTTENERE | - | ExpenseModel | Ottieni una spesa tramite id |
Spese | INVIARE | Crea modello di spesa | ExpenseModel | Crea un nuovo record di spesa |
Spese/{id} | METTERE | Aggiorna il modello di spesa | ExpenseModel | Aggiorna una spesa esistente |
Quando si richiede un elenco dei costi, è possibile applicare vari comandi di filtraggio e ordinamento utilizzando la libreria AutoQueryable. Una query di esempio con filtraggio e ordinamento:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Un valore del parametro dei comandi di decodifica è take=25&amount>=12&orderbydesc=date
. In questo modo possiamo trovare il paging, il filtraggio e l'ordinamento delle parti nella query. Tutte le opzioni di query sono molto simili alla sintassi di OData, ma sfortunatamente OData non è ancora pronto per .NET Core, quindi sto usando un'altra utile libreria.
La parte inferiore mostra tutti i modelli utilizzati in questa 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; } }
I modelli CreateExpenseModel
e UpdateExpenseModel
utilizzano gli attributi di annotazione dei dati per eseguire semplici controlli a livello di API REST tramite gli attributi.
Successivamente, per ogni metodo HTTP
, viene creata una cartella separata nel progetto e i file in essa contenuti vengono creati dall'apparecchiatura per ogni metodo HTTP
supportato dalla risorsa:
Implementazione del test di integrazione per ottenere un elenco delle spese:
[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(); } }
Implementazione del test di integrazione per ottenere i dati di spesa tramite 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); } }
Implementazione del test di integrazione per la creazione di una spesa:
[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; } }
Implementazione del test di integrazione per la modifica di una spesa:
[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); } }
Attuazione del test di integrazione per la rimozione di una spesa:
[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(); } }
A questo punto, abbiamo definito completamente il contratto API REST e ora posso iniziare a implementarlo sulla base di ASP.NET Core.
Implementazione API
Preparare il progetto Spese. Per questo, ho bisogno di installare le seguenti librerie:
- Mappatore automatico
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Tools
- Swashbuckle.AspNetCore
Dopodiché, devi iniziare a creare la migrazione iniziale per il database aprendo la Package Manager Console, passando al progetto Expenses.Data.Access
(perché il contesto EF
si trova lì) ed eseguendo il comando Add-Migration InitialCreate
:
Nel passaggio successivo, prepara in anticipo il file di configurazione appsettings.json, che dopo la preparazione dovrà ancora essere copiato nel progetto Expenses.Api.IntegrationTests
perché da lì eseguiremo l'API dell'istanza di test.
{ "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" } }
La sezione di registrazione viene creata automaticamente. Ho aggiunto la sezione Data
per archiviare la stringa di connessione al database e la mia chiave ApplicationInsights
.
Configurazione dell'applicazione
È necessario configurare diversi servizi disponibili nella nostra applicazione:
Attivazione di ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Registra i tuoi servizi tramite una chiamata: ContainerSetup.Setup(services, Configuration);
ContainerSetup
è una classe creata in modo da non dover archiviare tutte le registrazioni dei servizi nella classe Startup
. La classe si trova nella cartella IoC del progetto 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); } } }
Quasi tutto il codice in questa classe parla da sé, ma vorrei approfondire un po' di più il metodo ConfigureAutoMapper
.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Questo metodo usa la classe helper per trovare tutti i mapping tra modelli ed entità e viceversa e ottiene l'interfaccia IMapper
per creare il wrapper IAutoMapper
che verrà utilizzato nei controller. Non c'è niente di speciale in questo wrapper: fornisce solo una comoda interfaccia ai metodi di 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); } }
Per configurare AutoMapper, viene utilizzata la classe helper, il cui compito è cercare i mapping per classi di namespace specifiche. Tutte le mappature si trovano nella cartella Spese/Mappe:
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); } }
È importante non dimenticare Swagger
, per ottenere un'eccellente descrizione dell'API per altri sviluppatori ASP.net:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
Il metodo Startup.Configure
aggiunge una chiamata al metodo InitDatabase
, che migra automaticamente il database fino all'ultima migrazione:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
viene attivato solo se l'applicazione viene eseguita nell'ambiente di sviluppo e non richiede l'autenticazione per accedervi:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Successivamente, colleghiamo l'autenticazione (i dettagli possono essere trovati nel repository):
ConfigureAuthentication(app);
A questo punto, puoi eseguire i test di integrazione e assicurarti che tutto sia compilato ma non funzioni nulla e vai al controller ExpensesController
.
Nota: tutti i controller si trovano nella cartella Expenses/Server e sono suddivisi condizionatamente in due cartelle: Controllers e RestApi. Nella cartella, i controller sono controller che funzionano come controller nel vecchio MVC valido, ovvero restituiscono il markup e in RestApi, controller REST.
È necessario creare la classe Expenses/Server/RestApi/ExpensesController ed ereditarla dalla classe Controller:
public class ExpensesController : Controller { }
Quindi, configura l'instradamento del tipo ~ / api / Expenses
contrassegnando la classe con l'attributo [Route ("api / [controller]")]
.
Per accedere alla business logic e al mapper, è necessario iniettare i seguenti servizi:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
A questo punto, puoi iniziare a implementare i metodi. Il primo metodo consiste nell'ottenere un elenco delle spese:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
L'implementazione del metodo è molto semplice, otteniamo una query al database che è mappata in IQueryable <ExpenseModel>
da ExpensesQueryProcessor
, che a sua volta restituisce come risultato.
L'attributo personalizzato qui è QueryableResult
, che usa la libreria AutoQueryable
per gestire il paging, il filtraggio e l'ordinamento sul lato server. L'attributo si trova nella cartella Expenses/Filters
. Di conseguenza, questo filtro restituisce dati di tipo DataResult <ExpenseModel>
al client 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}); } }
Inoltre, diamo un'occhiata all'implementazione del metodo Post, creando un flusso:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Qui, dovresti prestare attenzione all'attributo ValidateModel
, che esegue una semplice convalida dei dati di input in conformità con gli attributi di annotazione dei dati e ciò avviene tramite i controlli MVC integrati.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Codice completo di 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); } }
Conclusione
Inizierò con i problemi: il problema principale è la complessità della configurazione iniziale della soluzione e la comprensione dei livelli dell'applicazione, ma con la crescente complessità dell'applicazione, la complessità del sistema è quasi invariata, il che è un grande più quando si accompagna un tale sistema. Ed è molto importante disporre di un'API per la quale esiste un set di test di integrazione e un set completo di unit test per la logica aziendale. La logica aziendale è completamente separata dalla tecnologia server utilizzata e può essere completamente testata. Questa soluzione è adatta per sistemi con API complesse e logiche di business complesse.
Se stai cercando di creare un'app Angular che utilizzi la tua API, dai un'occhiata ad Angular 5 e ASP.NET Core del collega Toptaler Pablo Albella.