在 ASP.NET Web API 2 中建立具有屬性路由的 REST API

Web API 2 支援新的路由類型,稱為屬性路由。 如需屬性路由的一般概觀,請參閱 Web API 2 中的屬性路由。 在本教學課程中,您將使用屬性路由來建立書籍集合的 REST API。 API 將支援下列動作:

動作 範例 URI
取得所有書籍的清單。 /api/books
依識別碼取得書籍。 /api/books/1
取得書籍的詳細資料。 /api/books/1/details
依內容類型取得書籍清單。 /api/books/fantasy
依發行日期取得書籍清單。 /api/books/date/2013-02-16 /api/books/date/2013/02/16 (替代形式)
取得特定作者的書籍清單。 /api/authors/1/books

所有方法都是唯讀的 (HTTP GET 要求)。

針對資料層,我們將使用 Entity Framework。 書籍記錄會有下列欄位:

  • 識別碼
  • 標題
  • Genre
  • 發行日期
  • 價格
  • 描述
  • AuthorID (Author 資料表的外部索引鍵)

不過,針對大部分的要求,API 會傳回此資料的子集 (標題、作者和內容類型)。 若要取得完整記錄,用戶端會要求 /api/books/{id}/details

必要條件

Visual Studio 2017 Community、Professional 或 Enterprise 版。

建立 Visual Studio 專案

從執行 Visual Studio 開始。 從 [檔案] 功能表選取 [新增],再選取 [專案]

展開 [已安裝的 >Visual C#] 類別。 在 [Visual C#] 下,選取 [Web]。 在專案範本清單中,選取 [ASP.NET Web Application (.NET Framework)]。 將專案命名為「BooksAPI」。

新建專案對話方塊的影像

New ASP.NET Web Application 對話方塊中,選取 Empty 範本。 在 [新增資料夾和核心參考] 底下,選取 [ Web API] 核取方塊。 按一下 [確定]

新 A P 點 Net Web 應用程式對話方塊的影像

這會建立針對 Web API 功能所設定的基本架構專案。

網域模型

接下來,新增領域模型的類別。 在方案總管中,以滑鼠右鍵按一下 Models 資料夾。 選取 [新增],然後選取 [類別]。 將類別命名為 Author

建立新類別的影像

將 Author.cs 中的程式碼替換為以下內容:

using System.ComponentModel.DataAnnotations;

namespace BooksAPI.Models
{
    public class Author
    {
        public int AuthorId { get; set; }
        [Required]
        public string Name { get; set; }
    }
}

現在,新增另一個名為 Book 的類別。

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BooksAPI.Models
{
    public class Book
    {
        public int BookId { get; set; }
        [Required]
        public string Title { get; set; }
        public decimal Price { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public int AuthorId { get; set; }
        [ForeignKey("AuthorId")]
        public Author Author { get; set; }
    }
}

新增 Web API 控制器

在此步驟中,我們將新增使用 Entity Framework 作為資料層的 Web API 控制器。

按 CTRL+SHIFT+B 以建置專案。 Entity Framework 會使用反映來探索模型的屬性,因此需要編譯的組件才能建立資料庫架構。

在方案總管中,以滑鼠右鍵按一下 Controllers 資料夾。 選取 [新增],然後選取 [控制器]。

新增控制器的影像

[新增 Scaffold] 對話方塊中,選取 [具有操作的 Web API 2 控制器,使用 Entity Framework]

新增 Scaffold 的影像

[新增控制器] 對話方塊的 [控制器名稱] 中,輸入「BooksController」。 選取 [使用非同步控制器動作] 核取方塊。 針對 [Model (模型)] 類別,選取 [Book (書籍)]。 (如果您沒有看到下拉式清單中所列的 Book 類別,請確定您已建置專案。然後一下 [+] 按鈕。

新增控制器對話方塊的影像

按一下 [新資料內容] 對話方塊中的 [新增]

新資料內容對話方塊的影像

按一下 [新增控制器] 對話方塊中的 [新增]。 Scaffolding 會新增名為 BooksController 的類別,以定義 API 控制器。 它也會在 Models 資料夾中新增名為 BooksAPIContext 的類別,以定義 Entity Framework 的資料內容。

新類別的影像

植入資料庫

從「工具」功能表中,選取「NuGet 套件管理員」,然後選取「套件管理員主控台」

在「套件管理員主控台」視窗中,輸入以下命令:

Add-Migration

此命令會建立 Migrations 資料夾,並新增名為 Configuration.cs 的新程式碼檔案。 開啟此檔案並將以下程式碼新增至 Configuration.Seed 方法。

protected override void Seed(BooksAPI.Models.BooksAPIContext context)
{
    context.Authors.AddOrUpdate(new Author[] {
        new Author() { AuthorId = 1, Name = "Ralls, Kim" },
        new Author() { AuthorId = 2, Name = "Corets, Eva" },
        new Author() { AuthorId = 3, Name = "Randall, Cynthia" },
        new Author() { AuthorId = 4, Name = "Thurman, Paula" }
        });

    context.Books.AddOrUpdate(new Book[] {
        new Book() { BookId = 1,  Title= "Midnight Rain", Genre = "Fantasy", 
        PublishDate = new DateTime(2000, 12, 16), AuthorId = 1, Description =
        "A former architect battles an evil sorceress.", Price = 14.95M }, 

        new Book() { BookId = 2, Title = "Maeve Ascendant", Genre = "Fantasy", 
            PublishDate = new DateTime(2000, 11, 17), AuthorId = 2, Description =
            "After the collapse of a nanotechnology society, the young" +
            "survivors lay the foundation for a new society.", Price = 12.95M },

        new Book() { BookId = 3, Title = "The Sundered Grail", Genre = "Fantasy", 
            PublishDate = new DateTime(2001, 09, 10), AuthorId = 2, Description =
            "The two daughters of Maeve battle for control of England.", Price = 12.95M },

        new Book() { BookId = 4, Title = "Lover Birds", Genre = "Romance", 
            PublishDate = new DateTime(2000, 09, 02), AuthorId = 3, Description =
            "When Carla meets Paul at an ornithology conference, tempers fly.", Price = 7.99M },

        new Book() { BookId = 5, Title = "Splish Splash", Genre = "Romance", 
            PublishDate = new DateTime(2000, 11, 02), AuthorId = 4, Description =
            "A deep sea diver finds true love 20,000 leagues beneath the sea.", Price = 6.99M},
    });
}

在「套件管理員主控台」視窗中,鍵入以下命令。

add-migration Initial

update-database

這些命令會建立本機資料庫,並叫用 Seed 方法來填入資料庫。

套件管理員主控台的影像

新增 DTO 類別

如果您現在執行應用程式,並將 GET 要求傳送至 /api/books/1,回應看起來會如下所示。 (我新增了可讀性的縮排。)

{
  "BookId": 1,
  "Title": "Midnight Rain",
  "Genre": "Fantasy",
  "PublishDate": "2000-12-16T00:00:00",
  "Description": "A former architect battles an evil sorceress.",
  "Price": 14.95,
  "AuthorId": 1,
  "Author": null
}

相反地,我希望此要求傳回欄位的子集。 此外,我希望它傳回作者的名稱,而不是作者識別碼。 為了達成此目的,我們將修改控制器方法,以傳回資料傳輸物件 (DTO) 而不是 EF 模型。 DTO 是只設計用來傳送資料的物件。

在 [方案總管] 中,以滑鼠右鍵按一下專案,然後選取 [新增] | [新增資料夾]。 將資料夾命名為「DTO」。 使用下列定義,將名為 BookDto 的類別新增至 DTO 資料夾:

namespace BooksAPI.DTOs
{
    public class BookDto
    {
        public string Title { get; set; }
        public string Author { get; set; }
        public string Genre { get; set; }
    }
}

新增另一個名為 BookDetailDto 的類別。

using System;

namespace BooksAPI.DTOs
{
    public class BookDetailDto
    {
        public string Title { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }         
        public string Author { get; set; }
    }
}

接下來,更新 BooksController 類別以傳回 BookDto 執行個體。 我們將使用 Queryable.Select 方法將執行個體投影 BookBookDto 執行個體。 以下是控制器類別的更新程式碼。

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }
        
        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

注意

我刪除了 PutBookPostBookDeleteBook 方法,因為本教學課程不需要它們。

現在,如果您執行應用程式並要求 /api/books/1,回應本文看起來應該像這樣:

{"Title":"Midnight Rain","Author":"Ralls, Kim","Genre":"Fantasy"}

新增路由屬性

接下來,我們將轉換控制器以使用屬性路由。 首先,將 RoutePrefix 屬性新增至控制器。 這個屬性會定義此控制器上所有方法的初始 URI 區段。

[RoutePrefix("api/books")]
public class BooksController : ApiController
{
    // ...

然後將 [Route] 屬性新增至控制器動作,如下所示:

[Route("")]
public IQueryable<BookDto> GetBooks()
{
    // ...
}

[Route("{id:int}")]
[ResponseType(typeof(BookDto))]
public async Task<IHttpActionResult> GetBook(int id)
{
    // ...
}

每個控制器方法的路由範本都是前置詞加上 Route 屬性中指定的字串。 針對 GetBook 方法,路由範本包含參數化字串「{id:int}」,如果 URI 區段包含整數值,則符合。

方法 路由範本 範例 URI
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

取得書籍詳細資料

若要取得書籍詳細資料,用戶端會將 GET 要求傳送至 /api/books/{id}/details,其中 {id} 是書籍的識別碼。

將下列方法新增至 BooksController 類別。

[Route("{id:int}/details")]
[ResponseType(typeof(BookDetailDto))]
public async Task<IHttpActionResult> GetBookDetail(int id)
{
    var book = await (from b in db.Books.Include(b => b.Author)
                where b.BookId == id
                select new BookDetailDto
                {
                    Title = b.Title,
                    Genre = b.Genre,
                    PublishDate = b.PublishDate,
                    Price = b.Price,
                    Description = b.Description,
                    Author = b.Author.Name
                }).FirstOrDefaultAsync();

    if (book == null)
    {
        return NotFound();
    }
    return Ok(book);
}

如果您要求 /api/books/1/details,回應看起來會像這樣:

{
  "Title": "Midnight Rain",
  "Genre": "Fantasy",
  "PublishDate": "2000-12-16T00:00:00",
  "Description": "A former architect battles an evil sorceress.",
  "Price": 14.95,
  "Author": "Ralls, Kim"
}

依內容類型取得書籍

若要取得特定內容類型的書籍清單,用戶端會將 GET 要求傳送至 /api/books/genre,其中 內容類型是內容類型 的名稱。 (例如,/api/books/fantasy)。

將下列方法新增至 BooksController

[Route("{genre}")]
public IQueryable<BookDto> GetBooksByGenre(string genre)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
        .Select(AsBookDto);
}

我們在此定義路由,其中包含 URI 範本中的 {genre} 參數。 請注意,Web API 能夠區分這兩個 URI,並將其路由至不同的方法:

/api/books/1

/api/books/fantasy

這是因為 GetBook 方法包含條件約束,表示「id」區段必須是整數值:

[Route("{id:int}")] 
public BookDto GetBook(int id)
{
    // ... 
}

如果您要求 /api/books/fantasy,回應看起來會像這樣:

[ { "Title": "Midnight Rain", "Author": "Ralls, Kim", "Genre": "Fantasy" }, { "Title": "Maeve Ascendant", "Author": "Corets, Eva", "Genre": "Fantasy" }, { "Title": "The Sundered Grail", "Author": "Corets, Eva", "Genre": "Fantasy" } ]

依作者取得書籍

若要取得特定作者的書籍清單,用戶端會將 GET 要求傳送至 /api/authors/id/books,其中 id 是作者的識別碼。

將下列方法新增至 BooksController

[Route("~/api/authors/{authorId:int}/books")]
public IQueryable<BookDto> GetBooksByAuthor(int authorId)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.AuthorId == authorId)
        .Select(AsBookDto);
}

這個範例很有趣,因為「書籍」會被視為「作者」的子資源。 此模式在 RESTful API 中相當常見。

路由範本中的 tilde (~) 會覆寫 RoutePrefix 屬性中的路由前置詞。

依發行日期取得書籍

若要依發行日期取得書籍清單,用戶端會將 GET 要求傳送至 /api/books/date/yyyy-mm-dd,其中 yyyy-mm-dd 是日期。

以下是執行此動作的一種方式:

[Route("date/{pubdate:datetime}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    return db.Books.Include(b => b.Author)
        .Where(b => DbFunctions.TruncateTime(b.PublishDate)
            == DbFunctions.TruncateTime(pubdate))
        .Select(AsBookDto);
}

{pubdate:datetime} 參數被限制為符合 DateTime 值。 這很有效,但實際上比我們想要的要寬鬆。 例如,這些 URI 也會符合路由:

/api/books/date/Thu, 01 May 2008

/api/books/date/2000-12-16T00:00:00

允許這些 URI 沒有任何問題。 不過,您可以將規則運算式條件約束新增至路由範本,以限制路由至特定格式:

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

現在,只有格式為「yyyy-mm-dd」的日期才會符合。 請注意,我們不會使用 regex 來驗證我們取得實際日期。 當 Web API 嘗試將 URI 區段轉換為 DateTime 執行個體時,就會處理。 無法轉換「2012-47-99」等無效日期,且用戶端會收到 404 錯誤。

您也可以透過新增另一個具有不同規則運算式的 [Route] 屬性來支援斜線分隔符號 (/api/books/date/yyyy/mm/dd)。

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
[Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]  // new
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

這裡有一個微妙但重要的細節。 第二個路由範本在 {pubdate} 參數開頭具有萬用字元 (*):

{*pubdate: ... }

這會告訴路由引擎 {pubdate} 應該符合其餘 URI。 根據預設,範本參數會符合單一 URI 區段。 在此情況下,我們想要 {pubdate} 跨越數個 URI 區段:

/api/books/date/2013/06/17

控制器程式碼

以下是 BooksController 類別的完整程式碼。

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    [RoutePrefix("api/books")]
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        [Route("")]
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [Route("{id:int}")]
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }

        [Route("{id:int}/details")]
        [ResponseType(typeof(BookDetailDto))]
        public async Task<IHttpActionResult> GetBookDetail(int id)
        {
            var book = await (from b in db.Books.Include(b => b.Author)
                              where b.AuthorId == id
                              select new BookDetailDto
                              {
                                  Title = b.Title,
                                  Genre = b.Genre,
                                  PublishDate = b.PublishDate,
                                  Price = b.Price,
                                  Description = b.Description,
                                  Author = b.Author.Name
                              }).FirstOrDefaultAsync();

            if (book == null)
            {
                return NotFound();
            }
            return Ok(book);
        }

        [Route("{genre}")]
        public IQueryable<BookDto> GetBooksByGenre(string genre)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
                .Select(AsBookDto);
        }

        [Route("~/api/authors/{authorId}/books")]
        public IQueryable<BookDto> GetBooksByAuthor(int authorId)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.AuthorId == authorId)
                .Select(AsBookDto);
        }

        [Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
        [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]
        public IQueryable<BookDto> GetBooks(DateTime pubdate)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => DbFunctions.TruncateTime(b.PublishDate)
                    == DbFunctions.TruncateTime(pubdate))
                .Select(AsBookDto);
        }

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

摘要

屬性路由可讓您在設計 API 的 URI 時,更有控制權和更大的彈性。