Erstellen einer ASP.NET-Web-API mit ASP.NET Core
Veröffentlicht: 2022-03-11Einführung
Vor einigen Jahren habe ich mir das Buch „Pro ASP.NET Web API“ zugelegt. Dieser Artikel ist der Ableger von Ideen aus diesem Buch, ein wenig CQRS und meiner eigenen Erfahrung bei der Entwicklung von Client-Server-Systemen.
In diesem Artikel werde ich behandeln:
- So erstellen Sie eine REST-API von Grund auf neu mit .NET Core, EF Core, AutoMapper und XUnit
- So stellen Sie sicher, dass die API nach Änderungen funktioniert
- Wie man die Entwicklung und den Support des REST-API-Systems so weit wie möglich vereinfacht
Warum ASP.NET Core?
ASP.NET Core bietet viele Verbesserungen gegenüber der ASP.NET MVC/Web-API. Erstens ist es jetzt ein Framework und nicht zwei. Ich mag es wirklich, weil es praktisch ist und es weniger Verwirrung gibt. Zweitens haben wir Protokollierungs- und DI-Container ohne zusätzliche Bibliotheken, was mir Zeit spart und es mir ermöglicht, mich auf das Schreiben von besserem Code zu konzentrieren, anstatt die besten Bibliotheken auszuwählen und zu analysieren.
Was sind Abfrageprozessoren?
Ein Abfrageprozessor ist ein Ansatz, bei dem die gesamte Geschäftslogik, die sich auf eine Entität des Systems bezieht, in einem Dienst gekapselt ist und alle Zugriffe oder Aktionen mit dieser Entität über diesen Dienst ausgeführt werden. Dieser Dienst heißt normalerweise {EntityPluralName}QueryProcessor. Falls erforderlich, enthält ein Abfrageprozessor CRUD-Methoden (Create, Read, Update, Delete) für diese Entität. Je nach Anforderung können nicht alle Methoden implementiert werden. Um ein konkretes Beispiel zu geben, werfen wir einen Blick auf ChangePassword. Wenn das Verfahren eines Abfrageprozessors Eingabedaten erfordert, sollten nur die erforderlichen Daten bereitgestellt werden. Normalerweise wird für jede Methode eine separate Abfrageklasse erstellt, und in einfachen Fällen ist es möglich (aber nicht wünschenswert), die Abfrageklasse wiederzuverwenden.
Unser Ziel
In diesem Artikel zeige ich Ihnen, wie Sie eine API für ein kleines Kostenverwaltungssystem erstellen, einschließlich grundlegender Einstellungen für Authentifizierung und Zugriffskontrolle, aber ich werde nicht auf das Authentifizierungssubsystem eingehen. Ich werde die gesamte Geschäftslogik des Systems mit modularen Tests abdecken und mindestens einen Integrationstest für jede API-Methode am Beispiel einer Entität erstellen.
Anforderungen an das entwickelte System: Der Benutzer kann seine Ausgaben hinzufügen, bearbeiten, löschen und nur seine Ausgaben sehen.
Der gesamte Code dieses Systems ist unter auf Github verfügbar.
Beginnen wir also mit der Gestaltung unseres kleinen, aber sehr nützlichen Systems.
API-Schichten
Das Diagramm zeigt, dass das System vier Schichten haben wird:
- Datenbank - Hier speichern wir Daten und nichts weiter, keine Logik.
- DAL – Um auf die Daten zuzugreifen, verwenden wir das Unit of Work-Muster und in der Implementierung verwenden wir den ORM EF Core mit Code-First- und Migrationsmustern.
- Geschäftslogik - Um die Geschäftslogik zu kapseln, verwenden wir Abfrageprozessoren, nur diese Schicht verarbeitet die Geschäftslogik. Die Ausnahme bilden einfachste Validierungen wie Pflichtfelder, die über Filter in der API ausgeführt werden.
- REST-API: Die eigentliche Schnittstelle, über die Clients mit unserer API arbeiten können, wird über ASP.NET Core implementiert. Routenkonfigurationen werden durch Attribute bestimmt.
Zusätzlich zu den beschriebenen Schichten haben wir mehrere wichtige Konzepte. Die erste ist die Trennung von Datenmodellen. Das Client-Datenmodell wird hauptsächlich in der REST-API-Schicht verwendet. Es konvertiert Abfragen in Domänenmodelle und umgekehrt von einem Domänenmodell in ein Client-Datenmodell, aber Abfragemodelle können auch in Abfrageprozessoren verwendet werden. Die Konvertierung erfolgt mit AutoMapper.
Projektstruktur
Ich habe VS 2017 Professional verwendet, um das Projekt zu erstellen. Normalerweise teile ich den Quellcode und die Tests in verschiedenen Ordnern. Es ist bequem, es sieht gut aus, die Tests in CI laufen bequem, und es scheint, dass Microsoft es so empfiehlt:
Projektbeschreibung:
Projekt | Beschreibung |
---|---|
Kosten | Projekt für Controller, Zuordnung zwischen Domänenmodell und API-Modell, API-Konfiguration |
Ausgaben.Api.Common | An dieser Stelle gibt es gesammelte Ausnahmeklassen, die von Filtern auf bestimmte Weise interpretiert werden, um korrekte HTTP-Codes mit Fehlern an den Benutzer zurückzugeben |
Ausgaben.API.Modelle | Projekt für API-Modelle |
Spesen.Daten.Zugriff | Projekt für Schnittstellen und Implementierung des Unit-of-Work-Patterns |
Ausgaben.Daten.Modell | Projekt für Domänenmodell |
Spesen.Abfragen | Projekt für Abfrageprozessoren und abfragespezifische Klassen |
Spesen.Sicherheit | Projekt für die Schnittstelle und Implementierung des Sicherheitskontextes des aktuellen Benutzers |
Referenzen zwischen Projekten:
Aus der Vorlage erstellte Ausgaben:
Andere Projekte im src-Ordner nach Vorlage:
Alle Projekte im Testordner nach Vorlage:
Implementierung
In diesem Artikel wird der mit der Benutzeroberfläche verbundene Teil nicht beschrieben, obwohl er implementiert ist.
Im ersten Schritt wurde ein Datenmodell entwickelt, das sich in der Assembly Expenses.Data.Model
befindet:
Die Klasse Expense
enthält die folgenden Attribute:
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; } }
Diese Klasse unterstützt „sanftes Löschen“ durch das IsDeleted
Attribut und enthält alle Daten für eine Ausgabe eines bestimmten Benutzers, die uns in Zukunft nützlich sein werden.
Die Klassen User
, Role
und UserRole
beziehen sich auf das Zugriffssubsystem; dieses System erhebt nicht den Anspruch, das System des Jahres zu sein, und die Beschreibung dieses Subsystems ist nicht der Zweck dieses Artikels; daher werden das Datenmodell und einige Details der Implementierung weggelassen. Das System der Zugriffsorganisation kann durch ein perfekteres ersetzt werden, ohne die Geschäftslogik zu ändern.
Als nächstes wurde die Unit of Work-Vorlage in der Expenses.Data.Access
-Assembly implementiert, die Struktur dieses Projekts wird gezeigt:
Für die Assemblierung werden folgende Bibliotheken benötigt:
-
Microsoft.EntityFrameworkCore.SqlServer
Es ist notwendig, einen EF
-Kontext zu implementieren, der die Zuordnungen automatisch in einem bestimmten Ordner findet:
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); } } }
Die Zuordnung erfolgt über die MappingsHelper
-Klasse:
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(); } }
Die Zuordnung zu den Klassen befindet sich im Maps
-Ordner und die Zuordnung zu Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
Schnittstelle 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; }
Seine Implementierung ist ein Wrapper für 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; } }
Die in dieser Anwendung implementierte Schnittstelle ITransaction
wird nicht verwendet:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
Seine Implementierung umschließt einfach die EF
Transaktion:
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(); } }
Auch in dieser Phase wird für die Komponententests die Schnittstelle ISecurityContext
benötigt, die den aktuellen Benutzer der API definiert (das Projekt ist Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
Als Nächstes müssen Sie die Schnittstelle und Implementierung des Abfrageprozessors definieren, der die gesamte Geschäftslogik für die Arbeit mit Kosten enthält – in unserem Fall IExpensesQueryProcessor
und 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(); } }
Der nächste Schritt besteht darin, die Assembly Expenses.Queries.Tests
zu konfigurieren. Folgende Bibliotheken habe ich installiert:
- Moq
- FließendBehauptungen
Dann definieren wir in der Assembly Expenses.Queries.Tests
das Fixture für Unit-Tests und beschreiben unsere Unit-Tests:
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>(); }
Nachdem die Unit-Tests beschrieben sind, wird die Implementierung eines Abfrageprozessors beschrieben:
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(); } }
Sobald die Geschäftslogik fertig ist, beginne ich mit dem Schreiben der API-Integrationstests, um den API-Vertrag festzulegen.
Der erste Schritt besteht darin, ein Projekt Expenses.Api.IntegrationTests
vorzubereiten
- Nuget-Pakete installieren:
- FließendBehauptungen
- Moq
- Microsoft.AspNetCore.TestHost
- Bauen Sie eine Projektstruktur auf
- Erstellen Sie eine CollectionDefinition, mit deren Hilfe wir die Ressource bestimmen, die zu Beginn jedes Testlaufs erstellt und am Ende jedes Testlaufs vernichtet wird.
[CollectionDefinition("ApiCollection")] public class DbCollection : ICollectionFixture<ApiServer> { } ~~~ And define our test server and the client to it with the already authenticated user by default:
öffentliche Klasse ApiServer: IDisposable { public const string Username = „admin“; öffentlicher konstanter String Passwort = „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; } } } ~~~
Um die Arbeit mit HTTP
-Anforderungen in Integrationstests zu vereinfachen, habe ich einen Helfer geschrieben:
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; } }
In diesem Stadium muss ich einen REST-API-Vertrag für jede Entität definieren, ich schreibe ihn für die REST-API-Ausgaben:
URL | Methode | Körpertyp | Ergebnistyp | Beschreibung |
---|---|---|---|---|
Aufwand | BEKOMMEN | - | DataResult<ExpenseModel> | Erhalten Sie alle Ausgaben mit möglicher Verwendung von Filtern und Sortierern in einem Abfrageparameter "Befehle". |
Ausgaben/{id} | BEKOMMEN | - | Kostenmodell | Holen Sie sich eine Ausgabe nach ID |
Kosten | POST | Ausgabenmodell erstellen | Kostenmodell | Erstellen Sie eine neue Spesenaufzeichnung |
Ausgaben/{id} | SETZEN | UpdateExpenseModel | Kostenmodell | Aktualisieren Sie eine vorhandene Ausgabe |
Wenn Sie eine Kostenliste anfordern, können Sie mithilfe der AutoQueryable-Bibliothek verschiedene Filter- und Sortierbefehle anwenden. Eine Beispielabfrage mit Filterung und Sortierung:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Ein Decodierbefehlsparameterwert ist take=25&amount>=12&orderbydesc=date
. So können wir Paging-, Filter- und Sortierteile in der Abfrage finden. Alle Abfrageoptionen sind der OData-Syntax sehr ähnlich, aber leider ist OData noch nicht bereit für .NET Core, daher verwende ich eine andere hilfreiche Bibliothek.
Der untere Teil zeigt alle Modelle, die in dieser API verwendet werden:
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; } }
Die Modelle CreateExpenseModel
und UpdateExpenseModel
verwenden Datenannotationsattribute, um einfache Prüfungen auf REST-API-Ebene durch Attribute durchzuführen.
Als Nächstes wird für jede HTTP
Methode ein separater Ordner im Projekt erstellt, und Dateien darin werden nach Fixture für jede HTTP
Methode erstellt, die von der Ressource unterstützt wird:
Implementierung des Integrationstests zum Erhalt einer Ausgabenliste:
[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(); } }
Implementierung des Integrationstests zum Abrufen der Spesendaten nach 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); } }
Implementierung des Integrationstests zum Erstellen eines Aufwands:
[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; } }
Implementierung des Integrationstests zum Ändern einer Ausgabe:
[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); } }
Durchführung des Integrationstests zur Beseitigung eines Aufwands:
[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(); } }
An dieser Stelle haben wir den REST-API-Vertrag vollständig definiert und jetzt kann ich mit der Implementierung auf Basis von ASP.NET Core beginnen.
API-Implementierung
Bereiten Sie die Projektkosten vor. Dazu muss ich folgende Bibliotheken installieren:
- AutoMapper
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.Tools
- Swashbuckle.AspNetCore
Danach müssen Sie mit dem Erstellen der anfänglichen Migration für die Datenbank beginnen, indem Sie die Package Manager Console öffnen, zum Projekt Expenses.Data.Access
wechseln (da der EF
-Kontext dort liegt) und den Befehl Add-Migration InitialCreate
:
Bereiten Sie im nächsten Schritt die Konfigurationsdatei appsettings.json vor, die nach der Vorbereitung noch in das Projekt Expenses.Api.IntegrationTests
kopiert werden muss, da wir von dort aus die Testinstanz-API ausführen.
{ "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" } }
Der Protokollierungsabschnitt wird automatisch erstellt. Ich habe den Abschnitt „ Data
“ hinzugefügt, um die Verbindungszeichenfolge zur Datenbank und meinen ApplicationInsights
-Schlüssel zu speichern.
Anwendungskonfiguration
Sie müssen verschiedene in unserer Anwendung verfügbare Dienste konfigurieren:
Einschalten von ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Registrieren Sie Ihre Dienste über einen Aufruf: ContainerSetup.Setup(services, Configuration);
ContainerSetup
ist eine Klasse, die erstellt wurde, damit wir nicht alle Dienstregistrierungen in der Startup
-Klasse speichern müssen. Die Klasse befindet sich im IoC-Ordner des Ausgabenprojekts:
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); } } }
Fast der gesamte Code in dieser Klasse spricht für sich selbst, aber ich möchte etwas mehr auf die ConfigureAutoMapper
-Methode eingehen.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
Diese Methode verwendet die Hilfsklasse, um alle Zuordnungen zwischen Modellen und Entitäten und umgekehrt zu finden, und ruft die IMapper
Schnittstelle ab, um den IAutoMapper
Wrapper zu erstellen, der in Controllern verwendet wird. Dieser Wrapper ist nichts Besonderes – er bietet lediglich eine praktische Schnittstelle zu den AutoMapper
Methoden.
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); } }
Zur Konfiguration von AutoMapper wird die Helper-Klasse verwendet, deren Aufgabe es ist, Mappings für bestimmte Namespace-Klassen zu suchen. Alle Zuordnungen befinden sich im Ordner Ausgaben/Karten:
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); } }
Es ist wichtig, Swagger
nicht zu vergessen, um eine hervorragende API-Beschreibung für andere ASP.net-Entwickler zu erhalten:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
Die Startup.Configure
-Methode fügt der InitDatabase
Methode einen Aufruf hinzu, der die Datenbank bis zur letzten Migration automatisch migriert:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
Swagger
wird nur aktiviert, wenn die Anwendung in der Entwicklungsumgebung ausgeführt wird und keine Authentifizierung für den Zugriff erfordert:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
Als nächstes verbinden wir die Authentifizierung (Details finden Sie im Repository):
ConfigureAuthentication(app);
An dieser Stelle können Sie Integrationstests ausführen und sicherstellen, dass alles kompiliert ist, aber nichts funktioniert, und zum Controller ExpensesController
gehen.
Hinweis: Alle Controller befinden sich im Ordner Ausgaben/Server und sind bedingt in zwei Ordner unterteilt: Controllers und RestApi. In dem Ordner sind Controller Controller, die im alten guten MVC als Controller fungieren – dh das Markup zurückgeben, und in RestApi REST-Controller.
Sie müssen die Klasse Expenses/Server/RestApi/ExpensesController erstellen und von der Controller-Klasse erben:
public class ExpensesController : Controller { }
Als nächstes konfigurieren Sie das Routing vom Typ ~ / api / Expenses
, indem Sie die Klasse mit dem Attribut [Route ("api / [controller]")]
markieren.
Um auf die Geschäftslogik und den Mapper zuzugreifen, müssen Sie die folgenden Dienste einfügen:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
In dieser Phase können Sie mit der Implementierung von Methoden beginnen. Die erste Methode besteht darin, eine Liste der Ausgaben zu erhalten:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
Die Implementierung der Methode ist sehr einfach, wir erhalten eine Abfrage an die Datenbank, die im IQueryable <ExpenseModel>
von ExpensesQueryProcessor
abgebildet wird, was wiederum als Ergebnis zurückkehrt.
Das benutzerdefinierte Attribut hier ist QueryableResult
, das die AutoQueryable
Bibliothek verwendet, um das Paging, Filtern und Sortieren auf der Serverseite zu handhaben. Das Attribut befindet sich im Ordner Expenses/Filters
. Als Ergebnis gibt dieser Filter Daten vom Typ DataResult <ExpenseModel>
an den API-Client zurück.
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}); } }
Sehen wir uns auch die Implementierung der Post-Methode an, die einen Flow erstellt:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
Hier sollten Sie auf das Attribut ValidateModel
achten, das eine einfache Validierung der Eingabedaten gemäß den Datenannotationsattributen durchführt, und zwar durch die eingebauten MVC-Prüfungen.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
Vollständiger Code von 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); } }
Fazit
Ich beginne mit Problemen: Das Hauptproblem ist die Komplexität der anfänglichen Konfiguration der Lösung und das Verständnis der Schichten der Anwendung, aber mit zunehmender Komplexität der Anwendung bleibt die Komplexität des Systems nahezu unverändert, was eine große ist plus bei Begleitung eines solchen Systems. Und es ist sehr wichtig, dass wir eine API haben, für die es eine Reihe von Integrationstests und eine vollständige Reihe von Einheitentests für die Geschäftslogik gibt. Die Geschäftslogik ist vollständig von der verwendeten Servertechnologie getrennt und kann vollständig getestet werden. Diese Lösung eignet sich gut für Systeme mit einer komplexen API und komplexer Geschäftslogik.
Wenn Sie eine Angular-App erstellen möchten, die Ihre API nutzt, sehen Sie sich Angular 5 und ASP.NET Core von Toptaler Pablo Albella an.