WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Web API と要求ヘッダの application/xml

by WebSurfer 7. June 2020 14:12

.NET Framework 版の ASP.NET Web API を要求する際、要求ヘッダの Accept に application/xml が含まれると応答は JSON ではなく XML になります。

ASP.NET Web API の応答

知ってました? 実は自分は知らなかったです。ちなみに、MVC の Json メソッド、および Core 3.1 版は期待通り JSON が返ってきます。

Web API の応答をチェックするには Postman などのツールを使うのが一般的なようです。でも、簡易的にはブラウザのアドレスバーに直接 URL を入力して応答を見ることでも十分だったので自分はそうしてました。IE11 ではそれで期待した通り JSON 文字列が返ってきてました。

ところが、Chrome, Edge, Firefox では応答が JSON ではなく XML になってしまいます。ハマったのは上の画像のようなサーバーエラーとなってしまったことです(HTTP 500 応答が返ってきます。上の画像は形式は XML ですが中身はエラーメッセージです)。

エラーメッセージ "The 'ObjectContent`1' type failed to serialize the response body for content type 'application/xml; charset=utf-8'." によるとシリアライズに失敗したということです。

IE11 や jQuery ajax を使った検証ではシリアライズには問題はないことは確認しており、ブラウザに Chrome などを使ったからと言ってそれがシリアライズの部分に影響があるとは考えられなかったです。何故なのでしょう?

循環参照の問題でした。

上の画像の InnerException にある Blog_E36679EB... が、先の記事「JSON シリアライズの際の循環参照エラー」にもあったのを思い出して気が付きました。

.NET Framework 版の ASP.NET Web API の JSON シリアライザは Newtonsoft の Json.NET のもので、JsonIgnoreAttribute クラスという属性が利用できます。なので、問題のプロパティに [JsonIgnore] を付与してシリアライズの際の循環参照エラーは回避していました。

でも [JsonIgnore] が有効なのは JSON にシリアライズするときのみで、XML にシリアライズするときは循環参照の問題は回避できません。

Chrome, Edge などを使った時は、ASP.NET Web API は要求ヘッダの Accept に application/xml が含まれるのを見て、XML にシリアライズしようとして循環参照の問題に陥ったということのようです。

Tags: , , ,

Web API

コレクションを表現した JSON のデシリアライズ

by WebSurfer 3. June 2020 15:49

以下のようなコレクション(配列)を表現した JSON 文字列を C# のオブジェクトにデシリアライズするときにハマった話を書きます。

こんなことにハマるのは無知だからだと言われそうですが、また無駄な時間を費やさないように備忘録として書いておくことにしました。

[
  {
    "id":1,
    "text":"project #1",
    "start_date":"2020-05-06",
    "end_date":"2020-05-26",
    "user":3,
    "duration":20,
    "parent":0,
    "progress":0
  },
  {
    "id":2,
    "text":"Task #1",
    "start_date":"2020-05-06",
    "end_date":"2020-05-16",
    "user":0,
    "duration":10,
    "parent":1,
    "progress":0
  },
  {
    "id":3,
    "text":"Task #2",
    "start_date":"2020-05-16",
    "end_date":"2020-05-26",
    "user":0,
    "duration":10,
    "parent":1,
    "progress":0
  }
]

先の記事「JSON 文字列から C# のクラス定義生成」で書きましたように、Visual Studio を使って JSON 文字列から C# のオブジェクトのクラス定義を生成することができます。

上の JSON 文字列から Visual Studio で C# のクラス定義を生成すると以下の通りとなります。

public class Rootobject
{
    public Class1[] Property1 { get; set; }
}

public class Class1
{
    public int id { get; set; }
    public string text { get; set; }
    public string start_date { get; set; }
    public string end_date { get; set; }
    public int user { get; set; }
    public int duration { get; set; }
    public int parent { get; set; }
    public int progress { get; set; }
}

Newtonsoft.Json と JavaScriptSerializer で上に書いた JSON 文字列を Rootobject にデシリアライズしてみます。コード例は以下の通りです。

// Newtonsoft.Json
var result1 = JsonConvert.DeserializeObject<Rootobject>(jsonText);

// JavaScriptSerializer
var serializer = new JavaScriptSerializer();
var result2 = serializer.Deserialize<Rootobject>(jsonText);

そうすると、Newtonsoft.Json では JsonSerializationException 例外が、JavaScriptSerializer では InvalidOperationException 例外がスローされます。Newtonsoft.Json が表示するエラーメッセージは以下の通りでした。

Newtonsoft.Json.JsonSerializationException: 'Cannot deserialize the current JSON array (e.g. [1,2,3]) into type 'ConsoleAppJson.Rootobject' because the type requires a JSON object (e.g. {"name":"value"}) to deserialize correctly. To fix this error either change the JSON to a JSON object (e.g. {"name":"value"}) or change the deserialized type to an array or a type that implements a collection interface (e.g. ICollection, IList) like List<T> that can be deserialized from a JSON array. JsonArrayAttribute can also be added to the type to force it to deserialize from a JSON array. Path '', line 1, position 1.'

これによると Rootobject ではダメで、List<Class1> にデシリアライズしろということのようです。やってみたら確かに List<Class1> にはデシリアライズできました。

なお、上のような配列だけの JSON 文字列ではなくて、例えば { "data": [ { ... },{ ... },{ ... } ] } のような形の JSON 文字列にした場合は、Visual Studio により生成される C# のクラス定義はほぼ同じになりますが、Rootobject にデシリアライズできます。

[ { ... },{ ... },{ ... } ] という形の JSON 文字列だけが要注意のようです。

.NET Framework 版の ASP.NET MVC は JavaScriptSerializer を、ASP.NET Web API は Newtonsoft.Json を使っているので、上のような JSON 文字列を送信してアクションメソッドに渡す場合が自分的に要注意だと思いました。

Tags: , ,

.NET Framework

ASP.NET Identity のロール管理 (CORE)

by WebSurfer 1. June 2020 10:07

ASP.NET Core Identity のユーザー認証を実装した ASP.NET Core 3.1 MVC アプリに、管理者がロールの表示・追加・変更・削除およびユーザーへのロールのアサイン・解除を行う機能を実装してみます。

ロール情報の表示

先の記事「ASP.NET Identity のロール管理 (MVC)」で .NET Framework 版を書きましたが、この記事は Core 3.1 版です。

ベースとしているアプリは、先の記事「ASP.NET Identity のユーザー管理 (CORE)」で、管理者用のユーザー管理ページを実装したものです。

(1) ロールサービスの追加

ロールを使うにはサービスの追加が必要になります。以下のように、既存のコードに .AddRoles<IdentityRole>() を一行追加するだけで OK です。.NET Framework 版のアプリとは違って Migration 操作は不要です。

ロールサービスの追加

この記事のベースとしているアプリは「認証なし」で作成したあとスキャフォールディングで ASP.NET Core Identity を実装していますので、既存のコードは Areas/Identity フォルダの IdentityHostingStartup.cs ファイルにあります。上の画像がそれです。

認証を「個別のユーザーアカウント」としてプロジェクトを作成すると、サービス関係のコードは上の画像とは異なり、アプリケーションルート直下の Startup.cs フォルダに配置されます。

これだけでロールは使えるようになりますが、ロールの作成・変更・削除や、登録済みユーザーへのロールのアサイン・解除を行う機能は自力でコード書いて実装しなければなりません。その基本的な方法を以下に書きます。

(2) Model の作成

プロジェクトのルート直下の Models フォルダに RoleModel.cs というクラスファイルを追加し、その中に RoleModel クラスを定義して使います。(ファイル名、モデル名は任意です)

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace MySQLIdentity.Models
{
    // ロールの表示、追加、変更、削除用の Model
    // Index, Details, Create, Edit に使用
    public class RoleModel
    {
        public string Id { get; set; }

        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(
          100,
          ErrorMessage = "{0} は {2} 文字以上",
          MinimumLength = 3)]
        [Display(Name = "ロール名")]
        public string Name { get; set; }
    }

    // 各ユーザーへのロールのアサイン状況一覧表示と
    // ロールのアサイン・解除用の Model
    // UserWithRoles, EditRoleAssignment に使用
    public class UserWithRoleInfo
    {
        public UserWithRoleInfo()
        {
            UserRoles = new List<RoleInfo>();
        }

        public string UserId { set; get; }

        [Display(Name = "ユーザー名")]
        public string UserName { set; get; }
        public string UserEmail { set; get; }

        public IList<RoleInfo> UserRoles { set; get; }
    }

    public class RoleInfo
    {
        public string RoleName { set; get; }
        public bool IsInThisRole { set; get; }
    }
}

RoleModel クラスはアクションメソッド Index, Details, Create, Edit でロールの表示、追加、変更、削除を行うために用いられる Model です。

フレームワークのライブラリに定義済みの IdentityRole クラスを利用することも可能です。

しかし、IdentityRole クラスを使うと (a) スキャフォールディング機能を使って View を生成できない(デザイナの「Razor ビューの追加」のダイアログで[モデルクラス(M):]の一覧に IdentityRole が現れない)、(b) 表示名を自由に付けられない・・・という理由で RoleModel クラスを定義して使うことにしました。

RoleModel クラスを使うと、Controller で Linq To Entities を使って取得した IdentityRole オブジェクトを RoleModel クラスに詰め替えなければならないという手間が増えますがやむを得ません。

UserWithRoleInfo, RoleInfo クラスは、アクションメソッド UserWithRoles, EditRoleAssignment で、ユーザーへのロールのアサイン状況の表示(この記事の一番上の画像参照)およびユーザーへのロールのアサイン・解除を行うために用いられる Model です。

(2) Controller の作成

.NET Framework 版の ASP.NET MVC5 アプリと違って、Core 版には組み込みの DI 機能があります。今回のケースではコントローラーを初期化する際に UserManager<MySQLIdentityUser> と RoleManager<IdentityRole> オブジェクトが注入されるよう実装します。

ロールの表示・追加・変更・削除を行うためコントローラーのコードは以下の通りです(Details アクションメソッドは実装していません)。細かい注意点はコメントで入れましたのでそれを見てください。

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 追加
using Microsoft.AspNetCore.Identity;
using MySQLIdentity.Areas.Identity.Data;
using Microsoft.EntityFrameworkCore;
using MySQLIdentity.Models;


namespace MySQLIdentity.Controllers
{
    public class RoleController : Controller
    {
        // コントローラーを呼び出すと DI により自動的にコンストラクタに
        // UserManager<MySQLIdentityUser> と RoleManager<IdentityRole>
        // オブジェクトへの参照が渡される
        private readonly UserManager<MySQLIdentityUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;

        public RoleController(UserManager<MySQLIdentityUser> userManager,
                              RoleManager<IdentityRole> roleManager)
        {
            _userManager = userManager;
            _roleManager = roleManager;
        }

        // GET: Role/Index
        // Model は RoleModel
        public async Task<IActionResult> Index()
        {
            var roles = from r in _roleManager.Roles
                        orderby r.Name
                        select new RoleModel
                        {
                            Id = r.Id,
                            Name = r.Name
                        };

            return View(await roles.ToListAsync());
        }


        // GET: Role/Details/5・・・省略
        // ロール名以外の情報は含まれないので不要


        // GET: Role/Create
        // Model は RoleModel クラス
        public IActionResult Create()
        {
            return View();
        }

        // POST: Role/Create
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(
                            [Bind("Id,Name")] RoleModel model)
        {
            if (ModelState.IsValid)
            {
                // ユーザーが入力したロール名を model.Name から
                // 取得し IdentityRole オブジェクトを生成
                var role = new IdentityRole { Name = model.Name };

                // 上の IdentityRole から新規ロールを作成・登録
                var result = await _roleManager.CreateAsync(role);

                if (result.Succeeded)
                {
                    // 登録に成功したら Role/Index にリダイレクト
                    return RedirectToAction("Index", "Role");
                }

                // result.Succeeded が false の場合 ModelSate にエ
                // ラー情報を追加しないとエラーメッセージが出ない。
                // Register.cshtml.cs のものをコピー
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, 
                                             error.Description);
                }
            }

            // ロールの登録に失敗した場合、登録画面を再描画
            return View(model);
        }

        // GET: Role/Edit/5
        // Edit でできるのはロール名の変更のみ
        // Model は RoleModel クラス
        public async Task<IActionResult> Edit(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var target = await _roleManager.FindByIdAsync(id);

            if (target == null)
            {
                return NotFound();
            }

            RoleModel model = new RoleModel
            { 
                Name = target.Name 
            };

            return View(model);
        }

        // POST: Role/Edit/5
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(string id, 
                        [Bind("Id,Name")] RoleModel model)
        {
            if (id == null)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                var target = await _roleManager.FindByIdAsync(id);
                if (target == null)
                {
                    return NotFound();
                }

                // ユーザーが入力したロール名を model.Name から取得し
                // て IdentityRole の Name を書き換え
                target.Name = model.Name;

                // Name を書き換えた IdentityRole で更新をかける
                var result = await _roleManager.UpdateAsync(target);

                if (result.Succeeded)
                {
                    // 更新に成功したら Role/Index にリダイレクト
                    return RedirectToAction("Index", "Role");
                }

                // result.Succeeded が false の場合 ModelSate にエ
                // ラー情報を追加しないとエラーメッセージが出ない。
                // Register.cshtml.cs のものをコピー
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, 
                                             error.Description);
                }
            }

            // 更新に失敗した場合、編集画面を再描画
            return View(model);
        }

        // GET: Role/Delete/5
        // 階層更新が行われているようで、ユーザーがアサインされて
        // いるロールも削除可能。
        // Model は RoleModel
        public async Task<IActionResult> Delete(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var role = await _roleManager.FindByIdAsync(id);

            if (role == null)
            {
                return NotFound();
            }

            var model = new RoleModel
            {
                Id = role.Id,
                Name = role.Name
            };

            return View(model);
        }

        // POST: Role/Delete/5
        // 上の Delete(string id) と同シグネチャのメソッド
        // は定義できないので、メソッド名を変えて、下のよう
        // に ActionName("Delete") を設定する
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var role = await _roleManager.FindByIdAsync(id);

            if (role == null)
            {
                return NotFound();
            }

            // ユーザーがアサインされているロールも以下の一行で
            // 削除可能。内部で階層更新が行われているらしい。
            var result = await _roleManager.DeleteAsync(role);

            if (result.Succeeded)
            {
                // 削除に成功したら Role/Index にリダイレクト
                return RedirectToAction("Index", "Role");
            }

            // result.Succeeded が false の場合 ModelSate にエ
            // ラー情報を追加しないとエラーメッセージが出ない。
            // Register.cshtml.cs のものをコピー
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, 
                                         error.Description);
            }
            // 削除に失敗した場合、削除画面を再描画
            var model = new RoleModel
            {
                Id = role.Id,
                Name = role.Name
            };

            return View(model);
        }
    }
}

登録済みユーザーへのロールのアサイン状況の表示、個々のユーザーへのロールの追加・削除を行うためのアクションメソッドは以下の通りです。実際は、上のコードの RoleController 内に実装しています(分けたのは単にその方が読みやすいと思ったからです)。

// 以下は:
// (1) UserWithRoles で登録済みユーザーの一覧と各ユーザーへの
//     ロールのアサイン状況を表示し、
// (2) Edit ボタンクリックで EditRoleAssignment に遷移し、当該
//     ユーザーへのロールのアサインを編集して保存
// ・・・を行うアクションメソッド。

// GET: Role/UserWithRoles
// ユーザー一覧と各ユーザーにアサインされているロールを表示
// Model は UserWithRoleInfo クラス
public async Task<IActionResult> UserWithRoles()
{
    var model = new List<UserWithRoleInfo>();

    // ToListAsync() を付与してここで DB からデータを取得して
    // DataReader を閉じておかないと、下の IsInRole メソッド
    // でエラーになるので注意
    var users = await _userManager.Users.
                OrderBy(user => user.UserName).ToListAsync();
    var roles = await _roleManager.Roles.
                OrderBy(role => role.Name).ToListAsync();

    foreach (MySQLIdentityUser user in users)
    {
        var info = new UserWithRoleInfo
        {
            UserId = user.Id,
            UserName = user.UserName,
            UserEmail = user.Email
        };

        foreach (IdentityRole role in roles)
        {
            RoleInfo roleInfo = new RoleInfo();
            roleInfo.RoleName = role.Name;
            roleInfo.IsInThisRole = await _userManager.
                                    IsInRoleAsync(user, role.Name);
            info.UserRoles.Add(roleInfo);
        }
        model.Add(info);
    }

    return View(model);
}

// GET: Role/EditRoleAssignment/Id
// 指定 Id のユーザーのロールへのアサインの編集
// Model は UserWithRoleInfo クラス
public async Task<IActionResult> EditRoleAssignment(string id)
{
    if (id == null)
    {
        return NotFound();
    }

    var user = await _userManager.FindByIdAsync(id);
    if (user == null)
    {
        return NotFound();
    }

    var model = new UserWithRoleInfo
    {
        UserId = user.Id,
        UserName = user.UserName,
        UserEmail = user.Email
    };

    // ToListAsync() を付与しておかないと下の IsInRole メソッド
    // で DataReader が閉じてないというエラーになる
    var roles = await _roleManager.Roles.
                  OrderBy(role => role.Name).ToListAsync();

    foreach (IdentityRole role in roles)
    {
        RoleInfo roleInfo = new RoleInfo();
        roleInfo.RoleName = role.Name;
        roleInfo.IsInThisRole = await _userManager.
                                IsInRoleAsync(user, role.Name);
        model.UserRoles.Add(roleInfo);
    }

    return View(model);
}

// POST: Role/EditRoleAssignment/Id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditRoleAssignment(string id, 
  [Bind("UserId,UserName,UserEmail,UserRoles")] UserWithRoleInfo model)
{
    if (id == null)
    {
        return NotFound();
    }

    // IsInRoleAsync, AddToRoleAsync, RemoveFromRoleAsync メソッド
    // の引数が MVC5 とは異なり、id ではなく MySQLIdentityUser が
    // 必要なのでここで取得しておく
    var user = await _userManager.FindByIdAsync(id);
    if (user == null)
    {
        return NotFound();
    }

    if (ModelState.IsValid)
    {
        IdentityResult result;

        foreach (RoleInfo roleInfo in model.UserRoles)
        {
            // id のユーザーが roleInfo.RoleName のロールに属して
            // いるか否か。以下でその情報が必要。
            bool isInRole = await _userManager.
                            IsInRoleAsync(user, roleInfo.RoleName);

            // roleInfo.IsInThisRole には編集画面でロールのチェッ
            // クボックスのチェック結果が格納されている
            if (roleInfo.IsInThisRole)
            {
                // チェックが入っていた場合

                // 既にロールにアサイン済みのユーザーを AddToRole
                // するとエラーになるので以下の判定が必要
                if (isInRole == false)
                {
                    result = await _userManager.AddToRoleAsync(user, 
                                                      roleInfo.RoleName);
                    if (!result.Succeeded)
                    {
                        // result.Succeeded が false の場合 ModelSate にエ
                        // ラー情報を追加しないとエラーメッセージが出ない。
                        // Register.cshtml.cs のものをコピー
                        foreach (var error in result.Errors)
                        {
                            ModelState.AddModelError(string.Empty, 
                                                     error.Description);
                        }
                        return View(model);
                    }
                }
            }
            else
            {
                // チェックが入っていなかった場合

                // ロールにアサインされてないユーザーを
                // RemoveFromRole するとエラーになるので以下の
                // 判定が必要
                if (isInRole == true)
                {
                    result = await _userManager.
                             RemoveFromRoleAsync(user, roleInfo.RoleName);
                    if (!result.Succeeded)
                    {
                        foreach (var error in result.Errors)
                        {
                            ModelState.AddModelError(string.Empty, 
                                                     error.Description);
                        }
                        return View(model);
                    }
                }
            }
        }

        // 編集に成功したら Role/UserWithRoles にリダイレクト
        return RedirectToAction("UserWithRoles", "Role");
    }

    // 編集に失敗した場合、編集画面を再描画
    return View(model);
}

(3) View の作成

View は Controller のアクションメソッドを右クリックして[ビューの追加(D)...]からスキャフォールディング機能を使って生成します。

Razor ビューの追加

アクションメソッド EditRoleAssignment に対応する View は自動生成されるコードだけでは不十分でいろいろ追加・修正が必要です。以下にその完成版のコードを載せておきます。

@model MySQLIdentity.Models.UserWithRoleInfo

@{
    ViewData["Title"] = "EditRoleAssignment";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>EditRoleAssignment</h1>

<h4>UserWithRoleInfo</h4>
<hr />
<form asp-action="EditRoleAssignment">
    <table class="table">
        <thead>
            <tr>
                <th>
                    @Html.DisplayNameFor(model => model.UserName)
                </th>
                @foreach (var role in Model.UserRoles)
                {
                    <th>
                        @Html.DisplayFor(modelItem => role.RoleName)
                    </th>
                }
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>
                    @Html.DisplayFor(model => model.UserName)
                    @Html.HiddenFor(model => model.UserName)
                    @Html.HiddenFor(model => model.UserEmail)
                    @Html.HiddenFor(model => model.UserId)
                </td>
                @for (int i = 0; i < Model.UserRoles.Count; i++)
                {
                    <td>
                        @Html.EditorFor(model => model.UserRoles[i].IsInThisRole)
                        @Html.HiddenFor(model => model.UserRoles[i].RoleName)
                    </td>
                }

            </tr>
        </tbody>
    </table>
    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary" />
    </div>
</form>

<div>
    <a asp-action="UserWithRoles">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

上の View のコードで注意すべき点は、コレクション(上の例では Model.UserRoles で取得できる RoleInfo クラスのコレクション) についてはレンダリングされる html 要素の name 属性が連番のインデックスを含むようにすることです。具体的には、name="prefix[index].Property" というパターンにします。

そうしないとモデルバインディングがうまくいきません。その理由など詳しくは先の記事「コレクションのデータアノテーション検証」を見てください。

Tags: , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  June 2020  >>
MoTuWeThFrSaSu
25262728293031
1234567
891011121314
15161718192021
22232425262728
293012345

View posts in large calendar