Swagger definition からクライアントとサーバーのソースコードを生成してみた

前回は簡単な Web API の Swagger definition を書いただけで終わってしまったけど、 ここからが本題。

やりたいのは、Swagger definition からクライアントとサーバーのソースコード生成すること。 そのためのツールとして swagger-codegen が提供されている。

http://swagger.io/swagger-codegen/swagger.io

お試しなので、Homebrew や mvn を使わずに直接 jar をダウンロードしてしまおう。

http://repo1.maven.org/maven2/io/swagger/swagger-codegen-cli/2.1.5/swagger-codegen-cli-2.1.5.jar

前回の Swagger definition を sample_api.yml として保存し、 まずは API クライアントを生成してみる。

java -jar swagger-codegen-cli-2.1.5.jar generate \
  -i sample_api.yml \
  -l csharp \
  -o SampleApiClient

を実行すると、RestSharp を使った API クライアントのソースコードが生成された。 一部抜粋すると、こんな感じ。

using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using RestSharp;
using IO.Swagger.Client;
using IO.Swagger.Model;

namespace IO.Swagger.Api
{
    // ...(IDefaultApi の定義は省略)...

    /// <summary>
    /// Represents a collection of functions to interact with the API endpoints
    /// </summary>
    public class DefaultApi : IDefaultApi
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="DefaultApi"/> class.
        /// </summary>
        /// <returns></returns>
        public DefaultApi(String basePath)
        {
            this.Configuration = new Configuration(new ApiClient(basePath));
        }
    
        /// <summary>
        /// Initializes a new instance of the <see cref="DefaultApi"/> class
        /// using Configuration object
        /// </summary>
        /// <param name="configuration">An instance of Configuration</param>
        /// <returns></returns>
        public DefaultApi(Configuration configuration = null)
        {
            if (configuration == null) // use the default one in Configuration
                this.Configuration = Configuration.Default; 
            else
                this.Configuration = configuration;
        }

        /// <summary>
        /// Gets the base path of the API client.
        /// </summary>
        /// <value>The base path</value>
        public String GetBasePath()
        {
            return this.Configuration.ApiClient.RestClient.BaseUrl.ToString();
        }

        /// <summary>
        /// Sets the base path of the API client.
        /// </summary>
        /// <value>The base path</value>
        [Obsolete("SetBasePath is deprecated, please do 'Configuraiton.ApiClient = new ApiClient(\"http://new-path\")' instead.")]
        public void SetBasePath(String basePath)
        {
            // do nothing
        }
    
        /// <summary>
        /// Gets or sets the configuration object
        /// </summary>
        /// <value>An instance of the Configuration</value>
        public Configuration Configuration {get; set;}

        /// <summary>
        /// Gets the default header.
        /// </summary>
        /// <returns>Dictionary of HTTP header</returns>
        [Obsolete("DefaultHeader is deprecated, please use Configuration.DefaultHeader instead.")]
        public Dictionary<String, String> DefaultHeader()
        {
            return this.Configuration.DefaultHeader;
        }

        /// <summary>
        /// Add default header.
        /// </summary>
        /// <param name="key">Header field name.</param>
        /// <param name="value">Header field value.</param>
        /// <returns></returns>
        [Obsolete("AddDefaultHeader is deprecated, please use Configuration.AddDefaultHeader instead.")]
        public void AddDefaultHeader(string key, string value)
        {
            this.Configuration.AddDefaultHeader(key, value);
        }
   
        
        /// <summary>
        /// ユーザー一覧取得 ユーザー一覧を取得します。
        /// </summary>
        /// <param name="page">ページ番号</param> 
        /// <returns>List&lt;User&gt;</returns>
        public List<User> UsersGet (int? page = null)
        {
             ApiResponse<List<User>> response = UsersGetWithHttpInfo(page);
             return response.Data;
        }

        /// <summary>
        /// ユーザー一覧取得 ユーザー一覧を取得します。
        /// </summary>
        /// <param name="page">ページ番号</param> 
        /// <returns>ApiResponse of List&lt;User&gt;</returns>
        public ApiResponse< List<User> > UsersGetWithHttpInfo (int? page = null)
        {
            
    
            var path_ = "/users";
    
            var pathParams = new Dictionary<String, String>();
            var queryParams = new Dictionary<String, String>();
            var headerParams = new Dictionary<String, String>(Configuration.DefaultHeader);
            var formParams = new Dictionary<String, String>();
            var fileParams = new Dictionary<String, FileParameter>();
            String postBody = null;

            // to determine the Accept header
            String[] http_header_accepts = new String[] {
                "application/json"
            };
            String http_header_accept = Configuration.ApiClient.SelectHeaderAccept(http_header_accepts);
            if (http_header_accept != null)
                headerParams.Add("Accept", Configuration.ApiClient.SelectHeaderAccept(http_header_accepts));

            // set "format" to json by default
            // e.g. /pet/{petId}.{format} becomes /pet/{petId}.json
            pathParams.Add("format", "json");
            
            if (page != null) queryParams.Add("page", Configuration.ApiClient.ParameterToString(page)); // query parameter
            
            
            
            

            
    
            // make the HTTP request
            IRestResponse response = (IRestResponse) Configuration.ApiClient.CallApi(path_, Method.GET, queryParams, postBody, headerParams, formParams, fileParams, pathParams);

            int statusCode = (int) response.StatusCode;
    
            if (statusCode >= 400)
                throw new ApiException (statusCode, "Error calling UsersGet: " + response.Content, response.Content);
            else if (statusCode == 0)
                throw new ApiException (statusCode, "Error calling UsersGet: " + response.ErrorMessage, response.ErrorMessage);
    
            return new ApiResponse<List<User>>(statusCode,
                response.Headers.ToDictionary(x => x.Name, x => x.Value.ToString()),
                (List<User>) Configuration.ApiClient.Deserialize(response, typeof(List<User>)));
            
        }


        // ...(以下 Web API を呼び出すメソッドが続く)...
    }
}

次はサーバー側。 README によると、ASP.NET Core のプロジェクトを生成できるようだ(名前は ASP.NET 5 のままになっているけど)。

java -jar swagger-codegen-cli-2.1.5.jar generate \
  -i sample_api.yml \
  -l aspnet5 \
  -o SampleApiServer

を実行したら、aspnet5 には対応してないっていうエラーになった。 …ASP.NET Core 対応が入ったの 1 ヶ月前だけど、2.1.5 がリリースされたのは 1 月 7 日だった。 含まれてなかったのか。

mvn でビルドするしかないかと思っていたら、 Swagger Editor でも ASP.NET Core のソースコードを生成できるではないですか。

メニューをクリックするとソースコードのアーカイブをダウンロードできた。 一部抜粋すると、こんな感じ。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc;
using Newtonsoft.Json;
using Swashbuckle.SwaggerGen.Annotations;
using IO.Swagger.Models;

namespace IO.Swagger.Controllers
{ 
    /// <summary>
    /// 
    /// </summary>
    public class DefaultApiController : Controller
    { 

        /// <summary>
        /// ユーザー一覧取得
        /// </summary>
        /// <remarks>ユーザー一覧を取得します。</remarks>
        /// <param name="page">ページ番号</param>
        /// <response code="200">OK</response>
        [HttpGet]
        [Route("/users")]
        [SwaggerOperation("UsersGet")]
        [SwaggerResponse(200, type: typeof(List<User>))]
        public IActionResult UsersGet([FromQuery]int? page)
        { 
            string exampleJson = null;
            
            var example = exampleJson != null
            ? JsonConvert.DeserializeObject<List<User>>(exampleJson)
            : default(List<User>);
            
            return new ObjectResult(example);
        }


        /// <summary>
        /// ユーザー登録
        /// </summary>
        /// <remarks>ユーザーを 1 件登録します。</remarks>
        /// <param name="name">ユーザー名</param>
        /// <response code="200">OK</response>
        [HttpPost]
        [Route("/users")]
        [SwaggerOperation("UsersPost")]
        [SwaggerResponse(200, type: typeof(User))]
        public IActionResult UsersPost([FromForm]string name)
        { 
            string exampleJson = null;
            
            var example = exampleJson != null
            ? JsonConvert.DeserializeObject<User>(exampleJson)
            : default(User);
            
            return new ObjectResult(example);
        }


        /// <summary>
        /// ユーザー取得
        /// </summary>
        /// <remarks>ユーザーを 1 件取得します。</remarks>
        /// <param name="userId">ユーザー ID</param>
        /// <response code="200">OK</response>
        [HttpGet]
        [Route("/users/{userId}")]
        [SwaggerOperation("UsersUserIdGet")]
        [SwaggerResponse(200, type: typeof(User))]
        public IActionResult UsersUserIdGet([FromRoute]string userId)
        { 
            string exampleJson = null;
            
            var example = exampleJson != null
            ? JsonConvert.DeserializeObject<User>(exampleJson)
            : default(User);
            
            return new ObjectResult(example);
        }


        /// <summary>
        /// ユーザー更新
        /// </summary>
        /// <remarks>ユーザーを 1 件更新します。</remarks>
        /// <param name="userId">ユーザー ID</param>
        /// <param name="name">ユーザー名</param>
        /// <response code="200">OK</response>
        [HttpPut]
        [Route("/users/{userId}")]
        [SwaggerOperation("UsersUserIdPut")]
        public void UsersUserIdPut([FromRoute]string userId, [FromForm]string name)
        { 
            throw new NotImplementedException();
        }


        /// <summary>
        /// ユーザー削除
        /// </summary>
        /// <remarks>ユーザーを 1 件削除します。</remarks>
        /// <param name="userId">ユーザー ID</param>
        /// <response code="200">OK</response>
        [HttpDelete]
        [Route("/users/{userId}")]
        [SwaggerOperation("UsersUserIdDelete")]
        public void UsersUserIdDelete([FromRoute]string userId)
        { 
            throw new NotImplementedException();
        }
    }
}

サーバー側は Swashbuckle に依存していた。 アクションの定義だけはあるけど、中身は実装してないに等しい。 せめて partial メソッドとか使っていればいいのに。

デモ版 Swagger Editor で生成できるソースコードに不満が無い場合は、 わざわざ swagger-codegen をインストールしなくてもよさそうだ。

ソースコードを生成するテンプレートをいじりたいときや、生成ロジックをカスタマイズしたいときに、 swagger-codegen を使うことになりそう。 とりあえず ASP.NET Core のテンプレートはカスタマイズが必要だな。

Web API の Swagger definition を書いてみた

Single Page Application(SPA) の開発を効率化できないか検討している。 具体的にはクライアント側と Web API 側を平行に開発したい。

Web API のインタフェースを定義しておいて、 Web API が形になるまではスタブを使ってクライアント側を開発すれば、 平行に開発できそうだ。 これは誰でも思いつくやり方。

そこで白羽の矢が立ったのが Swagger。

http://swagger.io/swagger.io

Swagger を使ってみた記事はネットでたくさん見つかったけど、 その多くが既存の Web API から Swagger を使ってドキュメントを生成するものだった。 やりたいのは Web API の Swagger definition を書いて、 Swagger を使ってドキュメントとソースコードの生成。

試しに、ユーザーの CRUD を行う Web API の Swagger definition を書いてみた。

swagger: '2.0'
info:
  title: Sample API
  version: "1.0.0"
basePath: /api/v1
produces:
  - application/json
paths:
  /users:
    get:
      summary: ユーザー一覧取得
      description: ユーザー一覧を取得します。
      parameters:
        - name: page
          description: ページ番号
          in: query
          required: false
          type: integer
      responses:
        200:
          description: OK
          schema:
            type: array
            items:
              $ref: '#/definitions/User'
    post:
      summary: ユーザー登録
      description: ユーザーを 1 件登録します。
      parameters:
        - name: name
          description: ユーザー名
          in: formData
          required: true
          type: string
      responses:
        200:
          description: OK
          schema:
            $ref: '#/definitions/User'
  /users/{userId}:
    get:
      summary: ユーザー取得
      description: ユーザーを 1 件取得します。
      parameters:
        - name: userId
          description: ユーザー ID
          in: path
          required: true
          type: string
      responses:
        200:
          description: OK
          schema:
            $ref: '#/definitions/User'
    put:
      summary: ユーザー更新
      description: ユーザーを 1 件更新します。
      parameters:
        - name: userId
          description: ユーザー ID
          in: path
          required: true
          type: string
        - name: name
          description: ユーザー名
          in: formData
          required: false
          type: string
      responses:
        200:
          description: OK
    delete:
      summary: ユーザー削除
      description: ユーザーを 1 件削除します。
      parameters:
        - name: userId
          description: ユーザー ID
          in: path
          required: true
          type: string
      responses:
        200:
          description: OK
definitions:
  User:
    type: object
    properties:
      id:
        type: string
      name:
        type: string

慣れるまでは Swagger Editor で書くのがいい。入力補完が効くので結構快適。 自分はインストールが面倒なのでライブデモを使った。

editor.swagger.io

Swagger Editor を使うと、Spec を編集するとリアルタイムでドキュメントのプレビューが更新される。 実際にプレビューに表示されるドキュメントはこんな感じ。

f:id:griefworker:20160309111411p:plain

『Butterflies』買った

BUMP OF CHICKEN のニューアルバム『Butterflies』を買った。 収録曲の半分がシングルという豪華な内容。

『Hello, world!』はここ数年のバンプの曲で一番好き。 久しぶりに疾走感溢れるナンバーで、何度もリピートしている。 Hello World といったらプログラミング入門の定番だけど、 それを取り入れた PV も個人的にツボ。

シングルとしても発売された『Butterfly』はテクノっぽくて、 今までの彼らの曲には無かった新しい音。 バンドとして新しいことに挑戦し続ける意思を感じた。

1st トラックの『GO』は聴くたびにどんどん好きになっていった。 特に2番の希望溢れる歌詞がお気に入り。 未来がとても素晴らしい日になるように、 行動を起こさなければいけないという、 焦りにも似た思いが込み上げてきた。

全体の感想としては、 GO→Hello, world!→Butterfly の流れでぐっと心がつかまれて、 そのまま最後まで駆け抜けていった。 時間を感じないアルバムだった。

Butterflies(通常盤)

Butterflies(通常盤)

signtool を使ってファイルに複数のデジタル署名が添付されていることを確認する

signtool は /as オプションを使うことで、1つのファイルに複数のデジタル署名を添付できる。

添付したデジタル署名の一覧はファイルのプロパティから確認できるが、 アルゴリズムとタイムスタンプだけでいいなら

signtool verify /pa /all <ファイルパス>

を実行すれば

File: <ファイルパス>
Index  Algorithm  Timestamp    
========================================
0      sha1       None         
1      sha256     None         

Successfully verified: <ファイルパス>

という風に表示できる。

ちゃんとデジタル署名が添付されているかチェックしたいファイルが多い場合に使えるかもしれない。

…と思ってたけど、遅い。 遅すぎる。 検証をしているわけで、時間がかかるのはわかるんだけど。 もっと速い方法はないものか。 とりあえずメモはしておく。

らーめん二男坊

たまには新規開拓をと思い、福岡パルコ地下1階にオープンした『らーめん二男坊』に行ってみた。

空腹だったのでチャーハンセットを注文。税込 1150 円と、なかなかのお値段だ。

臭みが無くて、クリーミーな豚骨スープは飲みやすい。麺への絡みも良い。ただ、心なしか濃厚さが足りない気がした。煮卵とチャーハンは良かっただけに惜しい。

関連ランキング:ラーメン | 天神駅西鉄福岡駅(天神)天神南駅

Selenium を使った画面テストでテスト対象の ASP.NET MVC アプリを自動起動する

ASP.NET MVC で開発しているアプリの画面テストを、Selenium を使って書き始めた。

tnakamura.hatenablog.com

テストを実行するときは、先に ASP.NET MVC アプリを Visual Studio から実行しないといけない。 毎回これやるの面倒なんで、IIS Express を使って自動化してみた。

using Microsoft.VisualStudio.TestTools.UnitTesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Firefox;
using System;
using System.Diagnostics;
using System.IO;

namespace MyMvcApp.UITests
{
    /// <summary>
    /// Selenium WebDriver を使った画面テストに共通する機能を提供します。
    /// </summary>
    [TestClass]
    public abstract class SeleniumTest
    {
        /// <summary>
        /// WEB ブラウザのドライバーを取得します。
        /// </summary>
        protected IWebDriver Driver { get; private set; }

        /// <summary>
        /// IIS のポート
        /// </summary>
        private const int Port = 2020;

        /// <summary>
        /// ベースアドレス
        /// </summary>
        protected static readonly string BaseAddress = $"http://localhost:{Port}";

        [TestInitialize]
        public virtual void TestInitialize()
        {
            Driver = new FirefoxDriver();
        }

        [TestCleanup]
        public virtual void TestCleanup()
        {
            Driver.Quit();
        }

        /// <summary>
        /// IIS Express のプロセス
        /// </summary>
        private static Process _iisProcess;

        /// <summary>
        /// IIS Express を開始します。
        /// </summary>
        private static void StartIIS()
        {
            if (_iisProcess == null)
            {
                var applicationPath = GetApplicationPath("MyMvcApp");
                var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
                var iisPath = Path.Combine(programFiles, "IIS Express", "iisexpress.exe");
                var startInfo = new ProcessStartInfo
                {
                    FileName = iisPath,
                    Arguments = $"/path:\"{applicationPath}\" /port:{Port}",
                };
                _iisProcess = Process.Start(startInfo);
            }
        }

        /// <summary>
        /// ソリューションフォルダ直下にあるテスト対象アプリのフォルダパスを取得します。
        /// </summary>
        /// <returns>テスト対象アプリのフォルダパス</returns>
        private static string GetApplicationPath(string applicationName)
        {
            var solutionFolder = Path.GetDirectoryName(
                Path.GetDirectoryName(
                    Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory)));
            return Path.Combine(solutionFolder, applicationName);
        }

        /// <summary>
        /// IIS Express を停止します。
        /// </summary>
        private static void StopIIS()
        {
            if (_iisProcess != null &&
                _iisProcess.HasExited == false)
            {
                _iisProcess.Kill();
            }
        }

        [AssemblyInitialize]
        public static void AssemblyInitialize(TestContext testContext)
        {
            StartIIS();
        }

        [AssemblyCleanup]
        public static void AssemblyCleanup()
        {
            StopIIS();
        }
    }
}

Proess クラスを使って、テスト対象の ASP.NET MVC アプリを IIS Express で起動している。 IIS Express の起動処理はテスト実行時に1回だけやればいいので、AssemblyInitialize 属性を付けた初期化用メソッドの中で呼び出している。

あとは、このクラスを継承してテストクラスを作ればいい。

ASP.NET MVC アプリの画面テストで Selenium を試してみた

Selenium 実践入門を読んで Selenium 熱に火がついたので、 ASP.NET MVC で実装しているアプリの画面テストを Selenium 使って書くことにした。 もちろん C# で。

画面テスト専用のテストプロジェクトを作成し、 Selenium を使うためのパッケージをインストール。

PM> Install-Package Selenium.WebDriver
PM> Install-Package Selenium.Support

最初はログインページの自動テストから書くことする。 ページオブジェクトパターンでやるので、ログインページを抽象化したページオブジェクトを作っておく。

using OpenQA.Selenium;

namespace MyMvcApp.UITests
{
    public class LoginPage
    {
        private IWebDriver _driver;

        public LoginPage(IWebDriver driver)
        {
            _driver = driver;
        }

        private IWebElement UserName => _driver.FindElement(By.Name("UserName"));

        private IWebElement Password => _driver.FindElement(By.Name("Password"));

        private IWebElement Submit => _driver.FindElement(By.CssSelector("input[type=submit]"));

        public void SetUserName(string userName)
        {
            UserName.Clear();
            UserName.SendKeys(userName);
        }

        public void SetPassword(string password)
        {
            Password.Clear();
            Password.SendKeys(password);
        }

        public void ClearAll()
        {
            UserName.Clear();
            Password.Clear();
        }

        public string GetValidationErrorMessage()
        {
            // 検証エラーメッセージは Bootstrap の Alert を使って表示する
            var alert = _driver.FindElement(By.CssSelector("div.alert-danger"));
            return alert.Text;
        }

        // 成功したらダッシュボードに移動する
        // DashboardPage は省略
        public DashboardPage Signin()
        {
            Submit.Submit();
            return new DashboardPage(_driver);
        }

        public LoginPage SigninInExceptingFailer()
        {
            Submit.Submit();
            return new LoginPage(_driver);
        }
    }
}

Selenium を使った画面テストを、このページオブジェクトを使って書いてみた。

using Microsoft.VisualStudio.TestTools.UnitTesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Firefox;

namespace MyMvcApp.UITests
{
    [TestClass]
    public class LoginTest
    {
        private const string LoginUrl = "http://localhost:49424/signin";

        private IWebDriver _driver;

        [TestInitialize]
        public void TestInitialize()
        {
            _driver = new FirefoxDriver();
        }

        [TestCleanup]
        public void TestCleanup()
        {
            _driver.Quit();
        }

        [TestMethod]
        public void ユーザー名とパスワードが正しいときログインできる()
        {
            _driver.Navigate().GoToUrl(LoginUrl);
            var page = new LoginPage(_driver);
            page.SetUserName("admin");
            page.SetPassword("1234admin");
            var nextPage = page.Signin();

            Assert.AreEquals("ダッシュボード", _driver.Title);
        }

        [TestMethod]
        public void ユーザー名が間違っているときログインできない()
        {
            _driver.Navigate().GoToUrl(LoginUrl);
            var page = new LoginPage(_driver);
            page.SetUserName("dummy_user");
            page.SetPassword("123admin");
            var nextPage = page.SigninInExceptingFailer();

            StringAssert.Contains(nextPage.GetValidationErrorMessage(), "ユーザー名");
        }

        [TestMethod]
        public void パスワードが間違っているときログインできない()
        {
            _driver.Navigate().GoToUrl(LoginUrl);
            var page = new LoginPage(_driver);
            page.SetUserName("admin");
            page.SetPassword("dummy_password");
            var nextPage = page.SigninInExceptingFailer();

            StringAssert.Contains(nextPage.GetValidationErrorMessage(), "パスワード");
        }
    }
}

テスト対象の ASP.NET MVC アプリをあらかじめ実行しておいて、テストを実行したところ上手くいった。 ただ、テスト対象の ASP.NET MVC アプリを毎回手動で起動しておくのは面倒なので、なんとか自動化できないものか。

あと、テストデータの投入もどうしようか悩む。テスト用に Web API を用意するか、直接データベースに投入するか。 直接データベースに投入した方が速いから、今のところそれでいこうかと検討中。 他にいい案があればいいんだけど。