これは OUCC Advent Calendar 2022 の17日目の記事です。 ここではTodoアプリのバックエンドを想定して簡単な REST APIを作ってみようと思います
目次
プロジェクトを作る
Visual Studioで ASP.NET Core Web API のプロジェクトテンプレートを選択して作成します。
実行するとhttps://localhost:<port>/swagger/index.htmlが自動的に開き最初からある WeatherForecast API が表示されます。ここで<port>ランダムに選択されたポート番号です。よく見るSwaggerページだと思います。Try it out で試しに実行することもできます。
サンプルデータ用のサービスを作成する
まず次のようなTodoItemクラスを作成します。
public class TodoItem
{
public int Id { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public DateTime DueDate { get; set; }
}
つぎにそれを扱うITodoItemContainerを作ります。実装は少し長くなったので下の方に書いておきます。単純にList<TodoItem>を用意してそれに足したり抜いたりしているだけです。
public interface ITodoItemContainer
{
IEnumerable<TodoItem> GetTodoItems();
TodoItem? AddTodoItem(TodoItem item);
bool RemoveTodoItem(int id);
bool UpdateTodoItem(TodoItem item);
}
ASP.NET Core は依存性注入(DI)を採用しているのでITodoItemContainerを API Controller で使うためにはDIコンテナに登録する必要があります。 Program.cs でbuilder.Build();が呼び出されている箇所より上でbuilder.Services.AddSingletonを呼び出せば登録することができます。
今回は次のコードをを追加しました。
builder.Services.AddSingleton<ITodoItemContainer, TodoItemContainer>();
API コントローラーの作成
API コントローラーはControllerBaseを継承して作ります。これを継承することで応答に便利な関数を利用できます。
[ApiController]
public class TodoItemsController : ControllerBase
ApiCotroller属性をつけると以下のようなAPIの動作を有効化できます。
- 属性ルーティング要件
- 自動的な HTTP 400 応答
- バインディング ソース パラメーター推論
- マルチパート/フォーム データ要求の推論
- エラー状態コードに関する問題の詳細
以下のように API コントローラーにメソッドを定義することでアクションを設定できます。ApiController属性を付けているとHttpGet属性なしでもメソッド名にGET POST PUT DELETEが含まれていると自動的にHTTPメソッドとして登録されますが、Swaggerが認識できないのでHttpGet属性をつけるほうが良いです。
[HttpGet]
public ActionResult<TodoItem> GetTodo() { }
ルーティング
ルーティングはRoute属性を用いてコントローラーまたはアクションに設定できます。ASP.NET Core のルーティングは大文字小文字の区別がありません。api/todoでもApi/Todoでも同じものとして扱われます。
コントローラにRoute属性を使うとそのコントローラーでのデフォルトのルートが設定されます。[controller]はコントローラーの名前から自動的に Controller の部分を取り除いた文字列が入れられます。
アクション、つまりメソッドにRoute属性を使うとそのメソッドだけにルーティングを設定できます。このとき、/または~/で始めるとコントローラーのルートテンプレートと結合されません。
また HttpGet などの HTTP 動詞テンプレートでもルーティングは設定できます。 HTTP 動詞テンプレートを使うとその HTTP 動詞だけを受け付けるように制限されるので REST API を作るなら全て HTTP 動詞テンプレートを使うこと方が良いです。 HTTP 動詞テンプレートは以下の6つです。
[HttpGet][HttpPost][HttpPut][HttpDelete][HttpHead][HttpPatch]
[Route("api/[controller]")]
[ApiController]
public class TodoItemController : ControllerBase
{
[HttpGet] // api/TodoItem
public ActionResult<TodoItem> GetTodo() { }
[HttpGet]
[Route("todo")] // api/TodoItem/todo
public ActionResult<TodoItem> GetTodo() { }
[HttpGet("todo")] // api/TodoItem/todo
public ActionResult<TodoItem> GetTodo() { }
[HttpGet("/todo")] // todo
public ActionResult<TodoItem> GetTodo() { }
}
メソッドの引数
メソッドの引数はURLパスから受け取ったりリクエスト本文から受け取ったりできます。
URLパスから情報を受け取る
Route属性やHttpGet属性などで{id}のように書くことで、引数idにその値を受け取ることができます。また、{id?}とすることで省略することができます。省略された場合引数にはデフォルトの値が入ります。
[Route("api/[controller]")]
[ApiController]
public class TodoItemController : ControllerBase
{
// api/TodoItem/1 => id = 1
// api/TodoItem => id = 0
[HttpGet("{id?}")]
public ActionResult<TodoItem> GetTodo(int id) { }
}
URLクエリパラメータやリクエスト本文から受け取る
それぞれ以下の属性を引数につけることでリクエストから受け取ることができます
FromQueryリクエストのクエリパラメーターFromBodyリクエスト本文FromFormリクエスト本文内のフォームデータFromHeaderリクエストヘッダー
FromBodyはSystem.Text.Jsonを使って自動的にパースしてくれます。
[Route("api/[controller]")]
[ApiController]
public class TodoItemController : ControllerBase
{
[HttpPost]
public ActionResult<TodoItem> CreateTodo([FromQuery] int id, [FromQuery] string title) { }
[HttpPost]
public ActionResult<TodoItem> CreateTodo([FromBody] TodoItem todo) { }
}
メソッドの返り値
メソッドの返り値にはActionResult<T>を指定しておけば暗黙的なT=>ActionResult<T>のキャストが実装されているのでとりあえずは困らにと思います。型が指定できないとき、例えば匿名型を使用するときなどはActionResultを使えばよいです。
そのまま値を返しても問題ありませんが、ControllerBaseに実装されているメソッドを利用することで一緒にStatusCodeを指定することもできます。よく使われそうなものをいかに列挙しておきます。
Ok(Object)HTTP200 OKを返すオブジェクトを生成する。 引数にはアクションの返り値にしたいオブジェクトを指定する。Created(String, Object)HTTP201 Createdを返すオブジェクトを生成する。 第1引数にはコンテンツが作成された URI を、第2引数にはアクションの返り値にしたいオブジェクトを指定する。CreatedAtRoute(String, Object, Object)HTTP201 Createdを返すオブジェクトを生成する。 第1引数には作成されたコンテンツを返すアクションの名前を、第2引数にはそのアクションのURLの生成に使用するルートデータ。第3引数にはアクションの返り値にしたいオブジェクトを指定する。NotFound()HTTP404 Not Foundを返すオブジェクトを生成する。
もっと見る場合は ControllerBase クラスのドキュメントを参照してください。
[Route("api/[controller]")]
[ApiController]
public class TodoItemController : ControllerBase
{
private ITodoItemContainer _todoItemContainer;
public TodoItemController(ITodoItemContainer todoItemContainer) {
_todoItemContainer = todoItemContainer;
}
[HttpGet("{id}")]
public ActionResult<TodoItem> GetTodo(int id) {
var todo = _todoItemContainer.GetTodoItems().FirstOrDefault(t => t.Id == id);
return todo is null ? NotFound() : Ok(todo);
}
[HttpPost]
public ActionResult<TodoItem> AddTodo([FromBody] TodoItem item) {
var todo = _todoItemContainer.AddTodoItem(item);
return todo is null ? BadRequest() : CreatedAtAction(nameof(GetTodo), new { Id = todo.Id }, todo);
}
}
完成
他にPUT DELETEメソッドを追加して最終的に次のようになりました。
TodoItemControllerの実装
[Route("api/[controller]")]
[ApiController]
public class TodoItemController : ControllerBase
{
private ITodoItemContainer _todoItemContainer;
public TodoItemController(ITodoItemContainer todoItemContainer) {
_todoItemContainer = todoItemContainer;
}
[HttpGet]
public ActionResult<TodoItem[]> GetTodoList() {
return Ok(_todoItemContainer.GetTodoItems().ToArray());
}
[HttpGet("{id}")]
public ActionResult<TodoItem> GetTodo(int id) {
var todo = _todoItemContainer.GetTodoItems().FirstOrDefault(t => t.Id == id);
return todo is null ? NotFound() : Ok(todo);
}
[HttpPost]
public ActionResult<TodoItem> AddTodo([FromBody] TodoItem item) {
var todo = _todoItemContainer.AddTodoItem(item);
return todo is null ? BadRequest() : CreatedAtAction(nameof(GetTodo), new { Id = todo.Id }, todo);
}
[HttpPut]
public ActionResult<TodoItem> UpdateTodo([FromBody] TodoItem item) {
var result = _todoItemContainer.UpdateTodoItem(item);
return result ? Ok(item) : BadRequest();
}
[HttpDelete("{id}")]
public ActionResult DeleteTodo(int id) {
var result = _todoItemContainer.RemoveTodoItem(id);
return result ? NoContent() : BadRequest();
}
}
ITodoItemContainerの実装
public class TodoItemContainer : ITodoItemContainer
{
private List<TodoItem> _items;
public TodoItemContainer() {
_items = Enumerable.Range(1, 10)
.Select(i => new TodoItem {
Id = i,
Title = $"todo item id:{i}",
DueDate = DateTime.Today.AddDays(1)
}).ToList();
}
public IEnumerable<TodoItem> GetTodoItems() => _items;
public TodoItem? AddTodoItem(TodoItem item) {
if (item.Id >= 1 && _items.Any(t => t.Id == item.Id))
return null;
if (item.Id < 1)
item.Id = _items.Max(t => t.Id) + 1;
_items.Add(item);
return item;
}
public bool RemoveTodoItem(int id) {
var todo = _items.FirstOrDefault(t => t.Id == id);
return todo is not null && _items.Remove(todo);
}
public bool UpdateTodoItem(TodoItem item) {
var todo = _items.FirstOrDefault(t => t.Id == item.Id);
if (todo is null)
return false;
_items[_items.IndexOf(todo)] = item;
return true;
}
}
あとがき
HTTP Status Code がどれを使えばいいのか全然わからなかったです。加えてうまくSwaggerに反映されてくれなくてよくわからなかったです…