放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

Azure Functions の HttpTrigger で受け取った値に対して自在にバリデーションをする

お疲れ様です。放浪軍師です。今回は Azure Functions の Http Trigger のバリデーションについてです。Webアプリを早く作りたいのに色々と調べる事が多くて進まねーな!!!

Http Trigger のバリデーション

受け取った値を検査して、通してよいかどうかを判別することをバリデーションと呼んだりするのですが、Http Trigger で受け取った値はどうやればバリデーションを行う事ができるのかがわからなかったので調べてみました。

System.ComponentModel.DataAnnotations を使用する

一般的には以下のように System.ComponentModel.DataAnnotations を用いるようです。簡単で良いですね。

using System.ComponentModel.DataAnnotations; // ←これを追加
using System.Text.Json.Serialization;

namespace Data
{
    public class Company()
    {
        public enum CategoryDatas
        {
            Admin,
            User,
            PowerUser,
            Customer,
        }

        [JsonPropertyName("id")]
        public string? Id { get; set; }

        [Required] // ←これを追加
        [JsonPropertyName("name")]
        public string? Name { get; set; }
    }
}
using Api.Validators.Companies;
using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.ComponentModel.DataAnnotations; // 追加
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.HttpTriggers.Companies
{
    public class PostCompany
    {
        private readonly ILogger<GetCompanies> _logger;
        private readonly CosmosClient _cosmosClient;

        public PostCompany(ILogger<GetCompanies> logger, CosmosClient cosmosClient) {
            _logger = logger;
            _cosmosClient = cosmosClient;
        }

        [Function(nameof(PostCompany))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "companies")] HttpRequest req) {
            _logger.LogInformation("C# HTTP trigger function processed a post request.");

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var company = JsonSerializer.Deserialize<Company>(requestBody);
            if (company == null) {
                return new BadRequestObjectResult("Invalid request payload.");
            }

            // -- 追加 --
            var validationResults = new List<ValidationResult>();
            var validationContext = new ValidationContext(company, null, null);
            if (!Validator.TryValidateObject(company, validationContext, validationResults, true)) {
                return new BadRequestObjectResult(validationResults);
            }
            // -- ここまで --

            company.Id = Guid.NewGuid().ToString();
            company.CreatedAt = DateTime.UtcNow;

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");

            var response = await container.CreateItemAsync(company, new PartitionKey((int)company.Category));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return new OkObjectResult(company);
        }
    }
}

これだけでバリデーションを行ってくれます。レスポンスにメッセージを自動で付けてくれるので、非常に便利ですね!!!また、Required (必須) 以外にもいろいろ用意されているようです。

もっと自在にバリデーションしたい

しかし上記のやり方だと、クラスではない値 をバリデーションする場合や、トリガーによってバリデーション内容を変える なんて事が対応できないはずです。その場合どうするんだろうと思って調べてみました。…が、よさげな記事が見つからなかったので自分で考えてみました。

GitHub

前回使用したブランチを改修しました。

github.com

構成

まずディレクトリ構成を変更しました。こんな感じ。

HttpTrigers ディレクトリと Validators ディレクトリを作成し、それぞれを管理するようにしています。

Validator

Validator クラスでは、Validate メソッドにて検証したい値を渡して、その内容によってバリデーションを行い、問題がある場合は IReadOnlyList にメッセージを返します。

using Data;
using System.ComponentModel.DataAnnotations;

namespace Api.Validators.Companies
{
    public class PostCompanyValidator
    {
        public IReadOnlyList<ValidationResult> Validate(Company company) {
            var results = new List<ValidationResult>();

            if (string.IsNullOrWhiteSpace(company.Name)) {
                results.Add(new ValidationResult($"{nameof(Company.Name)} は必須です。"));
            }
            if (company.Category == null) {
                results.Add(new ValidationResult($"{nameof(Company.Category)} は必須です。"));
            }
            if (company.Name == "つけもの") {
                results.Add(new ValidationResult($"ただし{company.Name} テメーはダメだ。"));
            }
            if (company.Name == "キマリ") {
                results.Add(new ValidationResult($"{company.Name} は通さない。"));
            }

            return results;
        }
    }
}

DI コンテナに登録

前述の Validator クラスは Program.cs にて DI コンテナに登録しておきます。かなり増えそうなので自動でやってくれると嬉しいんですけどね。情報お待ちしております。

using Api.Validators.Companies;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Fluent;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services => {
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
        services.AddSingleton(provider => {
            var connectionString = Environment.GetEnvironmentVariable("CosmosDBConnection");
            var client = new CosmosClientBuilder(connectionString)
                .WithSerializerOptions(new() {
                    PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
                })
                .Build();
            return client;
        });
        services.AddSingleton<PostCompanyValidator>(); //追加
    })
    .Build();

host.Run();

HttpTrigger

コンストラクタで先ほど登録した Validator クラスを DI して Validate メソッドを使用します。レスポンスに値が含まれていれば処理を中断してバリデーション内容を返す仕組みです。

using Api.Validators.Companies;
using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.HttpTriggers.Companies
{
    public class PostCompany
    {
        private readonly ILogger<GetCompanies> _logger;
        private readonly CosmosClient _cosmosClient;
        private readonly PostCompanyValidator _validator;

        public PostCompany(ILogger<GetCompanies> logger, CosmosClient cosmosClient, PostCompanyValidator validator) { // コンストラクタで Validator を DI する
            _logger = logger;
            _cosmosClient = cosmosClient;
            _validator = validator;
        }

        [Function(nameof(PostCompany))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "companies")] HttpRequest req) {
            _logger.LogInformation("C# HTTP trigger function processed a post request.");

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var company = JsonSerializer.Deserialize<Company>(requestBody);
            if (company == null) {
                return new BadRequestObjectResult("Invalid request payload.");
            }

            // -- 追加 --
            var validationResults = _validator.Validate(company);
            if (validationResults.Any()) {
                return new BadRequestObjectResult(validationResults);
            }
            // -- ここまで --

            company.Id = Guid.NewGuid().ToString();
            company.CreatedAt = DateTime.UtcNow;

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");

            var response = await container.CreateItemAsync(company, new PartitionKey((int)company.Category));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return new OkObjectResult(company);
        }
    }
}

動作確認

こうすることによりトリガー別にバリデーションを自在に扱う事ができました。

単体テスト

ついでなので、validator の単体テストも書いてみましょう。

using Api.Validators.Companies;
using Data;

namespace Test.Api.Validators.Companies
{
    [TestClass]
    public class PostCompanyValidatorTest
    {
        private PostCompanyValidator _validator = new();

        [TestInitialize]
        public void Setup() {

        }

        [TestMethod]
        public void Validate_正常() {
            var company = new Company { Name = "Company", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(0, results.Count);
        }

        [TestMethod]
        public void Validate_Nameが空() {
            var company = new Company { Name = string.Empty, Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_Categoryがnull() {
            var company = new Company { Name = "Company", Category = null };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_ただしつけものテメーはダメだ() {
            var company = new Company { Name = "つけもの", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_キマリは通さないi() {
            var company = new Company { Name = "キマリ", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

    }
}

簡単ですね。

まとめ

こういう事は必須だと思うんですけど、なんで記事が見当たらないんでしょうか?もしかしたら私が見つけられていないだけで、もっと良い方法があるかもしれないのですね。ご存じの方は掲示板にでもご一報ください。