WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core 3.1 Web API の .NET 版との違い

by WebSurfer 15. May 2020 12:45

ASP.NET Web API の Core 3.1 版のアプリが .NET Framework 版のアプリと異なる点を備忘録としてまとめておきます。あくまで自分的に大きな違いがあると思っている点のみです。

  1. Controller は ControllerBase クラスを継承する。(.NET Framework の ApiController ではなく)
  2. Controller に ApiControllerAttribute 属性を付与する。
  3. ルーティングは Controller に RouteAttibute 属性を付与して設定する。(.NET Framework 版プロジェクトで生成される "api/{controller}/{id}" というようなルーティング設定はないようです)
  4. アクションメソッドに [HttpGet], [HttpGet("{id}")], [HttpPost] 等の属性を付与する。(付与しないと HTTP 動詞でどれを呼ぶかの判別ができないようです)
  5. アクションメソッドの引数、例えば [FromBody] string name の name に文字列をバインドする場合、.NET Framework 版では「=文字列」という文字列をコンテンツとして送信すれば可能でしたが、Core 版ではそれができない。
  6. .NET のクラスのプロパティ名の最初の文字は大文字にするのが普通ですが、大文字にしておいてもシリアライズされた結果の応答の JSON 文字列のキー名の最初の文字が小文字になる。

詳しくは Microsoft のドキュメント「ASP.NET Core を使って Web API を作成する」に書いてありますのでそちらを見てください。

コード例を示すと以下のようになります。先の記事「ASP.NET Web API のバインディング」に .NET Framework 版のコードがありますので、それと比較してみてください。

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using WebAPI.Models;

namespace WebAPI.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        private List<Hero> heroes = new List<Hero> {
              new Hero {Id = 1, Name = "スーパーマン"},
              new Hero {Id = 2, Name = "バットマン"},
              new Hero {Id = 3, Name = "ウェブマトリクスマン"},
              new Hero {Id = 4, Name = "チャッカマン"},
              new Hero {Id = 5, Name = "スライムマン"}
          };

        // GET api/values (Read...すべてのレコードを取得)
        [HttpGet]
        public List<Hero> Get()
        {
            return heroes;
        }

        // GET api/values/5 (Read...id 指定のレコード取得)
        [HttpGet("{id}")]
        public Hero Get(int id)
        {
            return heroes[id - 1];
        }

        // POST api/values  (Create...レコード追加)
        [HttpPost]
        public List<Hero> Post(Hero postedHero)
        {
            heroes.Add(postedHero);
            return heroes;
        }

        // PUT api/values/5 (Update...id 指定のレコード更新)
        [HttpPut("{id}")]
        public List<Hero> Put(int id, Hero postedHero)
        {
            heroes[id - 1].Name = postedHero.Name;
            return heroes;
        }

        // DELETE api/values/5 (Delete...id 指定のレコード削除)
        [HttpDelete("{id}")]
        public List<Hero> Delete(int id)
        {
            heroes.RemoveAt(id - 1);
            return heroes;
        }
    }
}

上に列挙した 5 番目の項目に書いた件を説明します。上のコード例の Put の場合を見てください。.NET Framework 版の記事ではアクションメソッドの引数を Put(int id, [FromBody] string name) とし、それを呼び出す jQuery ajax のコードでは data: encodeURI("=ガッチャマン") として無理やり(?)引数 name にバインドできていました。Core 版ではそれはできないようで、以下のコードのようにしないとダメでした。

// .NET Framework ベースとは異なり、以下のようにしないとダメ
function apiHeroesPut5() {
    var j = { Id: 5, Name: "ガッチャマン" };
    var jsonString = JSON.stringify(j);
    $.ajax({
        type: "PUT",
        url: "/values/5",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data) {
            $('#heroes').empty();
            $.each(data, function (key, val) {
                var str = val.id + ': ' + val.name;
                $('<li/>', { html: str }).appendTo($('#heroes'));
            });
        },
        error: function (jqXHR, textStatus, errorThrown) {
            $('#heroes').empty();
            $('#heroes').text('textStatus: ' + textStatus +
                ', errorThrown: ' + errorThrown);
        }
    });
}

理由は不明です。バインディング ソース パラメーター推論を読むと分かるかも。手抜きですみません。

最後に、上に列挙した 6 番目の項目に書いた、シリアライズされた結果の応答の JSON 文字列のキー名の最初の文字が小文字になる件を説明します。

上の 1 番目のコード例を見てください。Hero クラスのプロパティ名 Id, Name の最初の文字は大文字にしています。しかし、Web API が Hero オブジェクトを JSON 文字列にシリアライズすると何故かキー名は id, name というように小文字になります。

これは Camel Casing と言うそうです。Core 3.0 以降の MVC でも同様で、MVC や Web API のフレームワークでデフォルトでそのようにする設定がされているようです(コンソールアプリで試したときは Camel Casing にはなりませんでしたので、JsonSerializer クラス自体の設定ではなさそうです)。Camel Casing になるのを回避する方法はあります。詳しくは別の記事「JsonSerializer の Camel Casing」を見てください。

2 番目のコード例の success: function (data) の data には応答の JSON 文字列をデシリアライズした JavaScript オブジェクトが渡されますが、キー名から値を取得する際、最初の文字を小文字にしないと値を取得できず undefined になってしまいます。(なので上のコードでは val.id, val.name としています)

何を隠そう自分はここにハマって 1 ~ 2 時間悩みました。(笑)

Tags: ,

CORE

JSON シリアライズの際の循環参照エラー

by WebSurfer 8. March 2020 15:51

.NET Framework の ASP.NET MVC アプリや Web API アプリでオブジェクトを JSON 文字列にシリアライズするときの循環参照エラーの問題とその回避方法を書きます。

循環参照エラー

例えば、Entity Framework 6 の Code First 機能を利用して、以下のコード(Microsoft のチュートリアル「新しいデータベースへの Code First」のサンプルコードです)からデーターベースを生成し、Linq to Entities で取得したデータを JSON にシリアライズするケースを考えます。

public class Blog
{
    public int BlogId { get; set; }
    public string Name { get; set; }

    public virtual List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public virtual Blog Blog { get; set; }
}

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
}

以下のコードのように、Linq to Enitities を使ってデータベースからデータを取得し、ASP.NET MVC5 の Controller.Json メソッドを使って JSON 文字列にシリアライズしてみます。

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Mvc5App.DAL;
using System.Data.Entity;

namespace Mvc5App.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Json()
        {
            var db = new BloggingContext();
            var list = db.Blogs.Include(b => b.Posts);
            return Json(list, JsonRequestBehavior.AllowGet);
        }
    }
}

上のアクションメソッド Json を呼び出すと、この記事の上の画像の通り、循環参照エラーになります。

(注: ちなみに、上のコードを var list = db.Blogs; に変えるとシリアライズの際に遅延ローディングが起こって "この Command に関連付けられている DataReader が既に開かれています。このコマンドを最初に閉じる必要があります" というエラーになります。var list = db.Blogs.ToList(); とすると、シリアライズの際に遅延ローディングが起こるのは同じですが、その時は DataReader が閉じているので循環参照のところまで進んで、上の画像と同じエラーになります)

ASP.NET Web API でも同様で、return db.Blogs.Include(b => b.Posts); とするとシリアライズする際に循環参照が生じて JSON へのシリアライズに失敗します。

原因は Post クラスにナビゲーションプロパティ Blog が定義されているためで、それをシリアライズしようとすると循環参照が発生し InvalidOperationException がスローされるからだそうです。ググってヒットした記事「ASP.NET Web API で循環参照なモデルの公開を解決する」を参考にさせていただきました。

その記事に書いてありますが、.NET Framework の ASP.NET Web API の JSON シリアライザは Newtonsoft の Json.NET のもので、JsonIgnoreAttribute Class という属性が利用できます。なので、上の Post クラスの Blog プロパティに [JsonIgnore] を付与すればシリアライズの際の循環参照エラーは回避できます。

しかしながら、MVC5 アプリで利用する Controller.Json メソッドは内部で JavaScriptSerializer クラスを使っており、JsonIgnore 属性は効果がありません。

なので、Microsoft のドキュメント「Create Data Transfer Objects (DTOs)」を参考にして以下のコードのようにしてみました。

public class BlogDto
{
    public int BlogId { get; set; }
    public string Name { get; set; }
    public List<PostDto> Posts { get; set; }
}

public class PostDto
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public int BlogId { get; set; }
}

public ActionResult Json()
{
    var db = new BloggingContext();

    var list = db.Blogs.Include(b => b.Posts).
               Select(b => new BlogDto
               {
                   BlogId = b.BlogId,
                   Name = b.Name,
                   Posts = b.Posts.Select(p => new PostDto
                   {
                       PostId = p.PostId,
                       Title = p.Title,
                       Content = p.Content,
                       BlogId = p.BlogId
                   }).ToList()
               });

    return Json(list, JsonRequestBehavior.AllowGet);
}

ナビゲーションプロパティを除いた別の入れ物(上の BlogDto クラス、PostDto クラス)に入れてシリアライズしているわけですから、当然ながら循環参照の問題はなくなって JSON 文字列にシリアライズできます。

循環参照を避ける以外に、必要なデータだけを望む形で JSON 文字列にシリアライズするということも出来るわけですから、積極的に DTO を使うのが正解だと思います。

なお、.NET Core 3.0 以降には JsonIgnoreAttribute クラスが利用できるので、上の .NET Framework のケースとは話は違ってきて、MVC, Web API どちらの場合も循環参照になるプロパティに [JsonIgnore] を付与すればシリアライズできます。

(Microsoft のドキュメント Add Newtonsoft.Json-based JSON format support を見ると ASP.NET Core 2.x 以前は Newtonsoft.Json パッケージが、ASP.NET Core 3.x 以降は System.Text.Json が使われているそうです)

Tags: , , ,

MVC

ASP.NET Core Web API と JWT

by WebSurfer 11. February 2020 18:48

ASP.NET Core 3.1 Web API でトークンベース認証を実装してアクセス制限し、ユーザー認証に ASP.NET Core Identity のユーザー情報を利用する方法を書きます。

結果の表示

.NET Framework Web API の場合は、先の記事「ASP.NET Web API の認証」で書きましたように、Visua Studio のテンプレートを使って認証を「個別のユーザーアカウントカウント」として自動生成すればデフォルトでトークンベースの認証が実装され、認証のためのユーザー情報のストアには ASP.NET Identity が使用されます。

Core Web API では自力での実装が必要になります。テンプレートで認証に「個別のユーザーアカウントカウント」を選択すると「クラウドの既存のユーザーストアに接続する」しか選べません。なので、認証なしの状態から Core 2 からサポートされたという JSON Web Token (JWT) を使った認証を実装することにします。

基本的な方法は Auth0 というサイトのブログの記事「ASP.NET Core 2.0 アプリケーションを JWT でセキュアする」(以下「Auth0 の記事」と書きます)に書いてあるのでそれを見れば済む話なのですが、リンク切れになったりすると困るので要点およびその記事には書いてないことを備忘録として残しておきます。

(1) プロジェクトの作成

元になる ASP.NET Core 3.1 Web API アプリのプロジェクトは Visual Studio Community 2019 のテンプレートで自動生成されたものを使います。以下の画像を見てください。認証は「なし」にしておきます。

プロジェクトの作成

テンプレートで自動生成したプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行([デバッグ(D)]⇒[デバッグなしで開始(H)])すると JSON 文字列が返ってきます。

そのアクションメソッド Get() に JWT ベースの認証を実装します(即ち、トークンが無いとアクセス拒否するようにします)。

(2) NuGet パッケージのインストール

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。青枠で囲んだものは、下に述べ���ユーザー認証に ASP.NET Core Identity のユーザー情報を利用する場合に必要になります。

NuGet パッケージのインストール

(3) JWT 認証スキーマを登録

自動生成された Startup.cs のコードの ConfigureServices メソッドで、AddAuthentication メソッドを使って JWT 認証スキーマを登録します。コードは Auth0 の記事のものをそのままコピペすれば OK です。using 句の追加を忘れないようにしてください。

さらに、認証を有効にするため Configure メソッドに app.UseAuthentication(); を追加します。既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

具体的には以下のコードで「JWT ベースの認証を行うため追加」とコメントしたコードを追加します。

// ・・・前略・・・

// JWT ベースの認証を行うため追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

//・・・中略・・・

  public void ConfigureServices(IServiceCollection services)
  {
    // JWT ベースの認証を行うため追加
    services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
          options.TokenValidationParameters = 
            new TokenValidationParameters
            {
              ValidateIssuer = true,
              ValidateAudience = true,
              ValidateLifetime = true,
              ValidateIssuerSigningKey = true,
              ValidIssuer = Configuration["Jwt:Issuer"],
              ValidAudience = Configuration["Jwt:Issuer"],
              IssuerSigningKey = new SymmetricSecurityKey(
                  Encoding.UTF8.GetBytes(
                      Configuration["Jwt:Key"]))
            };
        });

    services.AddControllers();
  }

  public void Configure(IApplicationBuilder app, 
            IWebHostEnvironment env)
  {
    // ・・・中略・・・

    // JWT ベースの認証を行うため追加
    app.UseAuthentication();

    app.UseAuthorization();

    //・・・後略・・・

(4) Key と Issuer を appsettings.json に登録

上の (3) コードでは Key と Issuer を appsettings.json ファイルより取得するようにしていますので、以下のように "Jwt" 要素を追加します。

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKey",
    "Issuer": "https://localhost:44330"
  }
}

既存の "AllowedHosts": "*" の後にカンマ , を追加するのを忘れないようにしてください。Key はパスワードのようなもので任意の文字列を設定できます(16 文字以上にしないとエラーになるようです)。Issuer はサービスを行う URL にします。

(5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずです。

(6) トークンを取得する API を実装

ユーザーの ID とパスワードを送信してトークンを取得する API を実装します。基本的には Auth0 の記事のコントローラ TokenController の通りですが、それを拡張してユーザー情報を既存の ASP.NET Core Identity のデータベースから取得して認証を行うようにしてみました。

コントローラ TokenController のコードは以下の通りです。UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 以下を追加
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.AspNetCore.Identity;

namespace WebApiJwtIdentity.Controllers
{
  [Route("api/[controller]")]
  [ApiController]
  public class TokenController : ControllerBase
  {
    private readonly IConfiguration _config;
    private readonly UserManager<IdentityUser> _userManager;

    public TokenController(IConfiguration config, 
            UserManager<IdentityUser> userManager)
    {
      _config = config;
      _userManager = userManager;
    }

    [AllowAnonymous]
    [HttpPost]
    public async Task<IActionResult> CreateToken(
                                        LoginModel login)
    {
      string id = login.Username;
      string pw = login.Password;
      IActionResult response = Unauthorized();
      var user = await _userManager.FindByNameAsync(id);
      if (user != null && 
          await _userManager.CheckPasswordAsync(user, pw))
      {
        var tokenString = BuildToken();
        response = Ok(new { token = tokenString });
      }

      return response;
    }

    private string BuildToken()
    {
      var key = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
      var creds = new SigningCredentials(
          key, SecurityAlgorithms.HmacSha256);

      var token = new JwtSecurityToken(
        _config["Jwt:Issuer"],
        _config["Jwt:Issuer"],
        expires: DateTime.Now.AddMinutes(30),
        signingCredentials: creds);

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

  public class LoginModel
  {
    public string Username { get; set; }
    public string Password { get; set; }
  }
}

上記のコード以外にも以下の追加が必要です。

(a) 上の (2) の画像で青枠で囲んだ NuGet パッケージのインストール。

(b) IdentityDbContext を継承した ApplicationDbContext クラスを追加。Data フォルダを作ってそれにクラスファイルとして実装します。

using System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApiJwtIdentity.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

(c) appsettings.json に ASP.NET Core Identity が使う既存の SQL Server DB への接続文字列を追加。

(d) Startup.cs に以下を追加。

// 追加
using Microsoft.AspNetCore.Identity;
using WebApiJwtIdentity.Data;
using Microsoft.EntityFrameworkCore;

public void ConfigureServices(IServiceCollection services)
{
  // 追加
  services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
      Configuration.GetConnectionString(
        "MvcCoreIdentityContextConnection")));

  // 追加
  services.AddDefaultIdentity<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

(7) 検証用 Home/Index ページを追加

以下は必須ではないですが、検証用の Home/Index ページを追加し、そこから jQuery ajax を使ってトークンの取得と認証が期待通りとなるかを確認してみます。

View のコードは以下のようになります。下のコードの Username と Password には "***" ではなくて有効な文字列を設定してください。このページを使って確認した結果が一番上の画像です。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <title>Index</title>
  <script src="~/Scripts/jquery.js"></script>
  <script type="text/javascript">
  //<![CDATA[
    var tokenKey = 'accessToken';

    function getToken() {            
      var obj = { Username : "***", Password : "***" };
      var jsonString = JSON.stringify(obj);
      $.ajax({
        type: "POST",
        url: "/api/token",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data, textStatus, jqXHR) {
          sessionStorage.setItem(tokenKey, data.token);
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function weatherForecast() {
      var token = sessionStorage.getItem(tokenKey);
      var headers = {};
      if (token) {
        headers.Authorization = 'Bearer ' + token;
      }

      $.ajax({
        type: "GET",
        url: "/WeatherForecast",
        headers: headers,
        cache: false,
        success: function (data, textStatus, jqXHR) {
          $('#output').empty();
          $.each(data, function (key, val) {
            var day = new Date(val.date);
            var dateString = day.getFullYear() + "年" +
                (day.getMonth() + 1) + "月" +
                day.getDate() + "日";
            $('#output').append(
              '<p>' + dateString + ' / ' +
              val.temperatureC + ' / ' +
              val.temperatureF + ' / ' +
              val.summary + '</p >');
          });                    
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

  //]]>
  </script>
</head >
<body>
    <h3>Web API から jQuery ajax を使ってデータの取得</h3>

    <input type="button" id="Button1" 
      value="WeatherForecast" onclick="weatherForecast();" />
    <input type="button" id="Button2" 
      value="Get Token" onclick="getToken();" />

    <hr />
    <p>結果の表示:</p>
    <div id="output"></div>
</body>
</html >

なお、元々のプロジェクトの設定が Web API 用ですので、そのままでは MVC 用の Controller と View は動きませんので注意してください。以下の設定が必要になります。

(a) Startup.cs で MVC 用のサービスの追加、静的ファイルの利用を可能にすること、ルーティングのためのマップ設定。

(b) launchSettings.json で "launchUrl" の "weatherforecast" を "home/index" に変更。

(c) jQuery を利用するので wwwroot/Script/jQuery.js を追加。

Tags: , , ,

CORE

About this blog

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

Calendar

<<  June 2021  >>
MoTuWeThFrSaSu
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar