🪸

【Azure Functions】Request Body のバリデーションを Extensions でやってみた話

2024/06/19に公開

まえがき

Java上がりの最近 C#(.NET) を始めた人間です。
HttpTriggerの入力項目の検証がしたいのでいろいろCopilotに聞いたりいろいろしてみました。
最初に思いつくのが、SpringBoot の Bean Validation 的なやつ、.NET にはないんかと探しに探し、ASP.NET core にはもちろんあるけど、Azure Functions には未だに実装されてないのか、いろんな人がいろんなことをやってて、結局どれがいいん????ってなったので、一番簡単にできそうなやつを実装してみました。

方針

  • リクエストボディは Json を想定
  • SpringBoot の Bean Validation っぽく、モデルに制約を書いたものを検証できるようにする
    • 個別に カスタムValidator は作らない(複数項目の検証のときは作るけど)
    • ASP.NET だと DataAnnotations を使った検証が近いみたい
  • バリデーションエラーのレスポンスは、400 でせっかくなので RFC7807 に則った形にしたい

大本の記事

しばやんさんの記事を漁りまくって、最後に書いてた牛尾さんのブログにたどり着きました。
https://tsuyoshiushio.medium.com/how-to-validate-request-for-azure-functions-e6488c028a41

少し記事も古いので、一部書き方を変えたのと、レスポンスボディを生成するところも Extentions にしてみました。

環境

  • .NET 8.0

ソースコード

対象のモデル

public class CreateRequest
{
    [Required(ErrorMessage = "Topic is required.")]
    [StringLength(100, ErrorMessage = "Topic must be less than 100 characters.")]
    public string? Topic { get; set; }

    [Required(ErrorMessage = "Text is required.")]
    [StringLength(2000, ErrorMessage = "Text must be less than 2000 characters.")]
    public string? Text { get; set; }
}

こんな感じの適当なモデルです。

HttpResponseBody

対象のモデルのバリデーション結果を持つクラスを作成します。

public class HttpResponseBody<T>
{
    public bool IsValid { get; set; }
    public T? Value { get; set; }

    public IEnumerable<ValidationResult>? ValidationResults { get; set; }
}

HttpRequestExtensions

Validator.TryValidateObject を毎回 Function 内に書くのはイケてないよね、ってことで、HttpRequestExtentions に書きます。と牛尾さんの記事で書いてるのでそのまま書きます。
ReadFromJsonAsync ってメソッドが今はあるので、そっちに書き換えてます。

public static class HttpRequestExtensions
{
    public static async Task<HttpResponseBody<T>> GetBodyAsync<T>(this HttpRequest request)
    {
        var body = new HttpResponseBody<T>();
        body.Value = await request.ReadFromJsonAsync<T>();

        var results = new List<ValidationResult>();
        body.IsValid = Validator.TryValidateObject(body.Value, new ValidationContext(body.Value, null, null), results, true);
        body.ValidationResults = results;
        return body;
    }
}

ValidationExtensions

ついでにバリデーションでエラーになったときに、Function毎にレスポンスを書くのがめんどくさすぎるので、 RFC7808 の形式にする Extentions を作りました。

/// <summary>
/// validation error を RFC7807 に従って返すための拡張メソッド
/// </summary>
public static class ValidationExtensions
{
    public static ProblemDetails ToProblemDetails(this IEnumerable<ValidationResult> validationResults, HttpRequest req)
    {
        var errors = validationResults
            .GroupBy(v => v.MemberNames.FirstOrDefault() ?? "General", v => v.ErrorMessage)
            .ToDictionary(g => g.Key, g => g.ToArray());

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status400BadRequest,
            Title = "Request validation error",
            Type = "about:blank",
            Detail = "One or more validation errors occurred.",
            Instance = req.Path
        };

        // エラーメッセージをExtensionsに追加
        foreach (var error in errors)
        {
            problemDetails.Extensions.Add(error.Key, error.Value);
        }

        return problemDetails;
    }
}

使い方

受け取ったリクエストを GetBodyAsync でパース&バリデートして、エラーがある場合は、レスポンスオブジェクトを作成して、400 で返すようにします。
これで、Function毎に書くコードは少なくなったかなと思われ。(本当はFilter か Middleware かで処理共通化できるのかもしれないですがまた今度)

    [Function("CreatePosts")]
    public async Task<IActionResult> CreatePosts([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "posts")] HttpRequest req)
    {
        // HttpRequestExtensions で定義した GetBodyAsync を呼ぶ
        var requestBody = await req.GetBodyAsync<CreateRequest>();
        // validate
        if (!requestBody.IsValid)
        {
            // ValidationExtensions の ToProblemDetails を呼ぶ
            var problemDetails = requestBody.ValidationResults.ToProblemDetails(req);
            return new BadRequestObjectResult(problemDetails);
        }
        var result = await _createPostService.CreatePost(requestBody.Value);

        return new OkObjectResult(result);
    }

あとがき

Extentions、C# 特有の記述方法っぽくて面白いなー便利だなーって思ったのが今回の学びです。
.NET8 が使えるようになって、もっといい書き方があるかもしれません。

ちなみに、モデルバインディングはしばやんさんの記事見ながら試してみましたが、バージョン古くて書き換え方がわからず挫折です。
https://blog.shibayan.jp/entry/20181227/1545885493
モデルバインディングができると、バリデーションはこのやり方じゃなくなるのかな。

C# も .NET もまだまだ全然わからんし、ASP.NETベースなのに、Azure Functions で使えないとかどういうこと????
とか思いながら、鍛錬に励みたいと思います。

Discussion