【ASP.NET Core】APIのリクエスト入力チェックをスマートに実装する【WebAPI】

こんにちは、サイオステクノロジーの佐藤 陽です。

今回は ASP.NET Core の Model Validationの機能を使い
MVCやAPIのController層にいて、ユーザーからのリクエスト内容をスマートに検証する方法をご紹介します。

はじめに

ASP.NET Core を使った MVC や WebAPI の構築の際に、Controller 層で受け取ったリクエスト内容が正確なものかどうか判断したいことがあります。
これをValidationとよびます。

例えば、「指定した ID の本のタイトルを返す 」API があったとし、以下のように定義します。

GET /api/books/{bookId}

この時、bookId は以下ルールに基づくとします。

「8 文字以上, 10 文字以下である」

この場合、Controller 層にてリクエスト内容を確認することで、不正なリクエストを弾く必要があります。

例えば

[HttpGet("{bookId}",Name = "BookById")]
public ActionResult<string> Get(string bookId)
{
    //bookIdの文字数チェック
    if(bookId.Lentth < 8 && bookId.Length > 10)
    {
        return BadRequest("Invalid Book Id");
    }

    //とりあえず適当に返す
    return "SIOS Technology";
}

こういう感じですね。
これでもやりたいことは実現できますが、もう少しスマートに書きたいですよね。

ASP.NET Core には、リクエスト内容が正しいかどうかを確認するためのValidationの仕組みが用意されています。
今回はそのValidationの仕組みについて紹介していきます!

環境構築

今回は以下の環境で実装を行います。

  • Visual Studio Code (+ C# 拡張機能)
  • .NET 6.0
  • ASP.NET Core 6.0

まずはVisualStudioCodeでASP.NET CoreのWebAPIプロジェクトを作成します。

dotnet new webapi -o validation

ビルドして、サンプルコードが実行できることを確認します

dotnet run --project validation/validation.csproj
https://localhost:7200/WeatherForecast

にアクセスして、以下のようなレスポンスが返ってくれば、下準備はOKです。

[
    {
        "date": "2023-05-02T09:32:24.2055347+09:00",
        "temperatureC": 12,
        "temperatureF": 53,
        "summary": "Bracing"
    }
    ....
]

このプロジェクトに今回、BooksController.csというファイルを作成し、ここにControllerを追加していきます。

using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
namespace validation.Controllers;

[Route("api/books")]
public class BooksController : ControllerBase
{
    public BooksController()
    {
    }
}

実装

実際にControllerを実装していきます。
今回Validationしたいリクエストの内容としては、以下の3つです。

  • パスパラメータ
  • クエリパラメータ
  • ボディパラメータ

(パラメータの違いとかは、REST APIの話になってくるので割愛します。)

パスパラメータ

これは先ほど出した例と同じですね。

先ほどと同じですが、元の例がこちらです。

[HttpGet("{bookId}",Name = "BookById")]
public ActionResult<string> Get(string bookId)
{
    //bookIdの文字数チェック
    if(bookId.Lentth < 8 && bookId.Length > 10)
    {
        return BadRequest("Invalid Book Id");
    }
    //とりあえず適当に返す
    return "SIOS Technology";
}

API.NET CoreのValidation機能を使う事で、以下のように書くことができます。

[HttpGet("{bookId}", Name = "BookById")]
public ActionResult<string> Get([StringLength(10, MinimumLength = 8)] string bookId)
{
    //とりあえず適当に返す
    return "SIOS Technology";
}

Controller 層のコード量がぐっと減り、すっきりしました。

なお今回はStringLengthというAttributeを使っていますが、他にも様々なAttributeが用意されています。
他のAttributeに関してはこちらを参照ください。

この時、仮に 7 文字の bookId を指定したリクエストを送った場合

GET /api/books/1234567

以下のようなレスポンスが返ってきます。

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-a8d863a8fc50e93aa297b47683ed7e40-939c6df69ad178ba-00",
    "errors": {
        "bookId": [
            "The field bookId must be a string with a minimum length of 8 and a maximum length of 10."
        ]
    }
}

この時注意する必要があるのが、Controller 層のクラスに[ApiController]のアトリビュートを付ける必要があることです。

[ApiController] //←これ
[Route("api/books")]
public class BookController : ControllerBase
{...

これを付けない場合、以下のようにController の中で判定処理を書く必要があります。

[HttpGet("{bookId}", Name = "BookById")]
public ActionResult<string> Get([StringLength(10, MinimumLength = 8)] string bookId)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
    //とりあえず適当に返す
    return "SIOS Technology";
}

クエリパラメータ

クエリパラメータも基本的にはパスパラメータと同じです。
例えば、出版年度をクエリパラメータでフィルターするAPIを考えます。

GET /api/books?publicationYear=2023

この時、出版年度(publicationYear)の値を

「1900年~9999年まで」

と定義する場合、以下のように書くことができます。

[HttpGet(Name = "BookByPublicationYear")]
public ActionResult<IEnumerable<string>> Get([Range(1900, 9999)] int publicationYear)
{
    //とりあえず適当に返す
    return new List<string>() { "SIOS Technology(1)", "SIOS Technology(2)", "SIOS Technology(3)" };
}

これに対して不正なリクエストを送ると、

GET /api/books?publicationYear=1000

以下のようなレスポンスが返ってきます。

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-8ac56bdd2e5087360179d3d96ad77a84-a9727972e9e40c3a-00",
    "errors": {
        "publicationYear": [
            "The field year must be between 1900 and 9999."
        ]
    }
}

ボディパラメータ

最後にボディパラメータです。
これはこれまでと少し毛色が違い、ASP.NET CoreのModelBind機能との絡みも出てきます。

例えば、本を新規に登録するような以下のようなAPIを考えます

POST /api/books

<Reqeust Body>

{
    "title": "SIOS Technology(4)",
    "author": {
        "name": "SIOS Technology",
        "email": "sios@sios.com",
        "url" : "https://sios.com"
    },
    "publishedYear": 2023,
}

ASP.NET Coreでは、このようなボディパラメータのリクエストを受け取る場合
あらかじめRequestModelを定義しておくことで、自動でバインドしてくれます。

例えば、RequestModelクラスとして、以下のように定義します。

using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace validation.Models;

public class CreateBooksModel
{
    public string Title { get; set; }
    public CreateAuthorModel Author { get; set; }
    public int PublicationYear { get; set; }
}

public class CreateAuthorModel
{
    public string Name { get; set; }
    public string Email { get; set; }
    public string Url { get; set; }
}

Controllerとしては以下のように書きます。

[HttpPost(Name = "CreateBooks")]
public ActionResult<string> Create([FromBody] CreateBooksModel model)
{
    return model.Title;
}

このように書くことで、リクエストの内容がmodelの変数にバインドされ、Controller内で扱うことができます。
もちろん

[HttpPost(Name = "CreateBooks")]
public ActionResult<string> Create([FromBody] CreateBooksModel model)
{
    if(model.Title.length == ....)
    if(model.Author == ....)
    if(model.PublicationYear == ....)
}

と自力で判定していってもいいですが、今回はValidation機能を使います。

パスパラメータ・クエリパラメータではControllerクラス内で判定内容を定義していましたが
ボディーパラメータの場合は、Modelクラスの方で判定内容を定義します。

今回のリクエストの判定条件として

  • 本のタイトル, 著者の名前, 出版年度は必須
  • 本のタイトルは16文字以内
  • 著者の名前は8文字以内
  • 出版年度は1900年以降(~9999年まで)

とします。
(この後のValidationのため、厳しめの条件にしてます。)

この場合、以下のようにModelクラスにAttributeを付けます。

public class CreateBooksModel
{
    [Required]
    [StringLength(16)]
    public string Title { get; set; }
    [Required]
    public CreateAuthorModel Author { get; set; }
    [Required]
    [Range(1900, 9999)]
    public int PublicationYear { get; set; }
}


public class CreateAuthorModel
{
    [Required]
    [StringLength(8)]
    public string Name { get; set; }
    [EmailAddress]
    public string Email { get; set; }
    [Url]
    public string Url { get; set; }
}

この時、色々とルールを無視したRequestを送ってみたいと思います。

{
  "title": "1234567890123456790",
  "author": {
    "email": "email_address",
    "url": "url_endpoint"
  },
  "publicationYear": 1000
}

レスポンスとしては以下の内容が返ってきました。

以下のことが怒られていますね。

  • Titleの長さが不正
  • 出版年度の値が不正
  • URL形式が不正
  • 著者名がRequiredなのに送られていない
  • Emailの形式が不正
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-173e4b012abd24c502cb4469143688e6-6e43d1fee5778abb-00",
    "errors": {
        "Title": [
            "The field Title must be a string with a maximum length of 16."
        ],
        "PublicationYear": [
            "The field PublicationYear must be between 1900 and 9999."
        ],
        "Author.Url": [
            "The Url field is not a valid fully-qualified http, https, or ftp URL."
        ],
        "Author.Name": [
            "The Name field is required."
        ],
        "Author.Email": [
            "The Email field is not a valid e-mail address."
        ]
    }
}

このようにModelを定義する時にAttributeを加えることで、Controller層では判定を行う必要がなくなります。
リクエストの内容がどういったものかはBindされるModelクラスで定義されるため、責務的にもこちらの方が自然ですね。

レスポンス内容のカスタマイズ

今回レスポンスとして返される内容としては、ASP.NET Core側で自動生成されるものです。
この内容をカスタマイズしたいことも考えられますね。

この場合

  • エラーメッセージを変更する
  • レスポンス全体の構成を変える

の2つをご紹介します。

エラーメッセージを変更する

こちらは割と簡単です。
先ほど判定内容を記述していたAttributeにErrorMessageパラメータを追加します。

例えば、Getメソッドの場合は、以下のようになります。

[HttpGet("{bookId}", Name = "BookById")]
public ActionResult<string> Get([StringLength(10, ErrorMessage = "Invalid Book Id", MinimumLength = 8)] string bookId)
{
    if (!ModelState.IsValid)
    {
        return InvalidRequestResponse(ModelState);
    }
    //とりあえず適当に返す
    return "SIOS Technology";
}

この時、不正な値を送ると、以下のようなレスポンスが返ります。

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-5c52cf3aa0242117ad3391b79bd7cb64-4b30b5dac5d06716-00",
    "errors": {
        "bookId": [
            "Invalid Book Id" //←指定したErrorメッセージが表示される
        ]
    }
}

レスポンス全体の構成を変える

エラーメッセージだけではなく、「レスポンスのjsonの構成自体を変えたい」といった要求もあるかと思います。

こういった場合は、判定結果を含むModelStateのインスタンスを利用して自分でレスポンスを作成します。

以下のように、不正なパラメータと、その理由だけをレスポンスとして返したいとします。

{
    "Title": "The field Title must be a string with a maximum length of 16.",
    "PublicationYear": "The field PublicationYear must be between 1900 and 9999.",
    "Author.Url": "The Url field is not a valid fully-qualified http, https, or ftp URL.",
    "Author.Name": "The Name field is required.",
    "Author.Email": "The Email field is not a valid e-mail address."
}

実装の流れとしては

  1.  レスポンス作成のためのメソッド実装
  2.  Controller層の中で呼び出し

です。

レスポンス作成のためのメソッド実装

以下のようなメソッドを作成します。

private ActionResult<string> InvalidRequestResponse(ModelStateDictionary modelSate)
{
    Dictionary<string, string> errors = new();
    foreach (KeyValuePair<string, ModelStateEntry> kvp in modelSate)
    {
        errors.Add(kvp.Key, kvp.Value.Errors[0].ErrorMessage);
    }
    return BadRequest(JsonConvert.SerializeObject(errors));
}

ModelStateはModelStateDictionaryクラスのインスタンスであり、各エラー情報が格納されています。

そのため、これを自力で取り出してエラーメッセージを作成します。

(今回は検証のためErrorが1つしか存在しないと仮定して実装しています)

Controller層の中で呼び出し

これをControllerで呼び出します。

[HttpPost(Name = "CreateBooks")]
public ActionResult<string> Create([FromBody] CreateBooksModel model)
{
    if (!ModelState.IsValid){
        return InvalidRequestResponse(ModelState);
    }
    return model.Title;
}

なお、Controllerクラスに[ApiController]のAttributeを付けている場合は、
ModelState.IsValidの判定を行う前に自動で400番が返されます。

手動で返したい場合は

のいずれかの対応をしてください。

なお、今回は単一のController内でInvalidRequestResponseの関数を実装していますが
複数のControllerで共通の処理を実装したい場合は、共通のControllerクラスを実装してそこで実装する方法が考えられます。

まとめ

今回はASP.NET CoreでのValidationの方法について紹介しました。
今回紹介した方法を使う事で、Controller層の実装コードからValidation部分を切り離すことができました。
より責務が明確になって、コードの質が上がりますね。

なお、今回は組み込みの属性を利用しましたが、カスタムで作成した判定を適用出来たりもします。
この辺りもまた触ってご紹介したいと思います。

ではまた!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です