在 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/my
依發行日期取得書籍清單。 /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 Edition。

建立 Visual Studio 專案

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

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

新專案對話方塊的影像

在 [ 新增 ASP.NET Web 應用程式 ] 對話方塊中,選取 [空白 ] 範本。 在 [新增資料夾和核心參考] 底下,選取 [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 ] 對話方塊中, 使用 Entity Framework 選取具有動作的 Web API 2 控制器

新增 Scaffold 的影像

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

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

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

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

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

新類別的影像

植入資料庫

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

在 [Package Manager Console] 視窗中,輸入下列命令:

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/quiet,回應看起來會像這樣:

[ { "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 中相當常見。

路由範本中的波浪線 (~) 會覆寫 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 錯誤。

您也可以新增另一個具有不同 RegEx 的[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 時有更多控制權和更大的彈性。