GitHub Actions で Haxe コードをビルドするために setup-haxe を作成した

はじめに

Haxe ビルド環境をセットアップするための krdlab/setup-haxe を作成して Marketplace に公開しました.

github.com

github.com

背景

仕事の方で Haxe を使っているリポジトリ*1があって,このリポジトリから共通ライブラリが生成されている状況にあるのですが,これまではこの生成作業が手動で行われていました.

しかし作業自体はコマンド化されていること,またリリースのタイミングに規則性があることから自動化が可能であると判断し,関係者と相談した上で GitHub Actions を利用して自動化することにしました.

このとき setup アクションの Haxe 版があると便利なのですが,あいにくと Marketplace ではみつからなかったため,自宅に戻ってから調べて作成してみました.

アクションの作り方

krdlab/setup-haxe は TypeScript で記述した JavaScript アクションです.公式の情報が大分充実しているため,基本的にはそれほど詰まらずに作成できます.

@zeit/ncc が便利

今回初めて使ったのですが,単純にコードをまとめたいだけならばとても便利でした.ncc build とするだけで package.jsonmain フィールドを読み取って必要な依存関係をすべてまとめて,dist/index.js として出力してくれます.

github.com

Haxe release asset の解凍

Haxe binaries の *.tar.gz*.zip を解凍すると haxe_20191217082701_67feacebc のような圧縮ファイル名からは推測しにくい名前のディレクトリが出現します.なので extractTarextractZip の後に execls を呼び出してディレクトリ名を解決させています*2

https://github.com/krdlab/setup-haxe/blob/0781874a0f6b861eab826f8098bbb8710deed955/src/setup.ts#L73-L85

Windows 環境におけるコマンドの実行

Windows Server 2019 にインストール済のソフトウェア一覧 からはぱっと見分からなかったのですが,実行ログに出力されたパスからすると Git for Windows が入っているみたいです.

あと extract 系の処理は ubuntu-latestmacos-latest だとコマンドが実行されるのですが,windows-latest では PowerShell から .Net のクラスを利用して処理されていました.なるほど.

C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -Sta -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command "$ErrorActionPreference = 'Stop' ; try { Add-Type -AssemblyName System.IO.Compression.FileSystem } catch { } ; [System.IO.Compression.ZipFile]::ExtractToDirectory('D:\a\_temp\6253d2a8-f1a0-4596-8e12-7b7dbcf71ad8', 'haxe-3.4.7-win64')"
"C:\Program Files\Git\usr\bin\ls.exe" -1 haxe-3.4.7-win64

動作確認の方法

Linux 環境に対する動作確認であれば Docker イメージを利用して簡単に実施可能です.

$ docker run -it --rm -v $PWD:/work ubuntu bash
root@xxx:/# cd work
#=> 必要なツールがあれば事前に apt-get install

root@xxx:/# env INPUT_HAXE-VERSION=4.0.5 node dist/index.js

アクション内部で @actions/coregetInput から値を取得している場合は,例えばキーが "haxe-version" であれば環境変数として INPUT_HAXE-VERSION を与えるとアクション側のコードでその値が取得できます.

Windows 環境対応がうまくいかない……

現時点で krdlab/setup-haxeruns-on: windows-* に対応していません.以前対応を試みたとき,Haxe コマンドにパスは通るのですが haxelib setup すると以下のようなエラーが発生してしまうため一旦対応コードを削除しました.

C:\hostedtoolcache\windows\haxe\3.4.7\x64\haxelib.exe setup C:\hostedtoolcache\windows\haxe\3.4.7\x64\lib
##[error]The process 'C:\hostedtoolcache\windows\haxe\3.4.7\x64\haxelib.exe' failed with exit code 3221225595

32212255950xC000007B なので STATUS_INVALID_IMAGE_FORMAT ですかね?ここは日を改めて再調査予定です*3

おわりに

krdlab/setup-haxe を作成したことで Haxe コードのビルドを GitHub Actions で手軽に実行できるようになりました.

Windows 環境向けにはまだ問題が残っている状態ですが,いずれは解決したいと考えています.

*1:私はビルド周りで少し関わっていた程度

*2:もっと簡単な方法があったりするんでしょうかね?

*3:私の単純なミスだと思う

コマンドラインツールにテンプレート機能を取り入れるための調査と実験

はじめに

Yeoman の generator みたいなテンプレート機能を自前のコマンドラインツール*1に作り込む必要が生じたので調査しました.

結論としては yeoman-environment と mem-fs-editor,inquirer あたりを組み合わせることで容易に実現できました.

Yeomon の調査

今回は Interacting with the file system に記載されている機能の一部 (templates からのファイルコピーと ejs テンプレート処理) が欲しいのでそこらへんを中心に調べます.

ejs によるテンプレート処理については this.fs.copyTpl を使えば良いことがマニュアルから読み取れます.パッケージは mem-fs-editor のようです.

それからコピー元のパスである templatePath を解決するための情報については,コードを調べてみると yeoman-environment がこの辺りの機能を担っていることがわかります.

https://github.com/yeoman/environment/blob/v2.8.0/lib/resolver.js#L42-L76

また,コピー先を決定する destinationPath.yo-rc.json や引数による指定がなければ process.cwd() から取得しているようです.

https://github.com/yeoman/generator/blob/v4.5.0/lib/index.js#L162-L176

https://github.com/yeoman/generator/blob/v4.5.0/lib/index.js#L831

https://github.com/yeoman/environment/blob/master/lib/environment.js#L209

実験

まずは yeoman-environment の方から.npm install --save yeoman-environment して以下のようなコードを書いて実行すると,

// file: /path/to/use-yeoman-environment/index.js
const y = require('yeoman-environment');
const env = y.createEnv();
const res = env.lookup({
  packagePatterns: 'yeoman-*',
  filePatterns: '*\/environment.js'
});
console.log(res);
$ node index.js 
[ { generatorPath:
     '/path/to/use-yeoman-environment/node_modules/yeoman-environment/lib/environment.js',
    packagePath:
     '/path/to/use-yeoman-environment/node_modules/yeoman-environment',
    namespace:
     'path:to:use-yeoman-environment:node_modules:yeoman-environment:lib:environment',
    registered: true } ]

こういう結果が返ってきます.なのでテンプレートデータのコピー元の絶対パス解決には packagePath を使えば良さそうです.

mem-fs-editor の方は以下のように利用します.

const memfs  = require('mem-fs');
const editor = require('mem-fs-editor');

const fs = editor.create(memfs.create());

const context = { name: 'hoge' };

fs.copyTpl('template/test.json', 'dest/test.json', context);
fs.commit(() => console.log('保存した'));

commit を呼び出すとファイルとして書き出されます.

試作

実際にテンプレートから雛形を生成する試作コマンド proto-cli を作成します.

proto-cli/
  template/
    index.js
    package.json
  main.js
  package.json

template/ 以下の内容を雛形としてコピーします.template/package.json の中身は以下の通り.

{
  "name": "<%= name %>",
  "version": "<%= version %>",
  "description": "<%= description %>",
  "private": true,
  "main": "index.js"
}

そして main.js の中身は以下の通り.ユーザー入力は inquirer でハンドリングします.

#!/usr/bin/env node

const yeoman   = require('yeoman-environment');
const memfs    = require('mem-fs');
const editor   = require('mem-fs-editor');
const inquirer = require('inquirer');
const path     = require('path');

const fs  = editor.create(memfs.create());
const env = yeoman.createEnv();

const res = env.lookup({
  packagePatterns: 'proto-cli',
  filePatterns: 'main.js'
});

const templateRoot    = path.join(res[0].packagePath, 'template');
const destinationRoot = process.cwd();
const templatePath    = file => path.join(templateRoot, file);
const destinationPath = file => path.join(destinationRoot, file);

const copyTemplate = context => {
  fs.copyTpl(
    templatePath('package.json'),
    destinationPath('package.json'),
    context
  );
  fs.copy(
    templatePath('index.js'),
    destinationPath('index.js')
  );
  fs.commit(() => console.log('initialized'));
};

inquirer.prompt([
  {
    name: 'name',
    message: 'package name',
    default: path.basename(__dirname)
  },
  {
    name: 'version',
    message: 'version',
    default: '0.1.0'
  },
  {
    name: 'description',
    message: 'description'
  },
  {
    name: 'author',
    message: 'author'
  }
]).then(answers => {
  copyTemplate(answers);
});

実際に動くコードはこちら. github.com

npm link した後,適当なディレクトリの中で proto-cli を実行すると動作を確認できます.

おわりに

yeoman-environmentmem-fs-editor (+ inquirer) を利用することで期待するテンプレート機能を実装することができました.当初は自前で実装しようと思っていましたが便利な形でパッケージ化されていたため助かりました.

さらにここから commander を組み合わせることで,よく見かける CLI ツールが作成できます.

*1:環境は Node.js

Keycloak を利用して OpenID Connect ライブラリ (haskell-oidc-client) の動作確認をする

はじめに

昨年末に以下の Issue が来ていたことに気付いて修正したのですが,リリース前に Keycloak を用いて haskell-oidc-client の動作確認をできないか試したところ,とても簡単に実施できました.

github.com

今回はその方法をまとめたものです.

方法

Keycloak は Docker イメージが公開されています. hub.docker.com

ですから pull して起動すれば,(テスト目的であれば) すぐに利用可能です.

docker pull jboss/keycloak
docker run -d -p 8080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=test jboss/keycloak

docker logs -f を眺めながら起動完了を待った後,localhost:8080 にアクセスするとリダイレクト後に以下の画面が表示されます.Administrator Console からログインします. f:id:KrdLab:20200122011814p:plain

これより下で設定手順を説明しますが,あくまでも動作確認を目的とした簡易なものになっているため注意してください.


ログイン後,Clients から Create ボタンを押して Client ID/Secret を発行します.Add Client 画面で項目を埋めて Save し, f:id:KrdLab:20200122010646p:plain

Settings タブから Access Type を credential に変更,かつ Valid Redirect URIs を Relying Party のコールバック URL に変更します. 変更を保存すると Credentials タブが出現して Secret を取得できるようになります. f:id:KrdLab:20200122011050p:plain

次に Users からログイン用のユーザーを作成します.Email や各種 Name フィールドは設定しておいた方がテストの時に躓きません.パスワードは Save 後の Credentials タブから設定可能です. f:id:KrdLab:20200122011214p:plain

またデフォルトでは profile スコープに name が含まれていません.Client Scopes から profile を選択し,Mappers タブから name を追加します. f:id:KrdLab:20200122011342p:plain

以上で最低限の IdP として動作します.デフォルトの状態で Issuer Location は http://localhost:8080/auth/realms/master です.テストコードに設定して動作確認を開始しましょう.

おわりに

今回の実験でずいぶんと簡単に試せることが分かりました.Admin REST API があるみたいなので,ライブラリの API 仕様テストを自動化できないか試行錯誤したいと思います.

参考情報

daab on kubernetes

はじめに

Raspberry Pi 4 Model B (4GiB) に k3s を導入して複数の試作ボットを single node 上でコンパクトに稼働させられないかな?と考えていて,今回はまずベースとなるマニフェストを Docker Desktop for Mac の kubernetes 上で作成してみます.

要件

  • single node
  • 複数の試作ボットを稼働させる
  • ボットとペアで Redis を用意*1
  • ログは一先ず kubectl logs でみられたら良い*2

構成

daab は hubot ベースなので Brain が Redis を使用します.daab と Redis にそれぞれ Pod を用意してやります.とてもシンプルな構成です.

redis.yml

github.com

Redis コンテナは公式の説明に従って --appendonly yes を指定して起動します.Persitent Volume はとりあえず hostPath でデフォルトの場所 ($HOME/.docker/Volumes) に作らせておきます*3.これで Pod を更新しても情報が残ります.

後は他の Pod からアクセスするための Service を定義したら完了です.

daab.yml

github.com

daab の方は REDIS_URL を指定し,HUBOT_DIRECT_TOKEN を Secret から受け取るようにします.また Pod 更新の際に同じトークンを持ったボットが (一時的にでも) 複数稼働するような状況を避けるために .spec.strategy.type = Recreate にします.

最後に Webhook 用に Ingress を追加*4したら完了です.

起動

あらかじめ ingress-nginx Installation Guide に従って ingress-nginx を導入しておきます*5

cd daab-ping
docker build -t daab-ping:v0.1.3 .
daab login
#=> アクセストークンを取得する (.env)
cd -

kubectl create namespace krdlab
kubectl create secret generic daab-secrets -n krdlab --from-env-file daab-ping/.env

kubectl apply -f redis.yml
kubectl apply -f daab.yml

kubectl get pods -n krdlab
#=> Running を確認

どちらも Running になったらボットが反応してくれるようになります.

おわりに

今回は 1 つのボットを稼働させるために必要なマニフェストを作成しました.この後は k3s 上で動作するように調整を入れて,実装固有の部分をパラメータ化していきます.

*1:後で共用に変えるかも

*2:後からノードレベルで収集するための調整を入れるかも

*3:今は Docker Desktop for Mac 上なので,path は Raspberry Pi に移してから調整する

*4:NodePort でも良いのですが,Ingress を通しておいた方がいろいろ調整が効くので

*5:k3s の場合は Traefik になる? https://rancher.com/docs/k3s/latest/en/networking/#traefik-ingress-controller

GitHub Actions と semantic-releaseを組み合わせて Node.js パッケージのリリースフローを整備する試み

はじめに

オープンソースとして公開しているとある複数のパッケージでリリースフローが整備されいないという問題があって,昨年末は少々頭を悩ませていました.

そこで今回は GitHub Actions と semantic-release を組み合わせて npm へのリリース作業*1を自動化する,という方針で上記の問題を解決できないか?ということを試してみようと思います.

実験台にしたパッケージは krdlab/daab-session *2です.

先に軽く結論を述べますと,ライブラリ系のリポジトリでは導入する価値ありだ (というかした方が良い) と思いました.master ブランチにマージするだけで必要なものが自動的にリリースされるようになります.

GitHub Actions の設定

ここは公式が用意してくれているものをそのまま設定します.この段階では publish 関連を設定せずに,ビルドとテストが成功することだけを確認します.

後ほど少しだけ修正します.

semantic release の設定

semantic-release は Angular Commit Message Conventions にしたがってコミットするとパッケージのバージョニングと公開を自動化してくれます.導入手順は以下の通りです.

  1. 対象 GitHub リポジトリの Settings にある Secrets へ GitHub と npm のアクセストークンを追加
  2. semantic-release と必要な plugin を npm install
  3. Actions の設定で追加した .github/workflows/nodejs.yml に semantic-release 実行ステップを追加
  4. package.jsonrelease フィールドを追加

1. 対象 GitHub リポジトリの Settings にある Secrets へ GitHub と npm へのアクセストークンを追加

semantic-release が使うトークンとして以下の 2 つが必要になるので,発行 & 設定します.

  • GitHub で Personal access token を発行
    • repo 権限を与える
    • これを GH_TOKEN として Secrets に登録する
  • npm で Access Token を発行
    • publish 権限を与える
    • これを NPM_TOKEN として Secrets に登録する

2. semantic-release と必要な plugin を npm install

npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git

デフォルトでロードされるプラグインは以下の通りです.

https://semantic-release.gitbook.io/semantic-release/usage/plugins#default-plugins

Changelog を生成したい場合は @semantic-release/changelog が追加で必要になります.

また,更新された package.json や生成された CHANGELOG.md はデフォルトだと publish 先にしか含まれないため,リポジトリに反映するためには @semantic-release/git が必要です.

3. .github/workflows/nodejs.yml に semantic-release 実行ステップを追加

.github/workflows/nodejs.ymlsteps に以下を追加します.

    - name: semantic-release
      if: matrix.node-version == '12.x'
      run: |
        npm run semantic-release
      env:
        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        GH_TOKEN: ${{ secrets.GH_TOKEN }}

matrix build しているため if でリリースフローが複数回実行されないように絞り込んでおきます.

semantic-release はデフォルト設定だと master ブランチのみを対象としかつ npm を対象とするため,上記の追加だけで「PR を master ブランチにマージすると publish」されるようになります.ちなみに master ブランチ以外で実行した場合は以下のようなメッセージが出力されます.

This test run was triggered on the branch feature/add-github-actions, while semantic-release is configured to only publish from master, therefore a new version won’t be published.

4. package.jsonrelease フィールドを追加

package.json に以下の設定を追加します.といっても plugins のデフォルト値に changelog と git を追加しているだけです.

  "release": {
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      "@semantic-release/changelog",
      "@semantic-release/npm",
      "@semantic-release/github",
      "@semantic-release/git"
    ]
  }

注意点としては,リポジトリに付いている既存のバージョンタグが v1.0.1 のように v から始まる場合は問題ありませんが,そうでない場合は追加で tagFormat の設定が必要だということです.

semantic-release のリリースステップや dry-run の出力をみると Git タグを基に次のバージョン値を決めていることが分かります.この時タグからバージョンを抽出するフォーマットが tagFormat なのですが,このデフォルト値が v${version} になっているため,これを既存のタグフォーマットにあわせて修正する必要があります.

例えば単純にバージョン値を Git タグとして付けている場合は以下のような設定を追加します.

  "release": {
    "plugins": [
      ...
    ],
    "tagFormat": "${version}" <--- これ
  }

補足をいくつか

おわりに

以上のような設定を施すことで「master ブランチへマージしたら Release note や ChangeLog が生成されて npm に publish される」という一連の作業が自動化されます.

実行してみて思ったのは「想像以上に楽だ……」ってことです.パッケージ数が多いとより嬉しいんじゃないかなと思います.

リリース手順は実施頻度が低ければ忘れやすく,頻度が高ければ面倒なものになってしまいます.自動化しておくことでいずれの場合についても負担を軽減できる*3と感じました.

参考情報

*1:Release note/Changelog 作成 + npm publish

*2:自分以外誰も使っていないであろうパッケージ

*3:コミットメッセージ規約と仕組み自体の周知は必要

Ansible のモジュールを check mode と差分表示に対応させる

はじめに

Ansible の実行時に --check を指定すると check mode (dry run) として (リモートに変更を加えることなく) 実行され,--diff を指定すると変更内容の差分が表示されます. ただしモジュール側がこれらのオプションをサポートしている場合に限ります*1

check mode や差分表示は事前確認が容易になるという点で利便性が高いため,可能な限りモジュールを対応させたくなります.

これらの対応方法について調べてみると Check Mode (“Dry Run”) で若干触れられていますが,実際には対応済みモジュールのコードを読み解く必要があります*2

そこで今回はこの対応方法についてまとめておきたいと考えました.

Ansible 環境

ansible 2.6.0 (devel ad69ef88e7) last updated 2018/05/20 18:09:04 (GMT +900)
  config file = None
  configured module search path = [u'/Users/krdlab/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/krdlab/.provisioning/dev/projects/misc/krdlab/ansible/lib/ansible
  executable location = /Users/krdlab/.provisioning/dev/projects/misc/krdlab/ansible/bin/ansible
  python version = 2.7.11 (default, Oct 20 2016, 16:27:36) [GCC 4.2.1 Compatible Apple LLVM 7.0.2 (clang-700.1.81)]

ベースとなるサンプルモジュール

path で指定したファイルを作成/削除するだけのモジュールを用意しました.

https://github.com/krdlab/examples/blob/master/ansible-module-check-and-diff/version1/simplefile.py

以下のように実行します.

$ ansible all -i ',localhost' -c local --module-path version1 -m simplefile -a 'path=/tmp/testfile state=present'
localhost | SUCCESS => {
    "changed": true,
    "simplefile": {
        "path": "/tmp/testfile",
        "state": "present"
    }
}

まだ check mode には対応していないコードであるため,当然 check mode では期待通りに動きません.

$ ansible all -i ',localhost' -c local --module-path version1 -m simplefile -a 'path=/tmp/testfile state=present' --check
localhost | SKIPPED     # ← スキップされる

check mode のサポート

check mode のサポートを追加したコード全体は,以下のリンク先の通りです.

https://github.com/krdlab/examples/blob/master/ansible-module-check-and-diff/version2/simplefile.py

AnsibleModulesupports_check_mode = True を指定すると check mode でもスキップされることなくモジュールが実行されます.

    module = AnsibleModule(
        argument_spec=dict(
            ...
        ),
        supports_check_mode=True    # ← これ
    )

module.check_mode で check mode かどうかの判定が可能です.

check mode 時は実際の変更をリモートに加えることなく,現在のリモートの状態と指定されたパラメータを比較して「変更が発生するか?」だけを判定させます. 例えば AWS 系のモジュールだと,describe API を呼び出して得られた情報と指定されたパラメータの内容とを比較して "changed" か "ok" かを決定しています.

今回の simplefile モジュールも (リモートではありませんが) 現在の状態と指定されたパラメータに基づいて,変更が発生する場合に changed = True を返すように実装しています (以下のリンク先参照).

https://github.com/krdlab/examples/blob/master/ansible-module-check-and-diff/version2/simplefile.py#L50

また戻り値*3をきちんと返すようにすると,register を使っていても後続のタスクが失敗しなくなるためおすすめです*4

差分表示のサポート

差分表示のサポートを追加したコード全体は,以下のリンク先の通りです.

https://github.com/krdlab/examples/blob/master/ansible-module-check-and-diff/version3/simplefile.py

module.exit_jsondiff パラメータを渡すと,--diff を指定して実行した場合に変更差分が表示されるようになります.

    ...
    module.exit_json(changed=changed, diff=diff, simplefile=params)
                                     # ↑これ

diff の構造は以下のものが期待されます.

diff = dict(
    before=dict(
        変更前の状態
    ),
    after=dict(
        変更後の状態
    )
)

beforeafter の中身は比較可能となるようにモジュール作成者が決定します. userfile モジュールなどを参考に実装すると良さそうです.

今回の simplefile モジュールだと以下のような情報を返しています.

def __create_diff(self):
    path = self.expected['path']
    act_state, exp_state = self.__file_state(), self.expected['state']

    diff = dict(before=dict(path=path), after=dict(path=path))  # path は変化しないので必ず入れる
    if act_state != exp_state:                                  # state は変化する場合だけ diff に取り込む
        diff['before']['state'] = act_state
        diff['after']['state'] = exp_state
    return diff

上記のようなデータを返すと,--diff を付けて実行したときに以下のような表示が得られます.ちなみにこれは,既に存在している /tmp/testfileabsent にする場合のものです.

--- before
+++ after
@@ -1,4 +1,4 @@
 {
     "path": "/tmp/testfile",
-    "state": "present"
+    "state": "absent"
 }

おわりに

Ansible のモジュールを check mode に対応させることで,実際に変更を適用する前に変更が発生するのかどうかを知ることができます. 差分表示にまで対応させれば「あれ?なんの変更だっけ?」といったときに役立ちます. また可能であれば check mode でも戻り値を返すようにしておくと,regsiter を使ってい他場合に後続タスクが失敗しなくなります.

やはり適用前に確認はしたいですから,check mode に対応することは結構重要なことだと思います*5

というわけで今回はその対応方法の紹介でした.

*1:実際に Ansible の標準モジュールの中にも未対応なものがちらほらみつかります.コアモジュール以外は "not required" とされているようです → Check Mode (“Dry Run”)

*2:もしかしてどこかにまとまっているんだろうか?

*3:ドキュメントで Return Value として定義されているデータ

*4:どうしても返せない場合もあるのですが……

*5:なのでこれが取り込まれると嬉しいなぁ…… → https://github.com/ansible/ansible/pull/39846

Servant に Experimental モジュールとして追加された認証の仕組み

はじめに

久しぶりに Haskell に戻って Servant を触ってみたら,いつの間にか認証周りの仕組みが実験的に追加されていました. 興味がわいたため,Authentication in Servant とコードを読みながら少しだけ仕組みをのぞいてみました.

バージョン情報

lts-7.14 です.バージョンは以下の通りです.

  • GHC 8.0.1
  • servant 0.8.1

サンプルコード

実際に動くコードを用意しました.

Recap の方にまとまっていますが,認証無しの場合と異なる箇所をザックリ挙げると以下の通りです.

  • 認証をかけたいエンドポイントに AuthProtect コンビネーターを指定する
    • AuthProtect "hoge" :> ProtectedAPI とした場合,ProtectedAPI が認証処理で保護される
  • AuthProtect tag と対応した type family AuthServerData の型インスタンスを宣言する
    • 認証ハンドラが返す値の具体的な型を HasServer api context インスタンスに指定する役割
  • serve の代わりに serveWithContext を使用する
    • ここで HasServer api contextcontext を指定
  • Context には実際の認証処理を担う AuthHandler を積む
    • AuthHandler は認証処理関数 (r -> Handler usr) をラップしたもの

少しだけ仕組みを覗いてみる

AuthProtect コンビネーターを指定すると以下のルーティングが有効になります.

-- 引用: https://hackage.haskell.org/package/servant-server-0.8.1/docs/src/Servant.Server.Experimental.Auth.html#line-54
instance ( HasServer api context
         , HasContextEntry context (AuthHandler Request (AuthServerData (AuthProtect tag)))
         )
  => HasServer (AuthProtect tag :> api) context where

  type ServerT (AuthProtect tag :> api) m =
    AuthServerData (AuthProtect tag) -> ServerT api m

  route Proxy context subserver =
    route (Proxy :: Proxy api) context (subserver `addAuthCheck` withRequest authCheck)
      where
        authHandler :: Request -> Handler (AuthServerData (AuthProtect tag))
        authHandler = unAuthHandler (getContextEntry context)
        authCheck :: Request -> DelayedIO (AuthServerData (AuthProtect tag))
        authCheck = (>>= either delayedFailFatal return) . liftIO . runExceptT . authHandler

-- 以下はコードを読むための補足:
-- type Server api = ServerT api Handler
-- type Handler = ExceptT ServantErr IO

HasContextEntry により,HasServer api contextcontext からは getContextEntry を使って AuthHandler Request (AuthServerData (AuthProtect tag)) を取得することが出来ます (ということが型制約から要求されます). contextserverWithContext で指定した型で,先のサンプルコードでは AuthHandler を積んだ型レベルのリストです.

AuthServerData (AuthProtect tag) の部分には (先のサンプルコード中にある) type family のインスタンス宣言によって Account が対応します.

また関連型により以下のような対応付けがなされるため,

  type ServerT (AuthProtect tag :> api) m =
    AuthServerData (AuthProtect tag) -> ServerT api m

subserverDelayed env (Account -> ServerT api Handler a) という型を持つことになります (Delayed については後述). これにより,対応するハンドラは Account を引数として受けることが要求されます.

後は取り出した authHandler を実行してその結果を subserver に渡すだけですが,すぐには実行されず一旦 Delayed の形で積まれて後続の処理へと渡されます*1

-- 引用: https://hackage.haskell.org/package/servant-server-0.8.1/docs/src/Servant.Server.Internal.RoutingApplication.html#addAuthCheck
addAuthCheck :: Delayed env (a -> b)
             -> DelayedIO a
             -> Delayed env b
addAuthCheck Delayed{..} new =
  Delayed
    { authD   = (,) <$> authD <*> new
    , serverD = \ c (y, v) b req -> ($ v) <$> serverD c y b req
    , ..
    } -- Note [Existential Record Update]

Delayed はエラーハンドリングの順序を決定するための仕組みだそうです.以下のような順序で実行され,すべてパスした場合に実際のハンドラが呼び出されます.

-- 引用: https://hackage.haskell.org/package/servant-server-0.8.1/docs/src/Servant.Server.Internal.RoutingApplication.html#runDelayed
runDelayed :: Delayed env a
           -> env
           -> Request
           -> IO (RouteResult a)
runDelayed Delayed{..} env = runDelayedIO $ do
  c <- capturesD env
  methodD
  a <- authD
  b <- bodyD
  DelayedIO (\ req -> return $ serverD c a b req)

runDelayedrunAction から呼び出されます.

-- 引用: https://hackage.haskell.org/package/servant-server-0.8.1/docs/src/Servant.Server.Internal.RoutingApplication.html#runAction
runAction :: Delayed env (Handler a)
          -> env
          -> Request
          -> (RouteResult Response -> IO r)
          -> (a -> RouteResult Response)
          -> IO r
runAction action env req respond k =
  runDelayed action env req >>= go >>= respond
  where
    go (Fail e)      = return $ Fail e
    go (FailFatal e) = return $ FailFatal e
    go (Route a)     = do
      e <- runExceptT a     -- Handler a の結果を取り出している
      case e of
        Left err -> return . Route $ responseServantErr err
        Right x  -> return $! k x

runAction... :> Get '[JSON] Text のような末端を処理する route 関数から呼び出されます.これで無事レスポンスが生成されました.

servant-auth (servant-auth-server) という別の選択肢

AuthProtect を使わずに認証周りを実装したパッケージとして servant-auth があります.

Auth (auths :: [*]) val というコンビネーターが用意されており,auths に複数の認証方式を指定できるようになっています.

このパッケージでは JWTCookie が提供されています. どちらも暗号化したデータを Token や Set-Cookie に用いる実装のようです.

おわりに

AuthProtect の仕組みによって認証を型として指定できるようになりました. セッション ID とユーザーの紐付けや,API トークンベースの認証が実装しやすくなりました.

Context のあたりは他にも WithNamedContext といった仕組みがあるようです.

しかし巧妙な方法ですね.自分ではとても思いつけそうにありません.

*1:ここら辺も結構変更されていますね