65.9K
CodeProject 正在变化。 阅读更多。
Home

在 ASP.NET Core 2.0 中创建 Web API

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (69投票s)

2018年10月22日

CPOL

13分钟阅读

viewsIcon

121898

在本指南中,我们将使用 WideWorldImporters 数据库来创建 Web API。

引言

让我们使用最新版本的 ASP.NET Core 和 Entity Framework Core 创建一个 Web API。

在本指南中,我们将使用 WideWorldImporters 数据库来创建 Web API。

REST API 至少提供以下操作:

  • GET
  • POST
  • PUT
  • 删除

REST 还有其他操作,但本指南不需要它们。

这些操作允许客户端通过 REST API 执行操作,因此我们的 Web API 必须包含这些操作。

WideWorldImporters 数据库包含 4 个架构

  • Application
  • 采购
  • 销售
  • 仓库

在本指南中,我们将处理 Warehouse.StockItems 表。我们将添加代码来处理此实体:允许从数据库检索库存项、按 ID 检索库存项、创建、更新和删除库存项。

此 API 的版本是 1。

这是 API 的路由表

动词 Url 描述
GET api/v1/Warehouse/StockItem 检索库存项
GET api/v1/Warehouse/StockItem/id 按 ID 检索库存项
POST api/v1/Warehouse/StockItem 创建新的库存项
PUT api/v1/Warehouse/StockItem/id 更新现有库存项
删除 api/v1/Warehouse/StockItem/id 删除现有库存项

请记住这些路由,因为 API 必须实现所有路由。

必备组件

软件

技能

  • C#
  • ORM(对象关系映射)
  • TDD(测试驱动开发)
  • RESTful 服务

Using the Code

本指南中,源代码的工作目录是C:\Projects

步骤 01 - 创建项目

打开 Visual Studio 并按照以下步骤操作

  1. 转到文件 > 新建 > 项目
  2. 转到已安装 > Visual C# > .NET Core
  3. 将项目名称设置为 WideWorldImporters.API
  4. 单击“确定”

Create Project

在下一个窗口中,选择API和 ASP.NET Core 的最新版本,在本例中是 2.1

Configuration For Api

Visual Studio 完成解决方案创建后,我们将看到此窗口

Overview For Api

步骤 02 - 安装 Nuget 包

在此步骤中,我们需要安装以下 NuGet 包:

  • EntityFrameworkCore.SqlServer
  • Swashbuckle.AspNetCore

现在,我们将通过右键单击 WideWorldImporters.API 项目来安装 Nuget 中的 EntityFrameworkCore.SqlServer 包。

Manage NuGet Packages

切换到“浏览”选项卡,然后键入 Microsoft.EntityFrameworkCore.SqlServer

Install EntityFrameworkCore.SqlServer Package

接下来,安装 Swashbuckle.AspNetCore 包。

Install Swashbuckle.AspNetCore Package

Swashbuckle.AspNetCore 包允许为 Web API 启用帮助页面。

这是项目的结构。

现在运行项目以检查解决方案是否已准备好,按 F5,Visual Studio 将显示此浏览器窗口。

First Run

默认情况下,Visual Studio 会在Controllers目录中添加一个名为ValuesController的文件,请将其从项目中删除。

步骤 03 - 添加模型

现在,创建一个名为Models的目录,并添加以下文件:

  • Domain.cs
  • Extensions.cs
  • Requests.cs
  • Responses.cs

Domain.cs 将包含所有与Entity Framework Core相关的代码。

Extensions.cs 将包含 DbContext 和集合的扩展方法。

Requests.cs 将包含请求的定义。

Responses.cs 将包含响应的定义。

Domain.cs 文件的代码

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
    public partial class StockItem
    {
        public StockItem()
        {
        }

        public StockItem(int? stockItemID)
        {
            StockItemID = stockItemID;
        }

        public int? StockItemID { get; set; }

        public string StockItemName { get; set; }

        public int? SupplierID { get; set; }

        public int? ColorID { get; set; }

        public int? UnitPackageID { get; set; }

        public int? OuterPackageID { get; set; }

        public string Brand { get; set; }

        public string Size { get; set; }

        public int? LeadTimeDays { get; set; }

        public int? QuantityPerOuter { get; set; }

        public bool? IsChillerStock { get; set; }

        public string Barcode { get; set; }

        public decimal? TaxRate { get; set; }

        public decimal? UnitPrice { get; set; }

        public decimal? RecommendedRetailPrice { get; set; }

        public decimal? TypicalWeightPerUnit { get; set; }

        public string MarketingComments { get; set; }

        public string InternalComments { get; set; }

        public string CustomFields { get; set; }

        public string Tags { get; set; }

        public string SearchDetails { get; set; }

        public int? LastEditedBy { get; set; }

        public DateTime? ValidFrom { get; set; }

        public DateTime? ValidTo { get; set; }
    }

    public class StockItemsConfiguration : IEntityTypeConfiguration<StockItem>
    {
        public void Configure(EntityTypeBuilder<StockItem> builder)
        {
            // Set configuration for entity
            builder.ToTable("StockItems", "Warehouse");

            // Set key for entity
            builder.HasKey(p => p.StockItemID);

            // Set configuration for columns

            builder.Property(p => p.StockItemName).HasColumnType("nvarchar(200)").IsRequired();
            builder.Property(p => p.SupplierID).HasColumnType("int").IsRequired();
            builder.Property(p => p.ColorID).HasColumnType("int");
            builder.Property(p => p.UnitPackageID).HasColumnType("int").IsRequired();
            builder.Property(p => p.OuterPackageID).HasColumnType("int").IsRequired();
            builder.Property(p => p.Brand).HasColumnType("nvarchar(100)");
            builder.Property(p => p.Size).HasColumnType("nvarchar(40)");
            builder.Property(p => p.LeadTimeDays).HasColumnType("int").IsRequired();
            builder.Property(p => p.QuantityPerOuter).HasColumnType("int").IsRequired();
            builder.Property(p => p.IsChillerStock).HasColumnType("bit").IsRequired();
            builder.Property(p => p.Barcode).HasColumnType("nvarchar(100)");
            builder.Property(p => p.TaxRate).HasColumnType("decimal(18, 3)").IsRequired();
            builder.Property(p => p.UnitPrice).HasColumnType("decimal(18, 2)").IsRequired();
            builder.Property(p => p.RecommendedRetailPrice).HasColumnType("decimal(18, 2)");
            builder.Property(p => p.TypicalWeightPerUnit).HasColumnType("decimal(18, 3)").IsRequired();
            builder.Property(p => p.MarketingComments).HasColumnType("nvarchar(max)");
            builder.Property(p => p.InternalComments).HasColumnType("nvarchar(max)");
            builder.Property(p => p.CustomFields).HasColumnType("nvarchar(max)");
            builder.Property(p => p.LastEditedBy).HasColumnType("int").IsRequired();

            // Columns with default value

            builder
                .Property(p => p.StockItemID)
                .HasColumnType("int")
                .IsRequired()
                .HasDefaultValueSql("NEXT VALUE FOR [Sequences].[StockItemID]");

            // Computed columns

            builder
                .Property(p => p.Tags)
                .HasColumnType("nvarchar(max)")
                .HasComputedColumnSql("json_query([CustomFields],N'$.Tags')");

            builder
                .Property(p => p.SearchDetails)
                .HasColumnType("nvarchar(max)")
                .IsRequired()
                .HasComputedColumnSql("concat([StockItemName],N' ',[MarketingComments])");

            // Columns with generated value on add or update

            builder
                .Property(p => p.ValidFrom)
                .HasColumnType("datetime2")
                .IsRequired()
                .ValueGeneratedOnAddOrUpdate();

            builder
                .Property(p => p.ValidTo)
                .HasColumnType("datetime2")
                .IsRequired()
                .ValueGeneratedOnAddOrUpdate();
        }
    }

    public class WideWorldImportersDbContext : DbContext
    {
        public WideWorldImportersDbContext(DbContextOptions<WideWorldImportersDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Apply configurations for entity

            modelBuilder
                .ApplyConfiguration(new StockItemsConfiguration());

            base.OnModelCreating(modelBuilder);
        }

        public DbSet<StockItem> StockItems { get; set; }
    }
#pragma warning restore CS1591
}

Extensions.cs 文件的代码

using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
    public static class WideWorldImportersDbContextExtensions
    {
        public static IQueryable<StockItem> GetStockItems(this WideWorldImportersDbContext dbContext, int pageSize = 10, int pageNumber = 1, int? lastEditedBy = null, int? colorID = null, int? outerPackageID = null, int? supplierID = null, int? unitPackageID = null)
        {
            // Get query from DbSet
            var query = dbContext.StockItems.AsQueryable();

            // Filter by: 'LastEditedBy'
            if (lastEditedBy.HasValue)
                query = query.Where(item => item.LastEditedBy == lastEditedBy);

            // Filter by: 'ColorID'
            if (colorID.HasValue)
                query = query.Where(item => item.ColorID == colorID);

            // Filter by: 'OuterPackageID'
            if (outerPackageID.HasValue)
                query = query.Where(item => item.OuterPackageID == outerPackageID);

            // Filter by: 'SupplierID'
            if (supplierID.HasValue)
                query = query.Where(item => item.SupplierID == supplierID);

            // Filter by: 'UnitPackageID'
            if (unitPackageID.HasValue)
                query = query.Where(item => item.UnitPackageID == unitPackageID);

            return query;
        }

        public static async Task<StockItem> GetStockItemsAsync(this WideWorldImportersDbContext dbContext, StockItem entity)
            => await dbContext.StockItems.FirstOrDefaultAsync(item => item.StockItemID == entity.StockItemID);

        public static async Task<StockItem> GetStockItemsByStockItemNameAsync(this WideWorldImportersDbContext dbContext, StockItem entity)
            => await dbContext.StockItems.FirstOrDefaultAsync(item => item.StockItemName == entity.StockItemName);
    }

    public static class IQueryableExtensions
    {
        public static IQueryable<TModel> Paging<TModel>(this IQueryable<TModel> query, int pageSize = 0, int pageNumber = 0) where TModel : class
            => pageSize > 0 && pageNumber > 0 ? query.Skip((pageNumber - 1) * pageSize).Take(pageSize) : query;
    }
#pragma warning restore CS1591
}

Requests.cs 文件的代码

using System;
using System.ComponentModel.DataAnnotations;

namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
    public class PostStockItemsRequest
    {
        [Key]
        public int? StockItemID { get; set; }

        [Required]
        [StringLength(200)]
        public string StockItemName { get; set; }

        [Required]
        public int? SupplierID { get; set; }

        public int? ColorID { get; set; }

        [Required]
        public int? UnitPackageID { get; set; }

        [Required]
        public int? OuterPackageID { get; set; }

        [StringLength(100)]
        public string Brand { get; set; }

        [StringLength(40)]
        public string Size { get; set; }

        [Required]
        public int? LeadTimeDays { get; set; }

        [Required]
        public int? QuantityPerOuter { get; set; }

        [Required]
        public bool? IsChillerStock { get; set; }

        [StringLength(100)]
        public string Barcode { get; set; }

        [Required]
        public decimal? TaxRate { get; set; }

        [Required]
        public decimal? UnitPrice { get; set; }

        public decimal? RecommendedRetailPrice { get; set; }

        [Required]
        public decimal? TypicalWeightPerUnit { get; set; }

        public string MarketingComments { get; set; }

        public string InternalComments { get; set; }

        public string CustomFields { get; set; }

        public string Tags { get; set; }

        [Required]
        public string SearchDetails { get; set; }

        [Required]
        public int? LastEditedBy { get; set; }

        public DateTime? ValidFrom { get; set; }

        public DateTime? ValidTo { get; set; }
    }

    public class PutStockItemsRequest
    {
        [Required]
        [StringLength(200)]
        public string StockItemName { get; set; }

        [Required]
        public int? SupplierID { get; set; }

        public int? ColorID { get; set; }

        [Required]
        public decimal? UnitPrice { get; set; }
    }

    public static class Extensions
    {
        public static StockItem ToEntity(this PostStockItemsRequest request)
            => new StockItem
            {
                StockItemID = request.StockItemID,
                StockItemName = request.StockItemName,
                SupplierID = request.SupplierID,
                ColorID = request.ColorID,
                UnitPackageID = request.UnitPackageID,
                OuterPackageID = request.OuterPackageID,
                Brand = request.Brand,
                Size = request.Size,
                LeadTimeDays = request.LeadTimeDays,
                QuantityPerOuter = request.QuantityPerOuter,
                IsChillerStock = request.IsChillerStock,
                Barcode = request.Barcode,
                TaxRate = request.TaxRate,
                UnitPrice = request.UnitPrice,
                RecommendedRetailPrice = request.RecommendedRetailPrice,
                TypicalWeightPerUnit = request.TypicalWeightPerUnit,
                MarketingComments = request.MarketingComments,
                InternalComments = request.InternalComments,
                CustomFields = request.CustomFields,
                Tags = request.Tags,
                SearchDetails = request.SearchDetails,
                LastEditedBy = request.LastEditedBy,
                ValidFrom = request.ValidFrom,
                ValidTo = request.ValidTo
            };
    }
#pragma warning restore CS1591
}

Responses.cs 文件的代码

using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Mvc;

namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
    public interface IResponse
    {
        string Message { get; set; }

        bool DidError { get; set; }

        string ErrorMessage { get; set; }
    }

    public interface ISingleResponse<TModel> : IResponse
    {
        TModel Model { get; set; }
    }

    public interface IListResponse<TModel> : IResponse
    {
        IEnumerable<TModel> Model { get; set; }
    }

    public interface IPagedResponse<TModel> : IListResponse<TModel>
    {
        int ItemsCount { get; set; }

        double PageCount { get; }
    }

    public class Response : IResponse
    {
        public string Message { get; set; }

        public bool DidError { get; set; }

        public string ErrorMessage { get; set; }
    }

    public class SingleResponse<TModel> : ISingleResponse<TModel>
    {
        public string Message { get; set; }

        public bool DidError { get; set; }

        public string ErrorMessage { get; set; }

        public TModel Model { get; set; }
    }

    public class ListResponse<TModel> : IListResponse<TModel>
    {
        public string Message { get; set; }

        public bool DidError { get; set; }

        public string ErrorMessage { get; set; }

        public IEnumerable<TModel> Model { get; set; }
    }

    public class PagedResponse<TModel> : IPagedResponse<TModel>
    {
        public string Message { get; set; }

        public bool DidError { get; set; }

        public string ErrorMessage { get; set; }

        public IEnumerable<TModel> Model { get; set; }

        public int PageSize { get; set; }

        public int PageNumber { get; set; }

        public int ItemsCount { get; set; }

        public double PageCount
            => ItemsCount < PageSize ? 1 : (int)(((double)ItemsCount / PageSize) + 1);
    }

    public static class ResponseExtensions
    {
        public static IActionResult ToHttpResponse(this IResponse response)
        {
            var status = response.DidError ? HttpStatusCode.InternalServerError : HttpStatusCode.OK;

            return new ObjectResult(response)
            {
                StatusCode = (int)status
            };
        }

        public static IActionResult ToHttpResponse<TModel>(this ISingleResponse<TModel> response)
        {
            var status = HttpStatusCode.OK;

            if (response.DidError)
                status = HttpStatusCode.InternalServerError;
            else if (response.Model == null)
                status = HttpStatusCode.NotFound;

            return new ObjectResult(response)
            {
                StatusCode = (int)status
            };
        }

        public static IActionResult ToHttpResponse<TModel>(this IListResponse<TModel> response)
        {
            var status = HttpStatusCode.OK;

            if (response.DidError)
                status = HttpStatusCode.InternalServerError;
            else if (response.Model == null)
                status = HttpStatusCode.NoContent;

            return new ObjectResult(response)
            {
                StatusCode = (int)status
            };
        }
    }
#pragma warning restore CS1591
}

理解模型

StockItems 类是对 Warehouse.StockItems 表的表示。

StockItemsConfiguration 类包含 StockItems 类的映射。

WideWorldImportersDbContext 类是数据库和 C# 代码之间的链接,此类处理查询并提交数据库中的更改,当然还有其他事情。

扩展

WideWorldImportersDbContextExtensions 包含 DbContext 实例的扩展方法,一个用于检索库存项,另一个用于按 ID 检索库存项,最后一个用于按名称检索库存项。

IQueryableExtensions 包含 IQueryable 的扩展方法,用于添加分页功能。

请求

我们有以下定义:

  • PostStockItemsRequest
  • PutStockItemsRequest

PostStockItemsRequest 代表创建新库存项的模型,包含保存到数据库所需的所有属性。

PutStockItemsRequest 代表更新现有库存项的模型,在这种情况下,它只包含 4 个属性:StockItemNameSupplierIDColorIDUnitPrice。此类不包含 StockItemID 属性,因为 ID 存在于控制器操作的路由中。

请求的模型不需要包含与实体相同的全部属性,因为我们不需要在请求或响应中公开完整的定义,使用属性较少的模型来限制数据是一个好习惯。

Extensions 类包含 PostStockItemsRequest 的一个扩展方法,用于从请求模型返回 StockItem 类的实例。

响应

这些是接口:

  • IResponse
  • ISingleResponse<TModel>
  • IListResponse<TModel>
  • IPagedResponse<TModel>

这些接口中的每一个都有实现,为什么我们需要这些定义,如果直接返回对象而不是将它们包装在这些模型中更简单?请记住,此 Web API 将为客户端提供操作,无论是否有 UI,并且更容易拥有用于发送消息的属性,拥有模型或在发生错误时发送信息,此外,我们在响应中设置 HTTP 状态码来描述请求的结果。

这些类是通用的,因为这样,我们节省了以后定义响应的时间,此 Web API 只为单个实体、列表和分页列表返回响应。

ISingleResponse 代表单个实体的响应。

IListResponse 代表带有列表的响应,例如现有订单的所有发货(不分页)。

IPagedResponse 代表带有分页的响应,例如日期范围内的所有订单。

ResponseExtensions 类包含将响应转换为 HTTP 响应的扩展方法,这些方法在发生错误时返回 InternalServerError (500) 状态,在正常情况下返回 OK (200),如果数据库中不存在实体则返回 NotFound (404),对于没有模型的列表响应则返回 NoContent (204)。

步骤 04 - 添加控制器

现在,在 Controllers 目录中,添加一个名为 WarehouseController.cs 的代码文件并添加此代码。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WideWorldImporters.API.Models;

namespace WideWorldImporters.API.Controllers
{
#pragma warning disable CS1591
    [ApiController]
    [Route("api/v1/[controller]")]
    public class WarehouseController : ControllerBase
    {
        protected readonly ILogger Logger;
        protected readonly WideWorldImportersDbContext DbContext;

        public WarehouseController(ILogger<WarehouseController> logger, WideWorldImportersDbContext dbContext)
        {
            Logger = logger;
            DbContext = dbContext;
        }
#pragma warning restore CS1591

        // GET
        // api/v1/Warehouse/StockItem

        /// <summary>
        /// Retrieves stock items
        /// </summary>
        /// <param name="pageSize">Page size</param>
        /// <param name="pageNumber">Page number</param>
        /// <param name="lastEditedBy">Last edit by (user id)</param>
        /// <param name="colorID">Color id</param>
        /// <param name="outerPackageID">Outer package id</param>
        /// <param name="supplierID">Supplier id</param>
        /// <param name="unitPackageID">Unit package id</param>
        /// <returns>A response with stock items list</returns>
        /// <response code="200">Returns the stock items list</response>
        /// <response code="500">If there was an internal server error</response>
        [HttpGet("StockItem")]
        [ProducesResponseType(200)]
        [ProducesResponseType(500)]
        public async Task<IActionResult> GetStockItemsAsync(int pageSize = 10, int pageNumber = 1, int? lastEditedBy = null, int? colorID = null, int? outerPackageID = null, int? supplierID = null, int? unitPackageID = null)
        {
            Logger?.LogDebug("'{0}' has been invoked", nameof(GetStockItemsAsync));

            var response = new PagedResponse<StockItem>();

            try
            {
                // Get the "proposed" query from repository
                var query = DbContext.GetStockItems();

                // Set paging values
                response.PageSize = pageSize;
                response.PageNumber = pageNumber;

                // Get the total rows
                response.ItemsCount = await query.CountAsync();

                // Get the specific page from database
                response.Model = await query.Paging(pageSize, pageNumber).ToListAsync();

                response.Message = string.Format("Page {0} of {1}, Total of products: {2}.", pageNumber, response.PageCount, response.ItemsCount);

                Logger?.LogInformation("The stock items have been retrieved successfully.");
            }
            catch (Exception ex)
            {
                response.DidError = true;
                response.ErrorMessage = "There was an internal error, please contact to technical support.";

                Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(GetStockItemsAsync), ex);
            }

            return response.ToHttpResponse();
        }

        // GET
        // api/v1/Warehouse/StockItem/5

        /// <summary>
        /// Retrieves a stock item by ID
        /// </summary>
        /// <param name="id">Stock item id</param>
        /// <returns>A response with stock item</returns>
        /// <response code="200">Returns the stock items list</response>
        /// <response code="404">If stock item is not exists</response>
        /// <response code="500">If there was an internal server error</response>
        [HttpGet("StockItem/{id}")]
        [ProducesResponseType(200)]
        [ProducesResponseType(404)]
        [ProducesResponseType(500)]
        public async Task<IActionResult> GetStockItemAsync(int id)
        {
            Logger?.LogDebug("'{0}' has been invoked", nameof(GetStockItemAsync));

            var response = new SingleResponse<StockItem>();

            try
            {
                // Get the stock item by id
                response.Model = await DbContext.GetStockItemsAsync(new StockItem(id));
            }
            catch (Exception ex)
            {
                response.DidError = true;
                response.ErrorMessage = "There was an internal error, please contact to technical support.";

                Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(GetStockItemAsync), ex);
            }

            return response.ToHttpResponse();
        }

        // POST
        // api/v1/Warehouse/StockItem/

        /// <summary>
        /// Creates a new stock item
        /// </summary>
        /// <param name="request">Request model</param>
        /// <returns>A response with new stock item</returns>
        /// <response code="200">Returns the stock items list</response>
        /// <response code="201">A response as creation of stock item</response>
        /// <response code="400">For bad request</response>
        /// <response code="500">If there was an internal server error</response>
        [HttpPost("StockItem")]
        [ProducesResponseType(200)]
        [ProducesResponseType(201)]
        [ProducesResponseType(400)]
        [ProducesResponseType(500)]
        public async Task<IActionResult> PostStockItemAsync([FromBody]PostStockItemsRequest request)
        {
            Logger?.LogDebug("'{0}' has been invoked", nameof(PostStockItemAsync));

            var response = new SingleResponse<StockItem>();

            try
            {
                var existingEntity = await DbContext
                    .GetStockItemsByStockItemNameAsync(new StockItem { StockItemName = request.StockItemName });

                if (existingEntity != null)
                    ModelState.AddModelError("StockItemName", "Stock item name already exists");

                if (!ModelState.IsValid)
                    return BadRequest();

                // Create entity from request model
                var entity = request.ToEntity();

                // Add entity to repository
                DbContext.Add(entity);

                // Save entity in database
                await DbContext.SaveChangesAsync();

                // Set the entity to response model
                response.Model = entity;
            }
            catch (Exception ex)
            {
                response.DidError = true;
                response.ErrorMessage = "There was an internal error, please contact to technical support.";

                Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(PostStockItemAsync), ex);
            }

            return response.ToHttpResponse();
        }

        // PUT
        // api/v1/Warehouse/StockItem/5

        /// <summary>
        /// Updates an existing stock item
        /// </summary>
        /// <param name="id">Stock item ID</param>
        /// <param name="request">Request model</param>
        /// <returns>A response as update stock item result</returns>
        /// <response code="200">If stock item was updated successfully</response>
        /// <response code="400">For bad request</response>
        /// <response code="404">If stock item is not exists</response>
        /// <response code="500">If there was an internal server error</response>
        [HttpPut("StockItem/{id}")]
        [ProducesResponseType(200)]
        [ProducesResponseType(400)]
        [ProducesResponseType(404)]
        [ProducesResponseType(500)]
        public async Task<IActionResult> PutStockItemAsync(int id, [FromBody]PutStockItemsRequest request)
        {
            Logger?.LogDebug("'{0}' has been invoked", nameof(PutStockItemAsync));

            var response = new Response();

            try
            {
                // Get stock item by id
                var entity = await DbContext.GetStockItemsAsync(new StockItem(id));

                // Validate if entity exists
                if (entity == null)
                    return NotFound();

                // Set changes to entity
                entity.StockItemName = request.StockItemName;
                entity.SupplierID = request.SupplierID;
                entity.ColorID = request.ColorID;
                entity.UnitPrice = request.UnitPrice;

                // Update entity in repository
                DbContext.Update(entity);

                // Save entity in database
                await DbContext.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                response.DidError = true;
                response.ErrorMessage = "There was an internal error, please contact to technical support.";

                Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(PutStockItemAsync), ex);
            }

            return response.ToHttpResponse();
        }

        // DELETE
        // api/v1/Warehouse/StockItem/5

        /// <summary>
        /// Deletes an existing stock item
        /// </summary>
        /// <param name="id">Stock item ID</param>
        /// <returns>A response as delete stock item result</returns>
        /// <response code="200">If stock item was deleted successfully</response>
        /// <response code="404">If stock item is not exists</response>
        /// <response code="500">If there was an internal server error</response>
        [HttpDelete("StockItem/{id}")]
        [ProducesResponseType(200)]
        [ProducesResponseType(404)]
        [ProducesResponseType(500)]
        public async Task<IActionResult> DeleteStockItemAsync(int id)
        {
            Logger?.LogDebug("'{0}' has been invoked", nameof(DeleteStockItemAsync));

            var response = new Response();

            try
            {
                // Get stock item by id
                var entity = await DbContext.GetStockItemsAsync(new StockItem(id));

                // Validate if entity exists
                if (entity == null)
                    return NotFound();

                // Remove entity from repository
                DbContext.Remove(entity);

                // Delete entity in database
                await DbContext.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                response.DidError = true;
                response.ErrorMessage = "There was an internal error, please contact to technical support.";

                Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(DeleteStockItemAsync), ex);
            }

            return response.ToHttpResponse();
        }
    }
}

所有控制器操作的流程是:

  1. 记录方法的调用。
  2. 创建响应实例(分页、列表或单个),具体取决于操作。
  3. 通过 DbContext 实例执行数据库访问。
  4. 如果存储库调用失败,请将 DidError 属性设置为 true,并将 ErrorMessage 属性设置为:发生内部错误,请联系技术支持。,因为不建议在响应中暴露错误详细信息,将所有异常详细信息保存在日志文件中更好。
  5. 将结果作为 HTTP 响应返回。

请记住所有以 Async 后缀结尾的方法名称,因为所有操作都是异步的,但在 HTTP 属性中,我们不使用此后缀。

步骤 05 - 设置依赖注入

ASP.NET Core 以原生方式启用依赖注入,这意味着我们不需要任何第三方框架来向控制器注入依赖项。

这是一个巨大的挑战,因为我们需要改变我们对 Web Forms 和 ASP.NET MVC 的看法,对于那些技术来说,使用框架注入依赖项是一种奢侈,现在在 ASP.NET Core 中,依赖注入是一个基本方面。

ASP.NET Core 的项目模板有一个名为Startup的类,在这个类中,我们必须添加注入 DbContext、服务、日志记录等实例的配置。

修改 Startup.cs 文件的代码,使其如下所示:

using System;
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Swagger;
using WideWorldImporters.API.Controllers;
using WideWorldImporters.API.Models;

namespace WideWorldImporters.API
{
#pragma warning disable CS1591
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            // Add configuration for DbContext
            // Use connection string from appsettings.json file
            services.AddDbContext<WideWorldImportersDbContext>(builder =>
            {
                builder.UseSqlServer(Configuration["AppSettings:ConnectionString"]);
            });

            // Set up dependency injection for controller's logger
            services.AddScoped<ILogger, Logger<WarehouseController>>();

            // Register the Swagger generator, defining 1 or more Swagger documents
            services.AddSwaggerGen(options =>
            {
                options.SwaggerDoc("v1", new Info { Title = "WideWorldImporters API", Version = "v1" });

                // Get xml comments path
                var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
                var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);

                // Set xml path
                options.IncludeXmlComments(xmlPath);
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
                app.UseDeveloperExceptionPage();

            // Enable middleware to serve generated Swagger as a JSON endpoint.
            app.UseSwagger();

            // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
            app.UseSwaggerUI(options =>
            {
                options.SwaggerEndpoint("/swagger/v1/swagger.json", "WideWorldImporters API V1");
            });

            app.UseMvc();
        }
    }
#pragma warning restore CS1591
}

ConfigureServices 方法指定了依赖关系如何解析,在此方法中,我们需要设置 DbContextLogging

Configure 方法添加了 HTTP 请求运行时的配置。

步骤 06 - 运行 Web API

在运行 Web API 项目之前,请在 appsettings.json 文件中添加连接字符串。

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AppSettings": {
    "ConnectionString": "server=(local);database=WideWorldImporters;integrated security=yes;"
  }
}

为了在帮助页面中显示描述,请为您的 Web API 项目启用 XML 文档。

  1. 右键单击项目 > 属性
  2. 转到生成 > 输出
  3. 启用XML 文档文件
  4. 保存更改

Enable XML Documentation File

现在,按 F5 开始调试 Web API 项目,如果一切正常,我们将在浏览器中获得以下输出:

Get Stock Items In Browser

另外,我们可以在另一个选项卡中加载帮助页面。

Help Page

步骤 07 - 添加单元测试

为了给 API 项目添加单元测试,请按照以下步骤操作:

  1. 右键单击解决方案 > 添加 > 新项目
  2. 转到已安装 > Visual C# > 测试 > xUnit 测试项目 (.NET Core)
  3. 将项目名称设置为 WideWorldImporters.API.UnitTests
  4. 单击“确定”

Add Unit Tests Project

管理 WideWorldImporters.API.UnitTests 项目的引用。

Add Reference To Api Project

现在为 WideWorldImporters.API 项目添加一个引用。

Reference Manager for Unit Tests Project.jpg

创建项目后,为项目添加以下 NuGet 包:

  • Microsoft.AspNetCore.Mvc.Core
  • Microsoft.EntityFrameworkCore.InMemory

删除 UnitTest1.cs 文件。

保存更改并生成 WideWorldImporters.API.UnitTests 项目。

现在我们开始添加与单元测试相关的代码,这些测试将使用内存数据库

什么是 TDD?测试是当今常见的实践,因为通过单元测试,可以在发布功能之前轻松地执行测试。测试驱动开发 (TDD) 是定义单元测试和验证代码行为的方法。

TDD 中的另一个概念是AAAArrange(准备)、Act(执行)和Assert(断言);Arrange 是创建对象的块,Act 是放置所有方法调用的块,Assert 是验证方法调用结果的块。

由于我们正在为单元测试使用内存数据库,因此我们需要创建一个类来模拟 WideWorldImportersDbContext 类,并添加数据来执行 IWarehouseRepository 操作的测试。

为了清楚起见:这些单元测试不与 SQL Server 建立连接

对于单元测试,请添加以下文件:

  • DbContextMocker.cs
  • DbContextExtensions.cs
  • WarehouseControllerUnitTest.cs

DbContextMocker.cs 文件的代码

using Microsoft.EntityFrameworkCore;
using WideWorldImporters.API.Models;

namespace WideWorldImporters.API.UnitTests
{
    public static class DbContextMocker
    {
        public static WideWorldImportersDbContext GetWideWorldImportersDbContext(string dbName)
        {
            // Create options for DbContext instance
            var options = new DbContextOptionsBuilder<WideWorldImportersDbContext>()
                .UseInMemoryDatabase(databaseName: dbName)
                .Options;

            // Create instance of DbContext
            var dbContext = new WideWorldImportersDbContext(options);

            // Add entities in memory
            dbContext.Seed();

            return dbContext;
        }
    }
}

DbContextExtensions.cs 文件的代码

using System;
using WideWorldImporters.API.Models;

namespace WideWorldImporters.API.UnitTests
{
    public static class DbContextExtensions
    {
        public static void Seed(this WideWorldImportersDbContext dbContext)
        {
            // Add entities for DbContext instance

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 1,
                StockItemName = "USB missile launcher (Green)",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 25.00m,
                RecommendedRetailPrice = 37.38m,
                TypicalWeightPerUnit = 0.300m,
                MarketingComments = "Complete with 12 projectiles",
                CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [\"USB Powered\"] }",
                Tags = "[\"USB Powered\"]",
                SearchDetails = "USB missile launcher (Green) Complete with 12 projectiles",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 2,
                StockItemName = "USB rocket launcher (Gray)",
                SupplierID = 12,
                ColorID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 25.00m,
                RecommendedRetailPrice = 37.38m,
                TypicalWeightPerUnit = 0.300m,
                MarketingComments = "Complete with 12 projectiles",
                CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [\"USB Powered\"] }",
                Tags = "[\"USB Powered\"]",
                SearchDetails = "USB rocket launcher (Gray) Complete with 12 projectiles",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 3,
                StockItemName = "Office cube periscope (Black)",
                SupplierID = 12,
                ColorID = 3,
                UnitPackageID = 7,
                OuterPackageID = 6,
                LeadTimeDays = 14,
                QuantityPerOuter = 10,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 18.50m,
                RecommendedRetailPrice = 27.66m,
                TypicalWeightPerUnit = 0.250m,
                MarketingComments = "Need to see over your cubicle wall? This is just what's needed.",
                CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [] }",
                Tags = "[]",
                SearchDetails = "Office cube periscope (Black) Need to see over your cubicle wall? This is just what's needed.",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 4,
                StockItemName = "USB food flash drive - sushi roll",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - sushi roll ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 5,
                StockItemName = "USB food flash drive - hamburger",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",
                Tags = "[\"16GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - hamburger ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 6,
                StockItemName = "USB food flash drive - hot dog",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - hot dog ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 7,
                StockItemName = "USB food flash drive - pizza slice",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",
                Tags = "[\"16GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - pizza slice ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 8,
                StockItemName = "USB food flash drive - dim sum 10 drive variety pack",
                SupplierID = 12,
                UnitPackageID = 9,
                OuterPackageID = 9,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 240.00m,
                RecommendedRetailPrice = 358.80m,
                TypicalWeightPerUnit = 0.500m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - dim sum 10 drive variety pack ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 9,
                StockItemName = "USB food flash drive - banana",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",
                Tags = "[\"16GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - banana ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 10,
                StockItemName = "USB food flash drive - chocolate bar",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - chocolate bar ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 11,
                StockItemName = "USB food flash drive - cookie",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",
                Tags = "[\"16GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - cookie ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.StockItems.Add(new StockItem
            {
                StockItemID = 12,
                StockItemName = "USB food flash drive - donut",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB food flash drive - donut ",
                LastEditedBy = 1,
                ValidFrom = new DateTime(2016, 5, 31, 23, 11, 0),
                ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
            });

            dbContext.SaveChanges();
        }
    }
}

WarehouseControllerUnitTest.cs 文件的代码

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using WideWorldImporters.API.Controllers;
using WideWorldImporters.API.Models;
using Xunit;

namespace WideWorldImporters.API.UnitTests
{
    public class WarehouseControllerUnitTest
    {
        [Fact]
        public async Task TestGetStockItemsAsync()
        {
            // Arrange
            var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemsAsync));
            var controller = new WarehouseController(null, dbContext);

            // Act
            var response = await controller.GetStockItemsAsync() as ObjectResult;
            var value = response.Value as IPagedResponse<StockItem>;

            dbContext.Dispose();

            // Assert
            Assert.False(value.DidError);
        }

        [Fact]
        public async Task TestGetStockItemAsync()
        {
            // Arrange
            var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemAsync));
            var controller = new WarehouseController(null, dbContext);
            var id = 1;

            // Act
            var response = await controller.GetStockItemAsync(id) as ObjectResult;
            var value = response.Value as ISingleResponse<StockItem>;

            dbContext.Dispose();

            // Assert
            Assert.False(value.DidError);
        }

        [Fact]
        public async Task TestPostStockItemAsync()
        {
            // Arrange
            var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestPostStockItemAsync));
            var controller = new WarehouseController(null, dbContext);
            var requestModel = new PostStockItemsRequest
            {
                StockItemID = 100,
                StockItemName = "USB anime flash drive - Goku",
                SupplierID = 12,
                UnitPackageID = 7,
                OuterPackageID = 7,
                LeadTimeDays = 14,
                QuantityPerOuter = 1,
                IsChillerStock = false,
                TaxRate = 15.000m,
                UnitPrice = 32.00m,
                RecommendedRetailPrice = 47.84m,
                TypicalWeightPerUnit = 0.050m,
                CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                Tags = "[\"32GB\",\"USB Powered\"]",
                SearchDetails = "USB anime flash drive - Goku",
                LastEditedBy = 1,
                ValidFrom = DateTime.Now,
                ValidTo = DateTime.Now.AddYears(5)
            };

            // Act
            var response = await controller.PostStockItemAsync(requestModel) as ObjectResult;
            var value = response.Value as ISingleResponse<StockItem>;

            dbContext.Dispose();

            // Assert
            Assert.False(value.DidError);
        }

        [Fact]
        public async Task TestPutStockItemAsync()
        {
            // Arrange
            var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestPutStockItemAsync));
            var controller = new WarehouseController(null, dbContext);
            var id = 12;
            var requestModel = new PutStockItemsRequest
            {
                StockItemName = "USB food flash drive (Update)",
                SupplierID = 12,
                ColorID = 3
            };

            // Act
            var response = await controller.PutStockItemAsync(id, requestModel) as ObjectResult;
            var value = response.Value as IResponse;

            dbContext.Dispose();

            // Assert
            Assert.False(value.DidError);
        }

        [Fact]
        public async Task TestDeleteStockItemAsync()
        {
            // Arrange
            var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestDeleteStockItemAsync));
            var controller = new WarehouseController(null, dbContext);
            var id = 5;

            // Act
            var response = await controller.DeleteStockItemAsync(id) as ObjectResult;
            var value = response.Value as IResponse;

            dbContext.Dispose();

            // Assert
            Assert.False(value.DidError);
        }
    }
}

正如我们所见,WarehouseControllerUnitTest 包含 Web API 的所有测试,这些是方法:

方法 描述
TestGetStockItemsAsync 检索库存项
TestGetStockItemAsync 按 ID 检索现有库存项
TestPostStockItemAsync 创建新的库存项
TestPutStockItemAsync 更新现有库存项
TestDeleteStockItemAsync 删除现有库存项

单元测试如何工作?

DbContextMocker 使用内存数据库创建 WideWorldImportersDbContext 实例,dbName 参数设置内存数据库的名称;然后调用 Seed 方法,该方法为 WideWorldImportersDbContext 实例添加实体以提供结果。

DbContextExtensions 类包含 Seed 扩展方法。

WarehouseControllerUnitTest 类包含 WarehouseController 类的所有测试。

请记住,每个测试都在每个测试方法中使用不同的内存数据库。我们使用测试方法名称和 nameof 运算符来检索内存数据库。

在此级别(单元测试),我们只需要检查存储库的操作,无需处理 SQL 数据库(关系、事务等)。

单元测试的过程是:

  1. 创建 WideWorldImportersDbContext 的实例
  2. 创建控制器的实例
  3. 调用控制器的方
  4. 从控制器调用中获取值
  5. 释放 WideWorldImportersDbContext 实例
  6. 验证响应

运行单元测试

保存所有更改并生成 WideWorldImporters.API.UnitTests 项目。

现在,在测试资源管理器中检查测试。

Test Explorer For Unit Tests

使用测试资源管理器运行所有测试,如果遇到任何错误,请检查错误消息,审查代码并重复此过程。

步骤 08 - 添加集成测试

为了给 API 项目添加集成测试,请按照以下步骤操作:

  1. 右键单击解决方案 > 添加 > 新项目
  2. 转到已安装 > Visual C# > 测试 > xUnit 测试项目 (.NET Core)
  3. 将项目名称设置为 WideWorldImporters.API.IntegrationTests
  4. 单击“确定”

Add Integration Tests Project

管理 WideWorldImporters.API.IntegrationTests 项目的引用。

Add Reference To Api Project

现在为 WideWorldImporters.API 项目添加一个引用。

Reference Manager For Integration Tests Project

创建项目后,为项目添加以下 NuGet 包:

  • Microsoft.AspNetCore.Mvc
  • Microsoft.AspNetCore.Mvc.Core
  • Microsoft.AspNetCore.Diagnostics
  • Microsoft.AspNetCore.TestHost
  • Microsoft.Extensions.Configuration.Json

删除 UnitTest1.cs 文件。

保存更改并生成 WideWorldImporters.API.IntegrationTests 项目。

单元测试和集成测试有什么区别?对于单元测试,我们模拟 Web API 项目的所有依赖项;对于集成测试,我们运行一个模拟 Web API 执行过程的进程,这意味着 HTTP 请求。

现在我们开始添加与集成测试相关的代码。

对于这个项目,集成测试将执行 HTTP 请求,每个 HTTP 请求将对 SQL Server 实例上的现有数据库执行操作。我们将使用本地 SQL Server 实例,这可能会根据您的工作环境而变化,我的意思是集成测试的范围。

TestFixture.cs 文件的代码

using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace WideWorldImporters.API.IntegrationTests
{
    public class TestFixture<TStartup> : IDisposable
    {
        public static string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
        {
            var projectName = startupAssembly.GetName().Name;

            var applicationBasePath = AppContext.BaseDirectory;

            var directoryInfo = new DirectoryInfo(applicationBasePath);

            do
            {
                directoryInfo = directoryInfo.Parent;

                var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));

                if (projectDirectoryInfo.Exists)
                    if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)
                        return Path.Combine(projectDirectoryInfo.FullName, projectName);
            }
            while (directoryInfo.Parent != null);

            throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");
        }

        private TestServer Server;

        public TestFixture()
            : this(Path.Combine(""))
        {
        }

        public HttpClient Client { get; }

        public void Dispose()
        {
            Client.Dispose();
            Server.Dispose();
        }

        protected virtual void InitializeServices(IServiceCollection services)
        {
            var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;

            var manager = new ApplicationPartManager
            {
                ApplicationParts =
                {
                    new AssemblyPart(startupAssembly)
                },
                FeatureProviders =
                {
                    new ControllerFeatureProvider(),
                    new ViewComponentFeatureProvider()
                }
            };

            services.AddSingleton(manager);
        }

        protected TestFixture(string relativeTargetProjectParentDir)
        {
            var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
            var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly);

            var configurationBuilder = new ConfigurationBuilder()
                .SetBasePath(contentRoot)
                .AddJsonFile("appsettings.json");

            var webHostBuilder = new WebHostBuilder()
                .UseContentRoot(contentRoot)
                .ConfigureServices(InitializeServices)
                .UseConfiguration(configurationBuilder.Build())
                .UseEnvironment("Development")
                .UseStartup(typeof(TStartup));

            // Create instance of test server
            Server = new TestServer(webHostBuilder);

            // Add configuration for client
            Client = Server.CreateClient();
            Client.BaseAddress = new Uri("https://:5001");
            Client.DefaultRequestHeaders.Accept.Clear();
            Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        }
    }
}

ContentHelper.cs 文件的代码

using System.Net.Http;
using System.Text;
using Newtonsoft.Json;

namespace WideWorldImporters.API.IntegrationTests
{
    public static class ContentHelper
    {
        public static StringContent GetStringContent(object obj)
            => new StringContent(JsonConvert.SerializeObject(obj), Encoding.Default, "application/json");
    }
}

WarehouseTests.cs 文件的代码

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using WideWorldImporters.API.Models;
using Xunit;

namespace WideWorldImporters.API.IntegrationTests
{
    public class WarehouseTests : IClassFixture<TestFixture<Startup>>
    {
        private HttpClient Client;

        public WarehouseTests(TestFixture<Startup> fixture)
        {
            Client = fixture.Client;
        }

        [Fact]
        public async Task TestGetStockItemsAsync()
        {
            // Arrange
            var request = new
            {
                Url = "/api/v1/Warehouse/StockItem"
            };

            // Act
            var response = await Client.GetAsync(request.Url);

            // Assert
            response.EnsureSuccessStatusCode();
        }

        [Fact]
        public async Task TestGetStockItemAsync()
        {
            // Arrange
            var request = new
            {
                Url = "/api/v1/Warehouse/StockItem/1"
            };

            // Act
            var response = await Client.GetAsync(request.Url);

            // Assert
            response.EnsureSuccessStatusCode();
        }

        [Fact]
        public async Task TestPostStockItemAsync()
        {
            // Arrange
            var request = new
            {
                Url = "/api/v1/Warehouse/StockItem",
                Body = new
                {
                    StockItemName = string.Format("USB anime flash drive - Vegeta {0}", Guid.NewGuid()),
                    SupplierID = 12,
                    UnitPackageID = 7,
                    OuterPackageID = 7,
                    LeadTimeDays = 14,
                    QuantityPerOuter = 1,
                    IsChillerStock = false,
                    TaxRate = 15.000m,
                    UnitPrice = 32.00m,
                    RecommendedRetailPrice = 47.84m,
                    TypicalWeightPerUnit = 0.050m,
                    CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",
                    Tags = "[\"32GB\",\"USB Powered\"]",
                    SearchDetails = "USB anime flash drive - Vegeta",
                    LastEditedBy = 1,
                    ValidFrom = DateTime.Now,
                    ValidTo = DateTime.Now.AddYears(5)
                }
            };

            // Act
            var response = await Client.PostAsync(request.Url, ContentHelper.GetStringContent(request.Body));
            var value = await response.Content.ReadAsStringAsync();

            // Assert
            response.EnsureSuccessStatusCode();
        }

        [Fact]
        public async Task TestPutStockItemAsync()
        {
            // Arrange
            var request = new
            {
                Url = "/api/v1/Warehouse/StockItem/1",
                Body = new
                {
                    StockItemName = string.Format("USB anime flash drive - Vegeta {0}", Guid.NewGuid()),
                    SupplierID = 12,
                    Color = 3,
                    UnitPrice = 39.00m
                }
            };

            // Act
            var response = await Client.PutAsync(request.Url, ContentHelper.GetStringContent(request.Body));

            // Assert
            response.EnsureSuccessStatusCode();
        }

        [Fact]
        public async Task TestDeleteStockItemAsync()
        {
            // Arrange

            var postRequest = new
            {
                Url = "/api/v1/Warehouse/StockItem",
                Body = new
                {
                    StockItemName = string.Format("Product to delete {0}", Guid.NewGuid()),
                    SupplierID = 12,
                    UnitPackageID = 7,
                    OuterPackageID = 7,
                    LeadTimeDays = 14,
                    QuantityPerOuter = 1,
                    IsChillerStock = false,
                    TaxRate = 10.000m,
                    UnitPrice = 10.00m,
                    RecommendedRetailPrice = 47.84m,
                    TypicalWeightPerUnit = 0.050m,
                    CustomFields = "{ \"CountryOfManufacture\": \"USA\", \"Tags\": [\"Sample\"] }",
                    Tags = "[\"Sample\"]",
                    SearchDetails = "Product to delete",
                    LastEditedBy = 1,
                    ValidFrom = DateTime.Now,
                    ValidTo = DateTime.Now.AddYears(5)
                }
            };

            // Act
            var postResponse = await Client.PostAsync(postRequest.Url, ContentHelper.GetStringContent(postRequest.Body));
            var jsonFromPostResponse = await postResponse.Content.ReadAsStringAsync();

            var singleResponse = JsonConvert.DeserializeObject<SingleResponse<StockItem>>(jsonFromPostResponse);

            var deleteResponse = await Client.DeleteAsync(string.Format("/api/v1/Warehouse/StockItem/{0}", singleResponse.Model.StockItemID));

            // Assert
            postResponse.EnsureSuccessStatusCode();

            Assert.False(singleResponse.DidError);

            deleteResponse.EnsureSuccessStatusCode();
        }
    }
}

正如我们所见,WarehouseTests 包含 Web API 的所有测试,这些是方法:

方法 描述
TestGetStockItemsAsync 检索库存项
TestGetStockItemAsync 按 ID 检索现有库存项
TestPostStockItemAsync 创建新的库存项
TestPutStockItemAsync 更新现有库存项
TestDeleteStockItemAsync 删除现有库存项

集成测试如何工作?

TestFixture<TStartup> 类为 Web API 提供 HTTP 客户端,使用项目中的Startup类作为引用来应用客户端的配置。

WarehouseTests 类包含发送 HTTP 请求到 Web API 的所有方法,HTTP 客户端的端口号是 1234

ContentHelper 类包含一个辅助方法,用于从请求模型创建 JSON 格式的 StringContent,这适用于 POSTPUT 请求。

集成测试的过程是:

  1. HTTP 客户端在类构造函数中创建。
  2. 定义请求:URL 和请求模型(如果适用)。
  3. 发送请求。
  4. 从响应中获取值。
  5. 确保响应具有成功的状态。

运行集成测试

保存所有更改并生成 WideWorldImporters.API.IntegrationTests 项目,测试资源管理器将显示项目中的所有测试。

Test Explorer For Integration Tests

请记住要执行集成测试,您需要有一个正在运行的 SQL Server 实例appsettings.json 文件中的连接字符串将用于建立与 SQL Server 的连接。

现在运行所有集成测试,测试资源管理器看起来像下面的图片。

Execution Of Integration Tests

如果执行集成测试时出现任何错误,请检查错误消息,审查代码并重复此过程。

代码挑战

此时,您已具备扩展 API 的技能,请将其视为一项挑战,并添加以下测试(单元和集成):

测试 描述
按参数获取库存项 通过 lastEditedBycolorIDouterPackageIDsupplierIDunitPackageID 参数搜索库存项的请求。
获取不存在的库存项 使用不存在的 ID 获取库存项,并检查 Web API 返回 NotFound (404) 状态。
添加具有现有名称的库存项 添加具有现有名称的库存项,并检查 Web API 返回 BadRequest (400) 状态。
添加缺少必需字段的库存项 添加缺少必需字段的库存项,并检查 Web API 返回 BadRequest (400) 状态。
更新不存在的库存项 使用不存在的 ID 更新库存项,并检查 Web API 返回 NotFound (404) 状态。
更新具有缺少必需字段的现有库存项 更新缺少必需字段的现有库存项,并检查 Web API 返回 BadRequest (400) 状态。
删除不存在的库存项 使用不存在的 ID 删除库存项,并检查 Web API 返回 NotFound (404) 状态。
删除有订单的库存项 使用不存在的 ID 删除库存项,并检查 Web API 返回 NotFound (404) 状态。

遵循单元测试和集成测试中使用的约定来完成此挑战。

祝您好运!

代码改进

  • 解释如何使用 .NET Core 的命令行
  • 为 API 添加安全性(身份验证和授权)
  • 将模型定义拆分到文件中
  • 将模型重构到 Web API 项目之外
  • 还有什么?请在评论中告诉我:)

关注点

  • 在本文中,我们正在使用Entity Framework Core
  • Entity Framework Core 具有内存数据库。
  • 我们可以调整所有存储库以公开特定的操作,在某些情况下,我们不希望有 GetAllAddUpdateDelete 操作。
  • 单元测试执行程序集的测试。
  • 集成测试执行 HTTP 请求的测试。
  • 所有测试均使用xUnit框架创建。

相关链接

历史

  • 2018 年 10 月 22 日:初始版本
  • 2018 年 11 月 22 日:移除存储库模式
  • 2018 年 12 月 11 日:添加 Web API 的帮助页面
在 ASP.NET Core 2.0 中创建 Web API - CodeProject - 代码之家
© . All rights reserved.