ASP.NET Web API 2 で属性ルーティングを使用して REST API を作成する

Web API 2 では、属性ルーティングと呼ばれる新しい種類のルーティングがサポートされています。 属性ルーティングの一般的な概要については、「Web API 2 での属性ルーティング」を参照してください。 このチュートリアルでは、属性ルーティングを使用して、書籍のコレクション用の REST API を作成します。 この API では、次のアクションがサポートされます。

アクション URI の例
すべての書籍の一覧を取得します。 /api/books
ID で書籍を取得します。 /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 を使用します。 書籍レコードには、次のフィールドがあります。

  • ID
  • タイトル
  • Genre
  • 公開日
  • 価格
  • 説明
  • AuthorID (Authors テーブルの外部キー)

ただし、ほとんどの要求では、API はこのデータのサブセット (タイトル、作成者、ジャンル) を返します。 完全なレコードを取得するために、クライアントは /api/books/{id}/details を要求します。

前提条件

Visual Studio 2017 Community、Professional、または Enterprise Edition。

Visual Studio プロジェクトを作成する

Visual Studio の実行から始めます。 [ファイル] メニューの [新規作成] を選択し、[プロジェクト] を選択します。

[インストール済み>Visual C#] カテゴリを展開します。 [Visual C#][Web] を選択します。 プロジェクト テンプレートの一覧で、[ASP.NET 4 Web アプリケーション (.NET Framework)] を選択します。 プロジェクトに「BooksAPI」という名前を付けます。

Image of new project dialog box

[新しい ASP.NET Web アプリケーション] ダイアログで、[空] テンプレートを選択します。 [フォルダーとコア参照の追加] で、[Web API] チェック ボックスをオンにします。 OK をクリックします。

Image of new A S P dot Net web application dialog

これにより、Web API 機能用に構成されたスケルトン プロジェクトが作成されます。

ドメイン モデル

次に、ドメイン モデルのクラスを追加します。 ソリューション エクスプローラーで、[モデル] フォルダーを右クリックします。 [追加] を選択し、[クラス] を選択します。 クラスに Author という名前を付けます。

Image of create new class

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 ではリフレクションを使用してモデルのプロパティを検出するため、データベース スキーマを作成するにはコンパイル済みアセンブリが必要です。

ソリューション エクスプローラーで、[コントローラー] フォルダーを右クリックします。 [追加] を選択し、[コントローラー] を選択します。

Image of add controller

[スキャフォールディングの追加] ダイアログで、[Entity Framework を使用したアクションがある Web API 2 コントローラー] を選択します。

Image of add scaffold

[コントローラーの追加] ダイアログで、[コントローラー名] に「BooksController」と入力します。 [Use async controller actions]\(非同期コントローラー アクションを使用する\) チェック ボックスをオンにします。 [モデル クラス] で、[書籍] を選択します。 (ドロップダウンに Book クラスがされない場合は、プロジェクトをビルドしたことを確認してください)。次に、[+] ボタンをクリックします。

Image of add controller dialog box

[新しいデータ コンテキスト] ダイアログで [追加] をクリックします。

Image of new data context dialog box

[コントローラーの追加] ダイアログで、[追加] をクリックします。 スキャフォールディングでは、API コントローラーを定義する BooksController という名前のクラスが追加されます。 また、Entity Framework のデータ コンテキストを定義する Models フォルダーに BooksAPIContext という名前のクラスも追加します。

Image of new classes

データベースのシード

[ツール] メニューの [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 メソッドを呼び出してデータベースを設定します。

Image of Package Manager Console

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
}

代わりに、この要求でフィールドのサブセットを返すようにします。 また、作成者 ID ではなく、作成者の名前を返すようにします。 これを実現するには、EF モデルではなくデータ転送オブジェクト (DTO) を返すようにコントローラー メソッドを変更します。 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 メソッドを使用して、Book インスタンスを BookDto インスタンスに投影します。 コントローラー クラスの更新されたコードを次に示します。

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);
        }
    }
}

Note

このチュートリアルでは必要ないため、PutBookPostBook、および DeleteBook メソッドを削除しました。

アプリケーションを実行し、/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} は書籍の 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 に送信します。ここで、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 では、これら 2 つの 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 は作成者の 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);
}

"books" は "authors" の子リソースとして扱われるため、これは興味深い例です。 このパターンは、RESTful API で非常に一般的です。

ルート テンプレートのチルダ (~) は、RoutePrefix 属性のルート プレフィックスをオーバーライドします。

発行日別に書籍を取得する

発行日別に書籍の一覧を取得するために、クライアントは GET 要求を /api/books/date/yyyy-mm-dd に送信します。ここで 、yyyy-mm-dd は日付です。

これを行う 1 つの方法を次に示します。

[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" という形式の日付のみが一致するようになりました。 正規表現を使用して、取得した値が実際の日付であることを検証していないことに注意してください。 これは、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)
{
    // ...
}

ここには微妙ですが重要な詳細があります。 2 番目のルート テンプレートには、{pubdate} パラメーターの先頭にワイルドカード文字 (*) があります。

{*pubdate: ... }

これにより、{pubdate} が残りの URI と一致する必要があることをルーティング エンジンに指示します。 既定では、テンプレート パラメーターは 1 つの 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 を設計する際の制御と柔軟性が向上します。