WebSurfer's Home

Filter by APML

.NET 10 ASP.NET Core Web API で Swagger を使用(その2)

by WebSurfer 4. April 2026 12:35

.NET 10 の ASP.NET Core Web API プロジェクトにデフォルトでインストールされている Microsoft.AspNetCore.OpenApi (以降「組込み OpenAPI」と書きます) と SwaggerUI (UI だけ) を使って [Authorize] ボタンを表示し、ベアラートークン (JWT) を使っての認証に必要な操作を行うことができるよう実装してみました。

SwaggerUI

先の記事「.NET 10 ASP.NET Core Web API で Swagger を使用」では SwaggerGen + SwaggerUI を使って実装しましたが、.NET 10 で組込み OpenAPI が使える状況ではそれは本筋ではなさそうですので。

過去はどうしていたのかも知っておいた方が理解しやすいと思いますので、.NET 8 からの経緯を以下に書きます。

  1. Visual Studio のテンプレートを使って作成する .NET 8 の ASP.NET Core Web API プロジェクトでは Swashbuckle.AspNetCore がデフォルトでインストールされている。
  2. Swashbuckle の SwaggerGen が OpenAPI ドキュメントを生成し、SwaggerUI が OpenAPI ドキュメントを解釈してブラウザ上で API のテストを行うことができる UI を提供していた。(参考: Swashbuckle と ASP.NET Core を始めよう
  3. .NET 9 以降では ASP.NET Core Web API プロジェクトには組込み OpenAPI が含まれるようになった。それゆえ Swashbuckle は含まれなくなった。(参考: Announcement: Swashbuckle.AspNetCore is being removed in .NET 9
  4. SwaggerUI は、組込み OpenAPI が生成する OpenAPI ドキュメントを解釈して UI を提供できる。したがって、組込み OpenAPI + SwaggerUI (UI だけ) で .NET 8 の時と同様にブラウザ上で API のテストができる。(参考: ローカルのアドホック テストに Swagger UI を使用する
  5. ただし、 [Authorize] ボタンを表示し、ベアラートークンを使っての認証に必要な操作を行うことができるようするためには OpenAPI ドキュメントのカスタマイズが必要 (Security Scheme と Security Requirement の追加が必要)。
  6. OpenAPI ドキュメントをカスタマイズするには、組込み OpenAPI + SwaggerUI の場合、トランスフォーマーを用いる。(参考: OpenAPI ドキュメントのトランスフォーマー
  7. Program.cs でトランスフォーマーを実装し、OpenAPI ドキュメントに Security Scheme と Security Requirement を追加すれば、前者により [Authorize] ボタンの表示とトークンの設定、後者によりベアラートークンの送信ができるようになる。

ということで、下の画像の通り、組込み OpenAPI (Microsoft.AspNetCore.OpenApi 10.0.5) と Swashbuckle.AspNetCore.SwaggerUI 10.1.7 をインストールし、[Authorize] ボタンを表示して認証に必要な操作を行うことができるよう実装してみました。

NuGet パッケージ

トランスフォーマーを実装した Program.cs のコードは以下の通りです。「方法1」のコードは Securing OpenAPI and Swagger UI with OAuth in .NET 10 を参考に、「方法2」のコードはドキュメント トランスフォーマーを使用するを参考にしました。下のコード例では、「方法2」の登録をコメントアウトしてありますので、「方法1」のみが有効になっています。どちらの方法でも OpenAPI ドキュメントのカスタマイズ結果は同じになります。

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Text;

namespace WebApi3
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            builder.Services.AddControllers();

            // JWT 認証の設定
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidIssuer = builder.Configuration["Jwt:Issuer"],
                        ValidAudience = builder.Configuration["Jwt:Issuer"],
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
                    };
                });

            builder.Services.AddAuthorization();

            // OpenAPI ドキュメントのトランスフォーマー設定(方法1)
            builder.Services.AddOpenApi(options =>
            {
                options.AddDocumentTransformer((document, context, cancellationToken) =>
                {
                    // OpenAPI ドキュメントの基本情報を設定(無くても可)
                    document.Info = new OpenApiInfo
                    {
                        Title = "ASP.NET Core Web API",
                        Version = "v1",
                        Description = "ASP.NET Core Web API with JWT authentication. " +
                            "Target Framework is .NET 10. " +
                            "Built‑in OpenAPI + SwaggerUI are used."
                    };

                    // Security Scheme を追加(JWT Bearer)
                    document.Components ??= new OpenApiComponents();
                    document.Components.SecuritySchemes ??=
                        new Dictionary<string, IOpenApiSecurityScheme>();
                    document.Components.SecuritySchemes.Add("Bearer",
                        new OpenApiSecurityScheme
                        {
                            Type = SecuritySchemeType.Http,
                            Scheme = "bearer",
                            BearerFormat = "JWT",
                            Description = "Please enter token"
                        });

                    // Security Requirement を追加
                    document.Security ??= new List<OpenApiSecurityRequirement>();
                    document.Security.Add(
                        new OpenApiSecurityRequirement
                        {
                            // インデクサ初期化子
                            [new OpenApiSecuritySchemeReference("Bearer", document)] = []
                        }
                    );

                    return Task.CompletedTask;
                });
            });

            // OpenAPI ドキュメントのトランスフォーマー設定(方法2)
            //builder.Services.AddOpenApi(options =>
            //{
            //    options.AddDocumentTransformer<JwtSecurityTransformer>();
            //});

            var app = builder.Build();

            if (app.Environment.IsDevelopment())
            {
                app.MapOpenApi();
                app.UseSwaggerUI(options =>
                {
                    options.SwaggerEndpoint("/openapi/v1.json", "v1");
                });
            }

            app.UseHttpsRedirection();

            // 認証と認可のミドルウェアを追加
            app.UseAuthentication();
            app.UseAuthorization();

            app.MapControllers();

            app.Run();

        }
    }

    // 方法2に用いる Transformer の実装
    // IAuthenticationSchemeProvider をコンストラクタへの DI で
    // 受け取ることで、登録されている認証スキームに Bearer が含
    // まれることを確認してから Security Scheme を追加する
    internal sealed class JwtSecurityTransformer(IAuthenticationSchemeProvider provider)
        : IOpenApiDocumentTransformer
    {
        public async Task TransformAsync(OpenApiDocument document,
                                         OpenApiDocumentTransformerContext context,
                                         CancellationToken cancellationToken)
        {
            // OpenAPI ドキュメントの基本情報を設定(無くても可)
            document.Info = new OpenApiInfo
            {
                Title = "ASP.NET Core Web API",
                Version = "v1",
                Description = "ASP.NET Core Web API with JWT authentication. " +
                            "Target Framework is .NET 10. " +
                            "Built‑in OpenAPI + SwaggerUI are used."
            };

            // Security Scheme を設定(JWT Bearer)
            var authenticationSchemes = await provider.GetAllSchemesAsync();
            if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer"))
            {
                var securitySchemes = new Dictionary<string, IOpenApiSecurityScheme>
                {
                    // インデクサ初期化子
                    ["Bearer"] = new OpenApiSecurityScheme
                    {
                        Type = SecuritySchemeType.Http,
                        Scheme = "bearer",
                        BearerFormat = "JWT",
                        Description = "Please enter token"
                    }
                };
                document.Components ??= new OpenApiComponents();
                document.Components.SecuritySchemes = securitySchemes;
            }

            // Security Requirement を設定
            document.Security = [
                new OpenApiSecurityRequirement
                {
                    [new OpenApiSecuritySchemeReference("Bearer", document)] = []
                }
            ];
        }
    }
}

上のトランスフォーマーにより OpenAPI ドキュメントに Security Scheme と Security Requirement が追加されます。下の画像を見てください。赤枠が OpenAPI ドキュメント上の Security Scheme 部分、青枠が Security Requirement 部分になります。

OpenAPI ドキュメント

SwaggerUI は、上の赤枠部分を解釈して [Authorize] ボタンの表示とトークンの設定、青枠部分を解釈してベアラートークンの送信ができる実装を追加します。

Tags: , , , , ,

DevelopmentTools

.NET 10 ASP.NET Core Web API で Swagger を使用

by WebSurfer 1. April 2026 12:04
2026/4/2 追記: この記事は、.NET 10 の組込み OpenAPI は使わないで、SwaggerGen + SwaggerUI を使った場合の話です。後で調べて分かったのですが、組込み OpenAPI と Swagger UI (UI だけ)を使って [Authorize] ボタンを表示し必要な操作を行うことは可能でした。その方法については「その2」に書きました。

ターゲットフレームワークを .NET 10 として作成した ASP.NET Core Web API プロジェクトで Swagger を使うにはどうすればよいかを調べましたので、備忘録として以下に書いておきます。

Swagger

ターゲットフレームワーク .NET 8 で作成した ASP.NET Core Web API プロジェクトにはデフォルトで Swagger か組み込まれており、Visual Studio からアプリを実行すると上の画像のようにブラウザ上に Swagger UI が表示され、それを操作して Web API にデータを送信し応答を受け取ることができました。

しかしながら、ターゲットフレームワーク .NET 9 以降の ASP.NET Core Web API のプロジェクトには Swagger は含まれません (理由は Announcement: Swashbuckle.AspNetCore is being removed in .NET 9 を見てください)。それを .NET 8 プロジェクトと同様に Swagger を利用できるようにするにはどうすれば良いかという話です。

プロジェクトの作成に Visual Studio 2026 のテンプレート ASP.NET Core Web API を使ってターゲットフレームワーク .NET 10 とすると、設定画面で [Enable OpenAPI support] にデフォルトでチェックが入っており、作成したプロジェクトには NuGet パッケージ Microsoft.AspNetCore.OpenApi がインストールされ、Program.cs にはそれを使う設定がされます。

しかし、Microsoft.AspNetCore.OpenApi には、Web API を操作するための組み込みのサポートは付属していませんので、.NET 8 と同様に Swagger を使いたいのであれば追加で Swagger UI をインストールする必要があります。

具体的には、Microsoft のドキュメント「ローカルのアドホック テストに Swagger UI を使用する」に書いてありますように、NuGet パッケージ Swashbuckle.AspNetCore.SwaggerUI をインストールし、swagger-ui ミドルウェアを有効にします。それで先の記事「ASP.NET Core Web API と Swagger(その1)」に書いたことまでは Swagger UI + Microsoft.AspNetCore.OpenApi でできるようになります。

(ただし、SampleB メソッドには [Consumes("multipart/form-data")] 属性を付与しないと、Swagger はコンテンツは正しく multipart/form-data 形式とするものの、応答ヘッダーは Content-Type: application/x-www-form-urlencoded としてしまうという問題がありました。Swagger UI + Microsoft.AspNetCore.OpenApi 併用で、引数に複合モデルを使った場合に起きるバグ的な挙動のようです)

問題は、Swagger UI + Microsoft.AspNetCore.OpenApi 併用では、先の記事「ASP.NET Core Web API と Swagger(その2)」に書いた AddSwaggerGen メソッドが使えず、[Authorize] ボタンを表示できないことです。当然、[Authorize] ボタンをクリックしてベアラトークン入力ウィンドウを表示し、テキストボックスにトークンを入力して、トークンを要求ヘッダに含めて送信するという操作はできません。

Copilot によると、[Authorize] ボタンを出したいなら SwaggerGen + SwaggerUI を使う他に方法はないとのこと。実はそれは誤りだったのですが、とりあえずその方向に進んで、以下のように NuGet パッケージ Microsoft.AspNetCore.OpenApi はアンインストールし、Swashbuckle.AspNetCore 10.1.7 をインストールしてこの記事の一番上の画像のように [Authorize] ボタンが出るよう実装してみました。

Swashbuckle.AspNetCore 10.1.7 をインストール

この時注意しなければならないのが、Swashbuckle 10.x では、GitHub の記事 Migrating to Swashbuckle.AspNetCore v10 に "Update any using directives that reference types from the Microsoft.OpenApi.Models namespace to use the new namespace Microsoft.OpenApi." と書いてあるように Microsoft.OpenApi.Models という名前空間がなくなって、そこにあったクラス類は Microsoft.OpenApi 名前空間に移動したということです。そして、全部移動した訳ではないので、using Microsoft.OpenApi; に変えるだけで Swashbuckle 9.x 以前の時代に使っていたコードがそのまま使えるわけではないということです。

今回、Swashbuckle 9.x 以前で使っていたコードをそのまま移植してどこが問題になったかというと、下の画像の通りです。

Security Requirement の追加

Swashbuckle 10.x には Reference も OpenApiReference も存在しません。理由は、Copilot に聞いた話ですが、以下の通りだそうです。

  • Swashbuckle 9.x までは OpenApiSecurityScheme, OpenApiReference, OpenApiSecurityRequirement など OpenAPI.NET のモデルを直接操作して Security を追加していた。
  • Swashbuckle 10.x では OpenAPI.NET のモデルを直接触る API が削除され、代わりに OpenAPI ドキュメントを後から加工する Transformer 方式に変更された。

(ちなみに、上の画像のコードの options.AddSecurityRequirement( ... を削除しても [Autorize] ボタンは表示されるものの、要求ヘッダに Authorization: Bearer ... が含まれなくなります)

Swashbuckle 10.x を使うなら options.AddSecurityRequirement( ... のコードを上で言う Transformer 方式で書き換えるということになります。Transformer というのは、これも Copilot に聞いた話ですが、SwaggerGen が生成した OpenAPI ドキュメントに対して追加・変更・削除を行う「後処理フィルター」のことだそうです。

Transformer 方式で書き直した options.AddSecurityRequirement( ... のコードは以下のようになります。下のコードの document は SwaggerGen が生成した OpenAPI ドキュメントで、そのドキュメントに対して Security Requirement を追加しています。

options.AddSecurityRequirement(document =>
    new OpenApiSecurityRequirement
    {
        [new OpenApiSecuritySchemeReference("Bearer", document)] = []
    });

これにより [Authorize] 機能は期待通り動くようになります。

この記事の一番上の画像の [Authorize] ボタンをクリックすると、下の画像のトークン入力ウィンドウが表示されるので、テキストボックスにトークンを入力し、下の画像の [Authorize] ボタンをクリックします。

トークン入力ウィンドウ

それ以降は Swagger からの要求は、すべて要求ヘッダに Authorization: Bearer <JWT> が含まれて送信されるようになります。

Fiddler で見た要求ヘッダ

以下にこの記事を書く際に検証用に使ったコードを載せておきます。

Program.cs

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Text;

namespace WebApi2
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            builder.Services.AddControllers();

            // JWT 認証の設定
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidIssuer = builder.Configuration["Jwt:Issuer"],
                        ValidAudience = builder.Configuration["Jwt:Issuer"],
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
                    };
                });

            builder.Services.AddAuthorization();

            // Swagger の設定(Swashbuckle.AspNetCore 10.x を使う例)
            builder.Services.AddSwaggerGen(options =>
            {
                // OpenAPI ドキュメントの基本��報を設定(無くても可)
                options.SwaggerDoc("v1", new OpenApiInfo
                {
                    Title = "ASP.NET Core Web API",
                    Version = "v1",
                    Description = "ASP.NET Core Web API with JWT authentication. " +
                    "Target Framework is .NET 10. " +
                    "Swashbuckle.AspNetCore 10.1.7 is used."
                });

                // Security Scheme を追加(JWT Bearer トークンを使う)
                options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
                {
                    Name = "Authorization",
                    Type = SecuritySchemeType.Http,
                    Scheme = "Bearer",
                    BearerFormat = "JWT",
                    In = ParameterLocation.Header,
                    Description = "Please enter token"
                });

                // Security Requirement を追加(これが無いと Authorize ウィンドウ
                // で JWT を設定しても、Swagger は Authorization: Bearer ... を
                // 要求ヘッダに含めない)                
                // 下のコードの document は SwaggerGen が生成した OpenAPI ドキュ
                // メント。そのドキュメントに対して Security Requirement を追加
                options.AddSecurityRequirement(document =>
                    new OpenApiSecurityRequirement
                    {
                        [new OpenApiSecuritySchemeReference("Bearer", document)] = []
                    });
            });

            var app = builder.Build();

            if (app.Environment.IsDevelopment())
            {
                // Swagger ミドルウェアを有効にする
                app.UseSwagger();

                // Swagger UI を有効にする
                app.UseSwaggerUI(options =>
                {
                    options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
                });
            }

            app.UseHttpsRedirection();

            // 認証と認可のミドルウェアを追加
            app.UseAuthentication();
            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}

appsettings.json

上の Program.cs の JWT 認証の設定で使う Key と Issuer の値を設定しています。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKeyWhichMustBeLongerThan32",
    "Issuer": "https://localhost:7032/"
  }
}

TokenController

Id と Password を受けて JWT を発行する API です。この記事の本題とは直接関係ないですが参考までに載せておきます。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;

namespace WebApi2.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly IConfiguration _config;

        public TokenController(IConfiguration config)
        {
            _config = config;
        }

        [AllowAnonymous]
        [HttpPost]
        public async Task<IActionResult> CreateToken(LoginModel login)
        {
            string? id = login.Username;
            string? pw = login.Password;
            IActionResult response = Unauthorized();

            if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(pw))
            {
                // 受け取った id と password を検証する
                if (await VerifyUserAsync(id, pw))
                {
                    // JWT を生成する
                    var tokenString = BuildToken(_config);
                    response = Ok(new { token = tokenString });
                }
            }

            return response;
        }

        // 受け取った id と password を検証するヘルパメソッド
        // 内部で UserManager.CheckPasswordAsync(id, pw) を使うことを
        // 想定して非同期メソッドにした
        private static Task<bool> VerifyUserAsync(string id, string pw)
        {
            // ここでは全て検証結果 OK として true を返す
            return Task.FromResult(true);
        }

        // JWT を生成するヘルパメソッド
        private static string BuildToken(IConfiguration config)
        {
            var key = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Key"]!));

            var creds = new SigningCredentials(
                key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: config["Jwt:Issuer"],
                audience: config["Jwt:Issuer"],
                claims: null,
                notBefore: null,
                expires: DateTime.Now.AddMinutes(30),
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }


    // クライアントから送信されてきた id と password を受け取る
    // ための View Model
    public class LoginModel
    {
        public string? Username { get; set; }
        public string? Password { get; set; }
    }
}

UploadController

アップロードされたファイルを受け取る API です。これもこの記事の本題とは直接関係ないですが参考までに載せておきます。

using Microsoft.AspNetCore.Mvc;

namespace WebApi2.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class UploadController : ControllerBase
    {
        [HttpPost("SampleA")]
        public IActionResult SampleA(IFormFile? postedFile,
                                     [FromForm] string? customField)
        {
            if (postedFile == null || postedFile.Length == 0)
            {
                return Content("ファイルを受信できませんでした");
            }

            if (customField == null)
            {
                return Content("customField を受信できませんでした");
            }

            return Content($"ファイル: {postedFile.FileName}, " +
                $"customField: {customField} 受信");
        }

        [HttpPost("SampleB")]
        // 以下の属性は無くても問題無し
        //[Consumes("multipart/form-data")]
        public IActionResult SampleB([FromForm] UploadModels model)
        {
            if (model.PostedFile == null || model.PostedFile.Length == 0)
            {
                return Content("ファイルを受信できませんでした");
            }

            if (model.CustomField == null)
            {
                return Content("customField を受信できませんでした");
            }

            return Content($"ファイル: {model.PostedFile.FileName}, " +
                $"customField: {model.CustomField} 受信");
        }
    }

    public class UploadModels
    {
        public string? CustomField { get; set; }
        public IFormFile? PostedFile { get; set; }
    }
}

Tags: , , , ,

DevelopmentTools

SQL Server のテーブルを Boorstrap Modal で編集・更新

by WebSurfer 25. March 2026 14:43

SQL Server のテーブルの CRUD を行う ASP.NET Core Razor Pages アプリで、ユーザーがレコードを編集・更新する際、対象レコードを Bootstrap Modal に表示し、ユーザーが編集を行った後 Bootstrap Modal 上の [Update] ボタンをクリックすると、レコードを更新する方法を紹介します。

Movie テーブルを Boorstrap Modal で編集・更新

先の記事「ASP.NET Core Razor Pages で Bootstrap Modal の利用」で Delete する前に削除するレコードの内容を確認するため Bootstrap Modal を使う方法を書きました。その続きです。

編集・更新は普通に別ページに遷移してそこで行って何ら不都合はないと思いますし、Bootstrap Modal を使うメリットはない(複雑になるデメリットしかない)とは思いますが、せっかく作ったのでブログに書いておくことにしました。

問題は Model のプロパティに付与する StringLength とか RegularExpression などのデータアノテーション属性によるユーザー入力の検証です。普通に別ページに遷移して行う時と同様、上の画像のように表示したいのですが、それがかなり面倒でした。特にクライアント側での検証を無効にしてサーバー側で検証を行う場合はいろいろ気を付けなければならないことがあります。

以下に、上の画像を表示するのに使った ASP.NET Core Razor Pages アプリのソースコードを載せて、気を付けるべき点を書いておきます。元になるアプリは、Visual Studio 2026 のテンプレートを使って、ターゲットフレームワーク .NET 10 で作成しました。

(1) Movie.cs(モデル)

ユーザー入力検証用のデータアノテーション属性をプロパティに付与します。この記事の例では Required、StringLength、Range を使いました。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace RazorPages.Models;

[Table("Movie")]
public partial class Movie
{
    [Key]
    public int Id { get; set; }

    [Display(Name = "タイトル")]
    [StringLength(128, MinimumLength = 5, 
        ErrorMessage = "{0}は{2}文字以上{1}文字以下でなければなりません。")]
    public string? Title { get; set; }

    [Display(Name = "公開日")]
    [Required(ErrorMessage = "{0}は必須です。")]
    [DisplayFormat(DataFormatString = "{0:yyyy年M月d日}")]
    public DateTime ReleaseDate { get; set; }

    [Display(Name = "ジャンル")]
    [StringLength(128, 
        ErrorMessage = "{0}は{1}文字以下でなければなりません。")]
    public string? Genre { get; set; }

    [Display(Name = "価格")]
    [Required(ErrorMessage = "{0}は必須です。")]
    [Range(100, 10000,
        ErrorMessage = "{0}は{1}から{2}の間でなければなりません。")]
    [DisplayFormat(DataFormatString = "{0:C0}")]
    [Column(TypeName = "decimal(18, 2)")]
    public decimal Price { get; set; }
}

(2) Movie2/Index.cshtml.cs

Visual Studio 2026 のスキャフォールディング機能を使って、SQL Server の Movie テーブルの CRUD を行うコードを自動生成させ、CRUD の Read を行う(レコード一覧を表示する)Index ページに手を加えました。

EditMovie プロパティ、OnGetMovieToEditAsync メソッド、OnPostUpdateAsync メソッド、MovieExists メソッドは、スキャフォールディング機能で自動生成された Movie2/Edit.cshtml.cs のコードに少し手を加えて使いました。コメントの説明を見てください。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RazorPages.Models;
using RazorPages.Data;

namespace RazorPages.Pages.Movies2
{
    public class IndexModel : PageModel
    {
        private readonly TestDatabaseContext _context;

        public IndexModel(TestDatabaseContext context)
        {
            _context = context;
        }

        // 編集対象の Movie データの授受に用いるプロパティを追加
        [BindProperty]
        public Movie EditMovie { get; set; } = default!;

        public IList<Movie> Movie { get;set; } = default!;

        public async Task OnGetAsync()
        {
            Movie = await _context.Movies.ToListAsync();
        }

        // 指定された id のデータを Movie テーブルから取得し JSON 形式で返す
        // ハンドラを追加。このデータを Bootstrap Modal に表示する
        public async Task<IActionResult> OnGetMovieToEditAsync(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var movie = await _context.Movies
                              .FirstOrDefaultAsync(m => m.Id == id);
            if (movie == null)
            {
                return NotFound();
            }
            
            return new JsonResult(movie);
        }

        // クライアントから送信されてきた EditMovie プロパティの内容でMovie
        // テーブルの当該レコードを更新するハンドラを追加
        public async Task<IActionResult> OnPostUpdateAsync()
        {
            if (!ModelState.IsValid)
            {
                // 検証エラーがある場合

                // Movie を再取得。これがないと return Page(); でリストを再表示す
                // る際、Index.cshtml の @foreach (var item in Model.Movie)
                // でエラーになる
                Movie = await _context.Movies.ToListAsync();

                // 検証エラーがあることを Index.cshtml に伝えるため、ViewData に
                // フラグをセット。これを受けて Index.cshtml ではモーダルを表示する
                ViewData["ValidationResult"] = "invalid";

                // 元の Index ページを再表示。上のフラグを "invalid" にセットして
                // いるので Index ページには Modal が表示され、EditMovie の内容
                // がエラーメッセージとともに表示される
                return Page();
            }

            _context.Attach(EditMovie).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!MovieExists(EditMovie.Id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return RedirectToPage("./Index");
        }

        private bool MovieExists(int id)
        {
            return _context.Movies.Any(e => e.Id == id);
        }
    }
}

(3) Movie2/Index.cshtml

リストの右横の Edit リンクをクリックすると JavaScript のメソッド openModal が呼び出され、その引数に当該レコードの id が渡されます。openModal は fetch を使ってハンドラ OnGetMovieToEditAsync を呼び出します。ハンドラ OnGetMovieToEditAsync は Movie テーブルから id で指定されたレコードを抽出し JSON 形式で返します。JSON を受け取ったらその内容を from タグ内の input 要素に書き込み、Modal を表示します。

ユーザーが Modal に表示された内容を編集後、[Update] ボタンをクリックすると、from タグには method="post" asp-page-handler="Update" と指定されているので、ハンドラ OnPostUpdateAsync に編集されたデータが post され、その内容で Movie テーブルの当該レコードが更新されます。

ポイントは from タグとその中身を、以下のコードのように、初期ページの内容に含むようにしておくことです。そうすることによって、サーバー側の検証 NG で元の Index ページに差し戻す際に、Modal 上のテキストボックスにはユーザーが編集した結果が表示され、検証 NG の場合はアノテーション属性に設定したエラーメッセージが表示されます。その結果がこの記事の上の画像です。

@page
@model RazorPages.Pages.Movies2.IndexModel

@{
    ViewData["Title"] = "Index";
}

<!-- Bootstrap Modal -->
<div class="modal fade" id="staticBackdrop" data-bs-backdrop="static"
     data-bs-keyboard="false" tabindex="-1"
     aria-labelledby="staticBackdropLabel" aria-hidden="true">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h1 class="modal-title fs-5"
                    id="staticBackdropLabel">
                    Edit Movie
                </h1>
                <button type="button" class="btn-close"
                        data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                <form method="post" asp-page-handler="Update">
                    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                    <input type="hidden" asp-for="EditMovie.Id" />
                    <div class="form-group">
                        <label asp-for="EditMovie.Title" class="control-label"></label>
                        <input asp-for="EditMovie.Title" class="form-control" />
                        <span asp-validation-for="EditMovie.Title" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <span class="text-danger">*</span>
                        <label asp-for="EditMovie.ReleaseDate" class="control-label"></label>
                        <input asp-for="EditMovie.ReleaseDate" class="form-control" aria-required="true" />
                        <span asp-validation-for="EditMovie.ReleaseDate" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <label asp-for="EditMovie.Genre" class="control-label"></label>
                        <input asp-for="EditMovie.Genre" class="form-control" />
                        <span asp-validation-for="EditMovie.Genre" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <span class="text-danger">*</span>
                        <label asp-for="EditMovie.Price" class="control-label"></label>
                        <input asp-for="EditMovie.Price" class="form-control" aria-required="true" />
                        <span asp-validation-for="EditMovie.Price" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <input type="submit" value="Update" class="btn btn-primary" />
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

<h1>Index</h1>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Movie[0].Title)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movie[0].ReleaseDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movie[0].Genre)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movie[0].Price)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Movie) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Title)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.ReleaseDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Genre)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Price)
            </td>
            <td>
                <a href="javascript:void(0);" 
                    onclick="openModal(@item.Id)">Edit</a>
            </td>
        </tr>
}
    </tbody>
</table>
@section scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }

    <script>
        window.openModal = async function (id) {
            const url = '@Url.Page("/Movies2/Index", "MovieToEdit")' + '&id=' + id;
            const response = await fetch(url);
            if (response.ok) {
                const data = await response.json();
                document.getElementById('@Html.IdFor(model => model.EditMovie.Id)').value = data.id;
                document.getElementById('@Html.IdFor(model => model.EditMovie.Title)').value = data.title;
                document.getElementById('@Html.IdFor(model => model.EditMovie.ReleaseDate)').value = data.releaseDate;
                document.getElementById('@Html.IdFor(model => model.EditMovie.Genre)').value = data.genre;
                document.getElementById('@Html.IdFor(model => model.EditMovie.Price)').value = data.price;

                // クライアント側での検証を有効にしておくとユーザー入力不正で検証メッセージ
                // が表示される。ページを再描画しない限りその検証メッセージは書き換えられる
                // ことはないので、そこで Modal を閉じて別の行で Modal を再表示すると前回
                // の検証メッセージが残ってしまう。下のスクリプトで検証メッセージを消す
                const validators = document.querySelectorAll("span[data-valmsg-for]");
                validators.forEach(function (validator) {
                    validator.innerText = "";
                });

                // 上の fetch で取得したデータを Modal 内のテキストボックスにセットして
                // から Modal を表示する。下の 'staticBackdrop' は Modal の id です
                const modal = document.getElementById('staticBackdrop');
                const editModal = new bootstrap.Modal(modal);
                editModal.show();
            }
        };

        // クライアント側での検証を無効にしサーバー側で検証を行う場合に、検証結果が 
        // NG だった場合に Modal を再表示するためのスクリプト
        window.addEventListener('DOMContentLoaded', () => {
            if ('@ViewData["ValidationResult"]' === "invalid") {
                const modal = document.getElementById('staticBackdrop');
                const editModal = new bootstrap.Modal(modal);
                editModal.show();
            }
        });
    </script>
}

Tags: , , , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  April 2026  >>
MoTuWeThFrSaSu
303112345
6789101112
13141516171819
20212223242526
27282930123
45678910

View posts in large calendar