佐世保観光

今年こそは夏期休暇らしいことをしたいと思い、佐世保にある『九十九島パールシーリゾート』に行ってきた。道中、尋常じゃない雨が降ってどうなるかとヒヤヒヤしたが、佐世保は晴れていて安心した。

お目当は水族館『海きらら』。 コンビニで前売り券を買っておいたので、100円安く入れた。

大水槽。

イルカショーは観客が多くて立ち見だった。30分以上前から場所取りしていたみたい。この暑い中、自分には無理だ。ジャンプは何とか見れたので良しとしよう。

客席の方は水しぶきをかなり浴びたみたいだ。

海きららはクラゲにも力を入れてるみたい。

海きららを見終わって、遅めの昼食を食べたら、もう16時過ぎ。福岡に帰るために佐世保駅へ向かった。

電車は高いので、高速バスを予約。バスの時間まで1時間以上あるので、佐世保駅の周辺を散策することにした。

佐世保駅のすぐ近くに立派な『カトリック三浦町教会』が。高台にあって雰囲気ある。

佐世保駅のすぐ裏には『させぼ五番街』があった。入ってるテナントは福岡のキャナルシティや木の葉モールと似た傾向。

すぐ側が海でムードがあった。

港も。

佐世保バーガーの有名店『ヒカリ』が出店していたのに、運悪く腹減って無くて食べなかったのが心残り。あと四ヶ町アーケードにも行けなかった。次の機会があれば行きたい。

中華そば かなで

最近は豚骨よりも醤油を好む傾向になってきた。こってりが辛くなったのは、もう若くないということだろうか。今回行ったのは、東比恵にある『中華そば かなで』。

中華そばを注文。

多加水麺はプチっとした食感が新鮮。 チャーシューは低温調理してあって、 チャーシューとは思えないジューシーさだった。 清湯鶏がらスープに古式しょうゆを合わせたスープは、あっさりでいて、味が奥深い。 中華そばとして完成されている印象。 福岡でこんな醤油が食べられるとはね。

中華そば かなで

食べログ 中華そば かなで

Microsoft.AspNetCore.Mvc.ApiExplorer を使って Web API ドキュメントを自作

開発している Web APIASP.NET Core 2.0 に移行しようと思っていたが、Web API のドキュメント生成に使っている Swashbuckle.AspNetCore がまだ 2.0 に対していないっぽくて一時中断。

ASP.NET Core MVC にはもとから Microsoft.AspNetCore.Mvc.ApiExplorer というのが提供されていて、これを使うことでちょっとした Web API のドキュメントを出力できる。ただ、SwaggerUI ほど高機能では無いし、Web API ドキュメントを表示するためのコントローラーとビューを自分で書く必要があるみたいだ。手軽ではない。

試してみたコードがこちら。

using System.Collections.Generic;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace SampleApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // AddMvc 内で ApiExplorer も登録されている
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseMvcWithDefaultRoute();
        }
    }

    [Route("api/[controller]")]
    [ApiExplorerSettings(GroupName = "Values API", IgnoreApi = false)]
    public class ValuesController : Controller
    {
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }

        [HttpGet("{id}")]
        public string Get(int id)
        {
            return "value";
        }

        [HttpPost]
        public void Post([FromBody]string value)
        {
        }

        [HttpPut("{id}")]
        public void Put(int id, [FromBody]string value)
        {
        }

        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }

    public class HomeController : Controller
    {
        readonly IApiDescriptionGroupCollectionProvider _apiExplorer;

        public HomeController(IApiDescriptionGroupCollectionProvider apiExplorer)
        {
            _apiExplorer = apiExplorer;
        }

        // API ドキュメントを表示する
        public IActionResult Index()
        {
            return View(_apiExplorer);
        }
    }
}

Web API のドキュメントを表示する Home/Index ビュー。

@using Microsoft.AspNetCore.Mvc.ApiExplorer
@model IApiDescriptionGroupCollectionProvider

<html>
<head>
    <meta charset="utf-8" />
    <title>Sample API</title>
</head>
<body>
    <h1>Sample API ドキュメント</h1>

    @foreach (var group in Model.ApiDescriptionGroups.Items)
    {
        <h2>@group.GroupName</h2>

        @foreach (var api in group.Items)
        {
            <h3>@api.HttpMethod @api.RelativePath</h3>

            <div class="parameters">
                <h4>パラメーター</h4>
                @if (0 < api.ParameterDescriptions.Count)
                    {
                    <table>
                        <thead>
                            <tr>
                                <th>名前</th>
                                <th></th>
                                <th>Constrains</th>
                                <th>デフォルト値</th>
                                <th>必須</th>
                            </tr>
                        </thead>
                        <tbody>
                            @foreach (var parameter in api.ParameterDescriptions)
                            {
                                <tr>
                                    <td>@parameter.Name,  (@parameter.Source.Id)</td>
                                    <td>@parameter.Type?.FullName</td>

                                    @if (parameter.RouteInfo != null)
                                    {
                                        <td>@string.Join(",", parameter.RouteInfo.Constraints?.Select(c => c.GetType().Name).ToArray())</td>
                                        <td>@parameter.RouteInfo.DefaultValue</td>
                                        <td>
                                            @if (parameter.RouteInfo.IsOptional == true)
                                            {
                                                <text></text>
                                            }
                                        </td>
                                    }
                                    else
                                    {
                                        <td></td>
                                        <td></td>
                                        <td></td>
                                    }
                                </tr>
                            }
                        </tbody>
                    </table>
                }
                else
                {
                    <i>なし</i>
                }
            </div>

            <div class="responses">
                <h4>レスポンス</h4>

                <table>
                    <thead>
                        <tr>
                            <th>ステータスコード</th>
                            <th></th>
                            <th>メディアタイプ</th>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach (var response in api.SupportedResponseTypes)
                        {
                            <tr>
                                <td>@response.StatusCode</td>
                                <td>@response.Type?.FullName</td>
                                <td>
                                    <ul>
                                        @foreach (var responseFormat in response.ApiResponseFormats)
                                        {
                                            <li>@responseFormat.MediaType</li>
                                        }
                                    </ul>
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
    }
</body>
</html>

実行すると、こんなドキュメントが表示される。

f:id:griefworker:20170817141951p:plain

取得した Web API の情報を自分で好きなように出力するので、自由度は高い。でもやっぱり Swagger UI の方がいいな。

2017 年 8 月 23 日訂正

ASP.NET Core 2.0 で Swashbuckle.AspNetCore 使えました。

『服を着るならこんなふうに(5)』を読んだ

5巻では、体型を隠すコーディネートと、結婚式の二次会なんかで使えるスマートカジュアルについて解説してあったが、自分には使えそうにない。

体型は今のところキープできているし、 周りはほとんど結婚してしまったので、 結婚式の二次会に行くことも無さそう。

セットアップはコーディネートの幅が広がるので、持っておくと便利そうではある。安い買い物ではないので、すぐにとはいかないが。

『王様達のヴァイキング(1)〜(13)』を読んだ

Podcast で IT エンジニアにお薦めと聴いて、Kindle で一気に読んだ。

天才的なハッカーでクラッカーでもある主人公の是枝が、もう1人の主人公であるエンジェル投資家の坂井と組んで、 世界征服を目指す。実際は、是枝のクラッカーとしての能力を生かして、サイバー犯罪と戦うマンガ。

出てくるサイバー犯罪は、どれも現実に起きておかしくないもので、これはないなと思うことがなかった。Web サービスを開発運用する身として、読んでて身が引き締まった。IT エンジニアにお薦めというのも納得。

是枝の宿敵と言えるキャラも出てきて、幾度となく熱いサイバーバトルを繰り広げるが、13 巻ともなると最終決戦かというくらいの盛り上がり。まさかこんな展開になるとは。熱い。熱すぎる。続きが気になって仕方ない。

あと、坂井がいいね。第一印象はめっちゃ胡散臭かったけど、すぐにお気に入りになった。脳みそが若かったりアンテナが高いところはもちろん良いが、それよりも、自分を馬鹿にしている後輩に対して、そういう気概があるべきだと言えるところが格好良い。 こんなアラフォーになりたいと思った。

.NET Core で Shift-JIS を扱う

例えば .NET Core でテキストファイルを出力するとき、文字コードに Shift-JIS を指定するには System.Text.Encoding.CodePages が必要だった。

www.nuget.org

using System;
using System.IO;
using System.Text;

namespace EncodingSample
{
    class Program
    {
        static void Main(string[] args)
        {
            // エンコードプロバイダーの登録が必要
            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

            var path = Path.Combine(".", "result.txt");
            using (var stream = File.OpenWrite(path))
            {
                // エンコードプロバイダーを登録していなかったら
                // ArgumentException が発生してしまう
                using (var writer = new StreamWriter(stream, Encoding.GetEncoding("shift_jis")))
                {
                    writer.WriteLine("Hello,World!");
                }
            }

            Console.WriteLine("Enter で終了");
            Console.ReadLine();
        }
    }
}

ASP.NET Core MVC で JWT を使った認証を実装する

以前、ASP.NET Core MVC で Basic 認証を行う記事を書いた。

tnakamura.hatenablog.com

プライベートな Web API なのでこれでいいかなと思っていたが、 大人の事情でそうはいかなくなり、 JWT(Json Web Token) を使った認証に変えることに。

Microsoft.AspNetCore.Authentication.JwtBearer パッケージを使って実装できそうだったので、 サンプルを書いてみた。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;

namespace JwtSample
{
    // アプリケーションの構成
    // 本来は application.json か環境変数に持たせるべきだが、
    // 今回は簡略化のために static クラスにしておく。
    public static class AppConfiguration
    {
        public static string SiteUrl { get; } = "http://localhost:5000";

        // JWT の署名で使う秘密鍵
        public static string SecretKey { get; } = Guid.NewGuid().ToString();
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            var host = new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup<Startup>()
                .UseApplicationInsights()
                .UseUrls(AppConfiguration.SiteUrl)
                .Build();

            host.Run();
        }
    }

    public class Startup
    {
        public Startup(IHostingEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
                .AddEnvironmentVariables();
            Configuration = builder.Build();
        }

        public IConfigurationRoot Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            loggerFactory.AddDebug();

            app.UseJwtBearerAuthentication(new JwtBearerOptions
            {
                // 着信したトークンの意図された受信者、またはトークンがアクセスを許可
                // するリソースを表す。
                // このパラメータで指定された値がトークンの aud パラメータと一致しない
                // 場合、トークンは別のリソースにアクセスするために使用されたため、
                // 拒否される。
                Audience = AppConfiguration.SiteUrl,
                
                // トークンによって定義されたユーザーが自動的にログインするかどうか
                AutomaticAuthenticate = true,

                TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    ValidateIssuer = true,
                    ValidIssuer = AppConfiguration.SiteUrl,
                    IssuerSigningKey = new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(AppConfiguration.SecretKey)),
                }
            });

            app.UseMvc();
        }
    }

    // ユーザー
    public class User
    {
        public string Id { get; set; }

        public string UserName { get; set; }

        public string Password { get; set; }
    }

    // パスワードを除いたユーザー情報を格納する
    public class UserViewModel
    {
        public string Id { get; set; }

        public string UserName { get; set; }
    }

    // トークンを取得するために渡された認証情報を格納する
    public class TokenInputModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        public string Password { get; set; }
    }

    // 生成したトークンと有効期限を格納する
    public class TokenViewModel
    {
        public string Token { get; set; }

        public DateTime Expiration { get; set; }
    }

    [Route("api")]
    public class HomeController : Controller
    {
        // テスト用ユーザー
        static List<User> _testUsers = new List<User>()
        {
            new User()
            {
                Id = Guid.NewGuid().ToString(),
                UserName = "tnakamura",
                Password = "test1234",
            }
        };

        [HttpPost("token")]
        public IActionResult Token([FromBody]TokenInputModel inputModel)
        {
            if (ModelState.IsValid)
            {
                var user = _testUsers.FirstOrDefault(u => u.UserName == inputModel.UserName);
                if (user != null && user.Password == inputModel.Password)
                {
                    var token = CreateJwtSecurityToken(user);
                    return Ok(new TokenViewModel
                    {
                        Token = new JwtSecurityTokenHandler().WriteToken(token),
                        Expiration = token.ValidTo,
                    });
                }
            }
            return BadRequest();
        }

        JwtSecurityToken CreateJwtSecurityToken(User user)
        {
            // JWT に含めるクレーム
            var claims = new List<Claim>()
            {
                // JwtBeaerAuthentication 用
                new Claim(JwtRegisteredClaimNames.Jti, user.Id),
                new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
                // User.Identity プロパティ用
                new Claim(ClaimTypes.Sid, user.Id),
                new Claim(ClaimTypes.Name, user.UserName),
            };

            var token = new JwtSecurityToken(
                issuer: AppConfiguration.SiteUrl,
                audience: AppConfiguration.SiteUrl,
                claims: claims,
                expires: DateTime.UtcNow.AddDays(7),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(AppConfiguration.SecretKey)),
                    SecurityAlgorithms.HmacSha256
                )
            );

            return token;
        }

        [Authorize]
        [HttpGet("me")]
        public IActionResult Me()
        {
            var id = User.Claims.First(c => c.Type == ClaimTypes.Sid).Value;
            var user = _testUsers.First(u => u.Id == id);
            return Ok(new UserViewModel
            {
                Id = user.Id,
                UserName = user.UserName,
            });
        }
    }
}

curl を使って動作を確認してみる。 まずは JWT を取得せずに、認証が必要な Web API を呼び出してみると、当然ながら 401 が返ってくる。

$ curl -i -s http://localhost:5000/api/me
HTTP/1.1 401 Unauthorized
Date: Fri, 04 Aug 2017 04:47:20 GMT
Content-Length: 0
Server: Kestrel
WWW-Authenticate: Bearer

次は JWT を取得。

$ curl -i -s -H 'Content-Type: application/json' --data '{ "userName": "tnakamura", "password": "test1234" }' http://localhost:5000/api/token
HTTP/1.1 200 OK
Date: Fri, 04 Aug 2017 04:49:44 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Transfer-Encoding: chunked

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI0ZjkxMWM0Mi05YzNjLTQwYjMtYmNlNC0zMzU2NDA3MDI0ZWEiLCJzdWIiOiJ0bmFrYW11cmEiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9zaWQiOiI0ZjkxMWM0Mi05YzNjLTQwYjMtYmNlNC0zMzU2NDA3MDI0ZWEiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidG5ha2FtdXJhIiwiZXhwIjoxNTAyNDI2OTg0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ.HlDiTrEvEeSjf4a8oAsVAJS-h5BZNPxCf4hBLkNVR8E","expiration":"2017-08-11T04:49:44Z"}

取得した JWT を Authorization ヘッダーに指定して、再び認証が必要な Web API を呼び出すと成功する。

$ curl -i -s -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI0ZjkxMWM0Mi05YzNjLTQwYjMtYmNlNC0zMzU2NDA3MDI0ZWEiLCJzdWIiOiJ0bmFrYW11cmEiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9zaWQiOiI0ZjkxMWM0Mi05YzNjLTQwYjMtYmNlNC0zMzU2NDA3MDI0ZWEiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidG5ha2FtdXJhIiwiZXhwIjoxNTAyNDI2OTg0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ.HlDiTrEvEeSjf4a8oAsVAJS-h5BZNPxCf4hBLkNVR8E' http://localhost:5000/api/me
HTTP/1.1 200 OK
Date: Fri, 04 Aug 2017 04:51:29 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Transfer-Encoding: chunked

{"id":"4f911c42-9c3c-40b3-bce4-3356407024ea","userName":"tnakamura"}

JWT を使った認証のメドは立ったかな。