💭

Blazor WebAssemblyにおける入力値検証について

2023/03/18に公開

一般的に入力値検証はクライアント側、サーバ側双方で行われることが一般的である。
クライアント側で入力値検証が行われることで入力時に入力値検証が行われることでユーザ利便性を高めることができます。
サーバ側で入力値検証が行われることで、アプリケーションそのものの仕様であったり、セキュリティを担保することができます。

Blazor WebAssemblyではクライアント側では入力値検証のためのフォームとモデルの属性により検証を行います。
一方、サーバ側ではASP.NET Core WebAPIを使用することが一般的ですがこちらではApiController属性とモデルの属性によりモデル検証を行います。モデル検証エラーが発生するとHTTP400応答が自動的にトリガーされます。

具体的なコード例を見ていきましょう。

Index.razor
@page "/"
@inject HttpClient http;
@using DotnetLab202303Hosted.Shared;
<h1>サンプル1</h1>
<EditForm Model="@parson" OnValidSubmit="@ValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />
    <p>
        <label>
            名前:
            <InputText @bind-Value="parson.Name" />
        </label>
    </p>
    <p>
        <label>
            誕生日:
            <InputDate @bind-Value="parson.Birthday" />
        </label>
    </p>
    <button type="submit">送信</button>
</EditForm>
@ErrorMessage
@if (parsons != null) {
<table class="table table-striped table-bordered">
    <thead class="table-primary">
        <tr>
            <td>名前</td>
            <td>誕生日</td>
        </tr>
    </thead>
    <tbody>
    @foreach(var items in parsons){
    <tr>
        <td>@items.Name</td>
        <td>@items.Birthday</td>
    </tr>
    }
        </tbody>
</table>
}
@code {
    private Parson parson = new();

    private IEnumerable<Parson>? parsons;
    private string? ErrorMessage;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            parsons = await http.GetFromJsonAsync<IEnumerable<Parson>>("api/ParsonItems");
        }
        catch(Exception ex)
        {
            ErrorMessage = ex.Message;
        }
    }

    private async Task ValidSubmit()
    {
        await http.PostAsJsonAsync("api/ParsonItems", parson);
        parsons = await http.GetFromJsonAsync<IEnumerable<Parson>>("api/ParsonItems");
    }
}
Parson.cs
    public class Parson
    {
        public long Id { get; set; }
        [Required(ErrorMessage = "名前は必ず入力してください。")]
        public string Name { get; set; } = string.Empty;
        [BirthdayValidator(ErrorMessage = "誕生日は今日以前の日付を入力してください。")]
        public DateTime Birthday { get; set; } = DateTime.Now;
        [ValidateComplexType]
        public List<Skill> Skills { get; set; } = new();
    }

    public class Skill
    {
        [Required(ErrorMessage = "スキル名は必ず入力してください。")]
        [StringLength(30)]
        public string SkillName { get; set; } = string.Empty;
        [Range(1, 5, ErrorMessage = "レア度は1から5の数値を入力してください。")]
        public int Rare { get; set; }
    }

ParsonItemsControllers.cs
    [Route("api/[controller]")]
    [ApiController]
    public class ParsonItemsController : ControllerBase
    {
        private readonly Context _context;
        private readonly ILogger<ParsonItemsController> _logger;

        public ParsonItemsController(Context context, ILogger<ParsonItemsController> logger)
        {
            _context = context;
            _logger = logger;
        }

        // GET: api/PersonItems
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Parson>>> GetPersonItems()
        {
            if (_context.PersonItems == null)
            {
                return NotFound();
            }
            List<Parson> parsons = new();
            var parsonItems = await _context.PersonItems.ToListAsync();
            foreach (var item in parsonItems)
            {
                Parson parson = new();
                parson.Id = item.Id;
                parson.Name = item.Name;
                parson.Birthday = item.Birthday;
                _logger.LogInformation(item.SkillId.ToString());
                var skills = await _context.SkillItems.Where(x => x.SkillId == item.SkillId).ToListAsync();
                foreach(var skill in skills)
                {
                    Skill skill1 = new();
                    skill1.SkillName = skill.SkillName;
                    skill1.Rare = skill.Rare;
                    parson.Skills.Add(skill1);
                }
                parsons.Add(parson);
            }
            return Ok(parsons);
        }

        // GET: api/PersonItems/5
        [HttpGet("{id}")]
        public async Task<ActionResult<Parson>> GetPersonItem(long id)
        {
            if (_context.PersonItems == null)
            {
                return NotFound();
            }
            var personItem = await _context.PersonItems.FindAsync(id);

            if (personItem == null)
            {
                return NotFound();
            }
            Parson parson = new();
            parson.Id = personItem.Id;
            parson.Name = personItem.Name;
            parson.Birthday = personItem.Birthday;
            var skillItems = await _context.SkillItems.Where(x => x.SkillId == personItem.SkillId).ToListAsync();
            foreach (var skillItem in skillItems) 
            { 
                Skill skill = new();
                skill.SkillName = skillItem.SkillName;
                skill.Rare = skillItem.Rare;
                parson.Skills.Add(skill);
            }

            return parson;
        }


        // POST: api/PersonItems
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<Parson>> PostPersonItem(Parson parson)
        {
            if (_context.PersonItems == null)
            {
                return Problem("Entity set 'Context.PersonItems'  is null.");
            }
            ParsonItem parsonItem = new();
            parsonItem.Name = parson.Name;
            parsonItem.Birthday = parson.Birthday;
            long skillId = _context.PersonItems.OrderByDescending(x => x.Id).Select(x => x.Id).ToList().FirstOrDefault();
            parsonItem.SkillId = skillId;
            var item = _context.PersonItems.Add(parsonItem);
            //await _context.SaveChangesAsync();
            //item.Entity.SkillId = item.Entity.Id;
            _logger.LogInformation(item.Entity.Id.ToString());
            _logger.LogInformation(parsonItem.Id.ToString());
            _logger.LogInformation("skillId" + skillId.ToString());
            foreach (var skill in parson.Skills)
            {
                SkillItem skillItem = new();
                skillItem.SkillId = skillId;
                skillItem.SkillName = skill.SkillName;
                skillItem.Rare = skill.Rare;
                _context.SkillItems.Add(skillItem);
            }


            await _context.SaveChangesAsync();

            return CreatedAtAction("GetPersonItem", new { id = parson.Id }, parson);
        }

    }

BirthdayValidator.cs
    public class BirthdayValidator : ValidationAttribute
    {
        protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
        {
            if (value is DateTime)
            {
                var dateValue = (DateTime)value;
                if (dateValue < DateTime.Now)
                {
                    return ValidationResult.Success;
                }
                else
                {
                    return new ValidationResult(ErrorMessage);
                }
            }
            throw new ArgumentException("引数の型が違います", nameof(value));
        }

    }

上記のような場合、名前が必須入力に誕生日は現在日付以前の日付が入力可能になります。
誕生日の現在日付以前という入力値検証については組み込みのデータ検証ではできないのでカスタム検証ロジックで実装しています。それが「BirthdayValidator.cs」です。
ValidationAttributeを継承してVaridationResultメソッドをオーバーライドし実装します。使用するときは、このクラス名を属性に記述します。

また、「Parson.cs」のように複合型を含むモデルの場合はクライアント側の検証がうまくいきません。
このような場合には、フォームを以下のようにします。


@inject HttpClient http;
@using DotnetLab202303Hosted.Shared;
@using System.Net;
<h1>サンプル3</h1>
<EditForm Model="@parson" OnValidSubmit="@ValidSubmit">
    <ObjectGraphDataAnnotationsValidator />
    <ValidationSummary />
    <p>
        <label>
            名前:
            <InputText @bind-Value="parson.Name" />
        </label>
    </p>
    <p>
        <label>
            誕生日:
            <InputDate @bind-Value="parson.Birthday" />
        </label>
    </p>
    @foreach (var skill in parson.Skills)
    {
        <p>
            <label>
                スキル名:
                <InputText @bind-Value="skill.SkillName" />
            </label>
        </p>
        <p>
            <label>
                レア度:
                <InputNumber @bind-Value="skill.Rare" />
            </label>
        </p>
    }
    <button type="button" @onclick="AddForm">増やす</button>
    <button type="submit">送信</button>
</EditForm>
@ErrorMessage
@if (parsons != null)
{
    <table class="table table-striped table-bordered">
        <thead class="table-primary">
            <tr>
                <td>名前</td>
                <td>誕生日</td>
                <td>スキル</td>
                <td>レア度</td>
            </tr>
        </thead>
        <tbody>
            @foreach (var items in parsons)
            {
                <tr>

                    <td>@items.Name</td>
                    <td>@items.Birthday</td>
                    <td>
                        @foreach (var skill in items.Skills)
                        {
                            @skill.SkillName

                            <br />
                        }
                    </td>
                    <td>
                        @foreach (var skill in items.Skills)
                        {
                            @skill.Rare

                            <br />
                        }
                    </td>

                </tr>
            }
        </tbody>
    </table>
}
@code {
    private Parson parson = new();

    private IEnumerable<Parson>? parsons;
    private string? ErrorMessage;

    protected override async Task OnInitializedAsync()
    {
        parson.Skills.Add(new Skill());
        try
        {
            parsons = await http.GetFromJsonAsync<IEnumerable<Parson>>("api/ParsonItems");
        }
        catch (Exception ex)
        {
            ErrorMessage = ex.Message;
        }
    }

    private void AddForm()
    {
        parson.Skills.Add(new Skill());
    }

    private async Task ValidSubmit()
    {
        try
        {
            var res = await http.PostAsJsonAsync("api/ParsonItems", parson);
            if (res.StatusCode == HttpStatusCode.BadRequest)
            {
                ErrorMessage = "BadRequest";
            }
        }
        catch (Exception)
        {

        }
        parsons = await http.GetFromJsonAsync<IEnumerable<Parson>>("api/ParsonItems");
    }
}

通常、バリデータとしてDataAnnotationsValidatorを使用しますが、実験的なコンポーネントとしてObjectGraphDataAnnotationsValidatorが提供されています。こちらを使うとひとまずうまくいきます。あるいはFluentValidationを使用します。こちらは記述方法が大きく異なっていますので触れません。

https://speakerdeck.com/tomokusaba/blazorniokeruru-li-zhi-jian-zheng-nituite
https://github.com/tomokusaba/DotnetLab202303Hosted

Discussion