Autify を使ってみた所感と Puppeteer と Playwright

この記事は個人ブログと同じ内容です

http://ratatatat30.hatenablog.jp/entry/2021/06/13/114921

sekitats です。

さて、back check では E2Eテストツール Autify が導入されました。

f:id:sekilberg:20210613194119p:plain

1日触ってみたので所感を書きたいと思います。

よかった点

まず、海外発サービスかと思いきや、日本人が作った感のある馴染みやすいUIが良い。

シナリオ作成では、ブラウザでの操作をそのままレコーディングしてテストシナリオとして保存できるのが画期的だ。すぐに使い方を覚えることができた。 これであれば誰でもテストシナリオを書くことができるだろう。

そして、何よりクロスブラウザテストをサポートしているところが嬉しい。これだけで導入するだけの価値はある。

また、AI技術を使って変更の差分を検出するなど、きめ細かなサポートが素晴らしい。

ダミーのメールアドレスを生成してそれを使いまわせるのが便利だし、開発者にとっては JavaScript でコードを実行できるのも柔軟さがあっていい。

気になった点

使い心地で不便を感じたのは、シナリオ作成途中で修正が発生した場合、基本的にブラウザでレコーディングをする以外の方法がないことだ。

それから、例えば、コードであれば当然あるシナリオをコメントアウトやスキップするなりして一部のシナリオを実行させないことができる。一部シナリオを無効化するなどできると嬉しいと感じた。

細かくシナリオを区切ってパーツ化したとしても、最終的に一つのテストプランに書き出すマスタリングのような工程が必要なため、つぎはぎのままではテストを実行できない。

それから、ローカルでの設定、ステージングでの設定など、設定を簡単に切り替えられるのもコードなら簡単にできる。

テストが異常終了した場合もコンソール上であれば分かりやすい。

それなら Autify である必要がないのはもっともだ。

結局、細かいところの制御はコードには勝てない。

要は用途によって使い分けるべきということでしょうか。

しかし、E2Eテストをここまで進化させた Autify を応援したい気持ちでいっぱいです。Autify の更なる進化に期待せざるを得ません。

Master of Puppeteer

f:id:sekilberg:20210613203131p:plain

私は Puppeteer でテストシナリオを書き始めた。Puppeteer は E2E テストフレームワークではないが、Autify で不便に感じたところを解消できそうな気がしたのである。

Puppeteer と聞くと、Metallica“Master of Puppets” が思い浮かぶ。 Metallica の代表的な曲であり海外では有名な曲なので聞いたことない人は一度聞いてみて損はない。(ギターリフを全て異常な速さのダウンピッキングで弾いていることでも有名)

話が脱線しましたが Puppeteer の tips を共有。

iframe 内の要素にアクセス

const frame = await page.frame().find(f => f.name() === 'preview-html')
const button = await frame.$('.button')

タブの切り替え

const pages = await browser.pages() // 全タブを取得
const newTab = pages[pages.length - 1] // 開いたタブを取得

新規タブが開いたあと画面がローディング中のまま進まないことがあった。 一度他のタブに切り替え、新規タブに切り替え直すとローディングから先に進むようになった。

const pages = await browser.pages() // 全タブを取得
...
const prevTab = pages[pages.length - 2] // 一個前のタブ
await prevTab.bringToFront() // 一個前のタブを前面に
await prevTab.waitForTimeout(2000) // 2秒待機
await newTab.bringToFront() // 開いたタブをもう一度選択する

クリップボードにコピー

const documentHandle = await page.evaluateHandle('document');
await page.evaluate((document) => {
    // documentHandle を渡すと evaluate 内でdocumentにアクセスできる
    const oneTimePass = document.getElementById('password').innerText
    navigator.clipboard.writeText(password)
}, documentHandle)
await documentHandle.dispose() // 使い終わったら破棄

クリップボードの中身を取得

const password = await page.evaluate(async () => {
    return await navigator.clipboard.readText()
})

ちなみにクリップボードのアクセスを許可しておかなければならない。

const browser = await puppeteer.launch()
await browser
  .defaultBrowserContext()
  .overridePermissions(' host名 ', ['clipboard-read'])

おまけ playwright も触ってみた

f:id:sekilberg:20210614131150p:plain

playwright とは

Playwrightとは、Microsoftが提供しているWebDriverやpuppeterと同じ種類の自動テスト用のライブラリです。2020年2月1日に、オープンソースで公開されました。ChromiumFirefoxWebKitといったブラウザの動作をプログラムで再現し、自動的にテストをすることができます。

ざっくりいうと puppeteer の制作チームが Microsoft に行って Chromium だけでなく Firefox, WebKit にも対応した新しいライブラリを作ろうってことで puppeteer の改良版 playwright を作ることになったてな感じでしょうか。

今回、最初から playwright で書けば良いのではと聞こえてきそうだが、“Master of Puppeteer” ってどうしても言いたかったのでよいのである。

puppeteer と Playwright との詳細な違いは他の記事に任せるとして、puppeteer とのざっくり変更点。browser インスタンス生成のところだけ変えただけでだいたい動いた。

const { chromium, webkit } = require('playwright');

const browser = await chromium.launch({ ...config })
const context = await this.browser.newContext()
await context.grantPermissions(['clipboard-read'], ' host名 ')

playwright になってタブの操作方法が変わった(以前の書き方でもできそうだが、context ってのを使うっぽい)

const [newPage] = await Promise.all([
  context.waitForEvent('page'),
  page.click('a[target="_blank"]') // Opens a new tab
])
await newPage.waitForLoadState();
console.log(await newPage.title());

(新規タブが開いたあと画面がローディング中のまま進まないところは以前として解決できない。これは他の原因がありそう。)

playwright について言えば、なんとユーザー操作からコードを生成するなんてことができるようだ。機会があれば使ってみて記事を書いてみたい。

まとめ

Autify は素晴らしいツールだ。人力でやっている非効率な作業から解放され、プロダクトの価値向上のためによりリソースを使える。こういったサービスがさらに進化し続け、世の中の負の解消になれば良いと思う。

他のE2Eテストフレームワークもどんどんと進化を続けている。 Karma によるブラウザの自動操作を初めて見たときは衝撃を受けた。もちろん Selenium もあったし、ヘッドレスブラウザに関しては、PhantomJS, CasperJS, Nightwatch.jsNightmare もあった。IEやレガシーEdge がなくなった世界になれば、E2Eテストはさらに楽になる。願ってやまない。

それから今回同じく E2Eテストフレームワークcypress も触ってみた。しかし、cypress は別タブ遷移ができないので痒いところに手が届かなそう、ということで断念。

結局何が言いたかったかというと、Metallica も E2Eテストでも何でも進化を止めてはいけない。という雑なまとめで締めたいと思う。

スカウトメールに 3割 返信もらったはなし

この記事は個人Qiitaと同じ内容です

qiita.com


スカウトメールに 3割 返信もらえたはなし

まえおき

エンジニア採用担当のみなさま、スカウト送信、やってますか?

ビズリーチ、転職ドラフト、Forkwell、キャリトレ、LAPRAS SCOUT等々、スカウト型転職サイトが、特にエンジニア採用において注目されていると思います。

企業にとって採用とは従来、応募や紹介など待ちのスタイルだったものが、スカウト型転職サイトにおけるスカウトの利用によって、必要な時に必要な分だけ企業側から行動を起こせる攻めのスタイルが選択できるという点で、採用の可能性が広がる仕組みと言えます。

ただし、経験ある方ならわかると思いますが、スカウト送信って、1件1件かなり時間がかかるものです。

私は業務の合間に対応して、残業時間も適度に利用して、1日10件送信が限度でした。

それだけ時間をかけて送るスカウトですから、返信率はなるべく高いほうがいい。

スカウト送信の目的

転職サイトへのスカウト送信作業の直接的なゴールは、 良質な面接 数を獲得することかな、と思います。

スカウトの先にあるのは採用面接ですので、企業と求職者の双方が入社してほしい/入社したいと感じるのが真の目標達成ですが、お互いの状況次第でそうはならない場合もあります。

スカウト送信における良質な面接とは

面接後に、企業側がぜひ入社してほしいと思えた方との面接や、または内定水準にはぎりぎり達しなかったけど、実際に会ってみなければわからなかった内容だったから面接できてよかったと腹落ちできる面接は、良質な面接と言ってよさそうです。

なので一つ注意点としては、検索範囲を闇雲に拡げて内定水準にマッチしないことがおおよそわかっている返信が増えてもそれは良質な面接にはつながらないため、自社のニーズにおいて妥協のない検索条件に対してヒットした求職者を送信対象にする、というのは大前提になります。

では以上を踏まえて、今回はスカウトの送信内容にフォーカスして、具体的に行ったことを見返してみましょう。

スカウトテンプレート作成のポイント

まず送信されたすべての求職者に届く定形部分になる、メールテンプレートにおいて気をつけた点があります。

応募ハードルを下げる

まず、応募のハードルを下げるために以下の2点を実施しました。

  • 必要技術スタックはMUST、WANT含め「習得できる技術スタック」として表現し応募ハードルを下げる
  • フルスタックと説明したいところは、表現を和らげて「マルチスタック」と表記する

具体的な技術スタックを、応募資格や応募条件のように表現すると、その技術スタックすべての既修得者だけを募集しているように見えるため、入社後の修得も可能であることを理解してもらうため、習得可能な技術として表現しました。

また、チームのEng.の目標はフルスタックになることではあるのですが、募集に「フルスタック」を書きすぎると気後れする求職者もいるかも知れないと考え、「マルチスタック」と柔らかい表現を使用し、"オラつき感"を抑えました。

伝えたい魅力を伝える

エンジニアとして、こういう項目に喜びを感じる方に入社してほしいという観点から、以下2件を魅力として訴求しました。

  • プロダクトの市場におけるチャレンジングな側面を説明する
  • ユーザー顧客と直接的に関われることを説明する

事業自体がチャレンジングであることと、ユーザーの声と近いことは、大切な価値であると共感いただける方と働きたいと思っています。

カスタマイズ文言作成のポイント

定形テンプレートの次に、最も力を注ぐべき、求職者ごとのカスタマイズ文言において気をつけた点をあげだしてみます。

まず、プロフィール自体と、併せて掲載されているリンクからGitHub、Qiita、スライド、ポートフォリオ、個人ホームページなどに目を通します。

ここで読み取るポイントは以下です。

  • 何を伝えたがっているのかを理解する
  • 他者と異なる点(個性、特性、属性)を探す
  • 求職者がやりたがっていることとこちらがお願いしたいことが一致するポイントを探す
  • 求職者が重視しているらしい環境や労働条件、役割、課題のうち、弊社なら提供、解決できそう、と伝えられそうなポイントを探す
  • その他、特徴的で同調できる取り組みや実績を探す

つまり、論点を抽出すると以下のどれかの切り口になります。

  • あなたが必要である
  • あなたの現状や希望を解決できる
  • あなたの体験に深く同調できる

これらを1つ以上、可能であれば複数掲載できると、より読んでもらいやすいスカウトメッセージになると予想しました。

そしてこれらの観点で読み取った内容を、例文にしてみると以下のようになります。 これらは実際に送信した文章の抜粋です。

  • ◯◯◯ を重視して開発されるスタイルについて理解
  • ご指摘の課題について当社スクラムチームでは継続的に解決しながら開発を進めている
  • お力添えをいただきたい内容が多くぜひともに働きたい
  • アーキテクトを包括的にリードしていただける役割を担っていただけないかと思う
  • スタートアップ企業においてすべて見渡せる規模の環境で xxx 様の技術力を存分に発揮してほしい
  • 技術スタックにおいて当社が導入を検討したい項目が見受けられ、力添えをいただきたい
  • 網羅的に対応し積極的に技術課題を解決されておりオールマイティに広く深い技術知見をお持ちとお見受けできる
  • 当社はでは ◯◯◯ についても対話が可能
  • ご希望の ◯◯◯ につながるキャリアパスも描きやすい環境
  • 技術者として好感の持てるプロセスを実直に遂行されている
  • xxx 様が記載されているフローやアプローチは、当社においても重要な観点
  • ◯◯◯ というフレーズが新鮮で、目を留めてしまった
  • ドキュメントの書き方から、エンジニアとしての誠実さを感じ取ることができた
  • ユーザー目線を大切にされていること、業務改善に高い意識で取り組まれていることが伝わる
  • 仲間やご家族を大切に思う気持ちが伝わる

スカウトされる側の心得...

少しテーマからそれますが、以上の内容は求職者(スカウトされる側)のプロフィールの書き方の参考にもなるかと思います。

求職者さんがスカウトをたくさん送られたい場合は、以下を気にかけると、スカウトを送る側にとって送りやすいプロフィールになるのかもしれません...

  • 企業に必要と思われるスキルや実績のアピール
  • 現業で実現できない、やりたいことや解決したいこと
  • 開発や事業に対する個人の想いや目標

私自身がスカウトされようとした実体験がないので、あくまで仮説の域を出ておりません。 的外れな発言だったらごめんなさい。

まとめ

以上のスカウト送信のポイントを実行して、私の今回のトライアルでは、母数はあまり多くないですが、20件送信して6件の返信、ちょうど3割の返信を獲得し、返信いただいたみなさんとカジュアル面接を実施することができました。

最後に、スカウトに限らず採用においては、私は以前から以下のことは大原則として心得るようにしています。

  • 採用プロセスはごく丁寧な対応に努めること
    • 採用候補者は企業のために時間を割いていただいているため
    • 採用候補者はプロダクトのユーザー、あるいは将来ユーザーになる可能性があるため

スカウトも、求職者の人生に少なからず関わろうとする行為であることを考慮しますと、丁寧にできることをやりきってから連絡するのが最低限のマナーなのかな、と感じました。

Bolt + lambda を使って Slack に通知メッセージを送る API を作る

この記事は個人ブログと同じ内容です

zenn.dev

 

 

Boltを利用してWebアプリと連携し、Slackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作ってみたので知見として書きます。

当記事で書くこと

  • Slack Appの設定
  • Bolt + serverless によるSlackBotのAPI実装
    • emailからユーザーとのDMチャンネルを検索
    • 取得したDMチャンネルIDへのメッセージ送信
  • lambdaへのデプロイ

当記事で書かないこと

  • Boltを利用したOAuth周りの認証設定

手順

【SlackApp側の設定】

Slack App 作成 ←のリンクから Slack App を作成する

App Home タブにてアプリの DM に表示するタブを設定

  • "App Display Name" の Edit ボタンから、好きな display name を設定して保存する
  • Messages Tab
    • ここのチェックをtrueにすると Messages Tab でユーザーがメッセージを送信できるようになる Allow users to send Slash commands and messages from the messages tab

OAuth & Permissions タブにて

  • Slack API の利用に必要な以下の権限を設定する
"channels:read",
"chat:write:bot",
"groups:read",
"im:read",
"mpim:read",
"users:read",
"users:read.email"

以下のAPIWorks withに必要なscopeが書いてあります

  • Install to WorkSpace する

【Slack App開発】

サンプルコードは以下のリポジトリで公開しています

まずはサーバーレスアプリケーションを開発、デプロイするためのツールをインストールします

こちらの記事で紹介されている事前準備を行ってください

Lambdaの作成

下記コマンドを実行してNode.js用の作業ディレクトリとLambdaの定義ファイル作成します。

$ serverless create --template aws-nodejs --path myService

以下を実行して初期設定を行います

$ yarn init
myService
├── .npmignore
├── handler.js
├── package.json
└── serverless.yml

以下のコマンドで必要なパッケージをインストールします

$ yarn add @slack/bolt @vendia/serverless-express multiparty
$ yarn add -D serverless serverless-offline

package.jsonに以下のscriptを追記します これでyarn devすることでlocalでデバックできるようになりました

package.json

{
    ...
    "scripts": {
        "dev": "sls offline",
        "deploy": "npx serverless deploy"
    },
}

Serverlessの設定ファイルを以下の内容に変更します

serverless.yml

service: serverless-bolt-js
frameworkVersion: "2"
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
functions:
  slack:
    handler: app.handler
    events:
      - http:
          method: ANY
          path: /{any+}
useDotenv: true
plugins:
  - serverless-offline
package:
  patterns:
    - '!.git/**'
    - '!README.md'

あわせて環境変数を追加します

.env

SLACK_SIGNING_SECRET="xxx"
SLACK_BOT_TOKEN="xoxb-xxx"

準備ができたら処理を書いていきます

handler.js

const { App, ExpressReceiver } = require("@slack/bolt");
const serverlessExpress = require("@vendia/serverless-express");
const multiparty = require("multiparty");

const accessToken = process.env["SLACK_BOT_TOKEN"];

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env["SLACK_SIGNING_SECRET"],
  processBeforeResponse: true,
});

const app = new App({
  token: accessToken,
  receiver: expressReceiver,
});

// /slack/events/massegesへのpostリクエストのエンドポイント作成
app.receiver.router.post("/slack/events/masseges", async (req, res) => {
  // req から fields を抽出する
  const data = await new Promise((resolve, reject) => {
    const form = new multiparty.Form();
    form.parse(req, (err, fields, files) => {
      resolve(fields);
    });
  });

  // validation
  if (!data.email) {
    res.status(400).send("error: no_email");
    return;
  }
  if (!data.text) {
    res.status(400).send("error: no_text");
    return;
  }

  const userEmailList = data.email.find((_, i) => i === 0).split(",");
  const massege = data.text.find((_, i) => i === 0);

  let userIds = [];
  for (const email of userEmailList) {
    try {
      // reqパラメーターのemilがワークスペースに存在するか確認
      const user = await app.client.users.lookupByEmail({
        token: accessToken,
        email: email,
      });
      if (user) {
        userIds = [...userIds, user.user.id];
      }
    } catch (error) {
      res.status(400).send(`error: user is Not Found. ${email}`);
      return;
    }
  }

  // DMチャンネル一覧を取得
  const conversationsList = await app.client.conversations.list({
    token: accessToken,
    types: "im",
  });
  const channels = conversationsList.channels;

  if (!!userIds.length) {
    // メールアドレスから取得したユーザーの DM チャンネルのみにフィルター
    channels
      .filter((x) => {
        return userIds.some((y) => {
          return y === x.user;
        });
      })
      .forEach((x) => {
        // メッセージ送信
        app.client.chat.postMessage({
          token: accessToken,
          channel: x.id,
          blocks: massege,
        });
      });
  }
  res.status(200).send("success!!");
});

// Handle the Lambda function event
module.exports.handler = serverlessExpress({
  app: expressReceiver.app,
});

デプロイ

以下コマンドでlambdaへデプロイします

yarn deploy

これで完成です!

試してみる

APIエンドポイントに対してcurlでリクエストを送ってみる (email が一致したユーザーは Slack の DM にメッセージが送信される)

aws

通知メッセージを作成する

paramater

  • email : カンマ( , )区切りで通知を送信したいユーザーのメールアドレスを渡す
  • text : メッセージの block を作成しパラメーターに渡す
$ curl --location --request POST 'https://xxxxxxxxxx.amazonaws.com/dev/slack/events/masseges' \
--form 'email="taro@hoge.com,jiro@hoge.com"' \
--form 'text="[
    {
        \"type\": \"section\",
        \"text\": {
            \"type\": \"mrkdwn\",
            \"text\": \"*twitterフォローしてね!*\"
        }
    },
    {
        \"type\": \"section\",
        \"fields\": [
            {
                \"type\": \"mrkdwn\",
                \"text\": \"https://twitter.com/Area029S\"
            }
        ]
    }
]"'

送信できました!

screeen_shot

あとがき

以上でSlackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作成することができました。 今後はBoltの認証機能を利用したマルチワークスペース対応の実装例を紹介できればと思います。 ちなみにシンプルに通知のみを実行したいのであればBoltを使わないでサービス側で直接SlackAPIを叩いてしまう方が低コストに実現できます。

参考にした記事

Date系ライブラリとIE11とタイムゾーン

この記事は個人ブログと同じ内容です

http://ratatatat30.hatenablog.jp/entry/2021/05/14/130211


sekitats です。backcheck で主に送りバントをしています。

さて、これまで backcheck では Date系ライブラリとして moment-timezone.js を使っていました。2020年9月 moment.js はすでにメンテナンスモードに入っており、backcheck ではこの度重い腰を上げて代替ライブラリへの移行をすることになりました。

以下の候補から最適なライブラリを選択するため調査を行いました。

  • date-fns (date-fns-tz)
  • Day.js
  • Luxon
  • js-joda(js-jodaについてはほぼ調査していませんが、念の為候補としてあげています。)

IE11 サポートとタイムゾーンの壁

前提として、Backcheck では IE11のブラウザ使用率が数%あり、IE11のサポートを完全に切ることができません。 選定の課題として IE11上でタイムゾーンの機能が正常に動作するかという点が必須項目となりました。

以下メリデメを表にしました。

ライブラリ名 メリット デメリット
Day.js ・他のライブラリと比べて最もファイルサイズが軽い
・moment に寄せて作られているため移行コストが最も安い
・開発継続性高い
・学習コストがほぼいらない
・IE11や timezone 関連のサポートの信頼性が低い(ライブラリの中では最もモダンブラウザに振り切ってる印象)
・IE11で意図した出力がされなかったため致命的と判断
date-fns(date-fns-tz) ・IE11での動作を確認
・採用しているプロジェクトが多い
・開発継続性高い。
・使用経験者がいる
・関数型設計のため、NuxtプロジェクトやPHPユーザーとは相性が悪い
・学習コストが要る
Luxon ・IE11での動作を確認
・moment.js のコントリビューターをしている人が作っているため後継として信頼性が高い
・Moment の後継であり開発継続性は高い
・IE11サポートに関するドキュメントが充実
・あまり人気がない
・使用経験者がいない
・学習コストが要る
js-joda - 独自実装のためIE9までサポートしている ・人気がない
・使用経験者がいない
java.time, Joda-Tome or Noda Time に慣れた人にはメリットだが、おそらくチーム的に不向き
・学習コストがいる

Luxon に決定 🎉

f:id:sekilberg:20210514111452j:plain
IE11で各種日付ライブラリを検証する

本命の Dayjs は、IE11ではタイムゾーン('Asia/Tokyo’)を指定した日時が意図した結果のになりませんでした。

date-fns (date-fns-tz)とLuxon はdate-fnsが標準時の表記(JST)、Luxon が IANA Time Zone 表記('Asia/Tokyo’)といった違いが出たものの日時は意図した時間が出力されました。 あとは好みや使い勝手といったところになってきますが Nuxt プロジェクトやPHPer との相性(Carbonの使用感との差)といった点で date-fns は脱落。最後に残ったのは Luxon でした。もともと Luxon は Moment.js のコントリビューターが作っているということもあり、IE11サポートのドキュメントも充実していてやはり安心感が違います。

polyfill

さて、IE11は UTC以外の特定のタイムゾーンをサポートしていません。IE11でタイムゾーンをサポートするためには polyfill が必要になります。

Option value ‘Asia/Tokyo’ for 'timeZone' is outside of valid range. Expected: ['UTC’]

https://stackoverflow.com/questions/54364061/ie-11-throwing-timezone-is-outside-of-valid-range-when-setting-timezone-to

IE11でタイムゾーンを扱う際の polyifll として date-time-format-timezone があります。しかし minified でも 2.64MBあり、そのままバンドルに含めると moment-timezone.jsよりもファイルサイズが増えてしまうため注意が必要です。さらにリポジトリアーカイブされており今後更新はされないため最新の polyfill を使うようにします。

最新のはこちら formatjs.io

すべて CDNから取得する場合は以下になります。polyfill.io は userAgent を見て polyfill が必要であるかを判断してファイルを返してくれるため、モダンブラウザで不要なファイルのロードをせずに済みます。

// nuxt.config.js
head: {
  script: [
    {
      src: ‘https://polyfill.io/v3/polyfill.min.js?features=Intl.~locale.ja,Intl.Locale,Intl.getCanonicalLocales,Intl.PluralRules.~locale.ja,Intl.NumberFormat,Intl.NumberFormat.~locale.ja,Intl.DateTimeFormat,Intl.DateTimeFormat.~locale.ja,Intl.NumberFormat.~locale.ja’,
      defer: true
    },
  ],
}

しかし、試してみたところ Intl.DateTimeFormat.~locale.ja のところで正しくロードできませんでした。 import する場合は下記のようになるかと思います。(そのほか必要な polyfill は適宜追加してください。 全てCDNにするか、全てimport にするかにしないと運用がたいへん。。)

// plugins/polyfill-datetimeformat.js
import { shouldPolyfill } from "@formatjs/intl-datetimeformat/should-polyfill";
(async function polyfill() {
  if (shouldPolyfill()) {
    try {
      const dataPolyfills = [
        import("@formatjs/intl-getcanonicallocales/polyfill"),
        import("@formatjs/intl-locale/polyfill"),
        import("@formatjs/intl-numberformat/polyfill"),
        import("@formatjs/intl-numberformat/locale-data/ja"),
        import("@formatjs/intl-pluralrules/polyfill"),
        import("@formatjs/intl-pluralrules/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/polyfill"),
        import("@formatjs/intl-datetimeformat/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/add-all-tz"),
      ];
      await Promise.all(dataPolyfills);
    } catch (e) {
      console.error(e);
    }
  }
})();

まとめ

IE11とタイムゾーンのサポートまでしているところはあまりないのではないでしょうか。タイムゾーンは日本で住んでいるとあまり意識することがありませんが、アプリケーションの世界では必ず必要になる概念であり、非常に学びの多いミッションでした。

代替ライブラリの選定にあたり、date-fns (date-fns-tz), Dayjs, Luxon, (js-joda) について多くの記事を調べましたが、IE11とタイムゾーンに言及した情報というのは見た限りはなかったと思います。この記事が有益な情報となれば幸いです。


AWS CloudFront で S3 にホスティングした静的ウェブサイトを CDN 配信(&独自ドメインのHTTPS化)

この記事は個人ブログと同じ内容です

www.ritolab.com


AWS の S3 にホスティングした静的 WEB サイトを CloudFront を用いて CDN での配信を行うと共に、SSL 証明書等の整備も行って独自ドメインHTTPS でアクセスできるようにしていきます。

なお、S3 にホスティングした WEB サイトを独自ドメインHTTPS でアクセスできるようにするにも同じで、今回のように CloudFront を用いて構築する必要があり、手段手順としては同じになります。

S3 にホスティングした静的 WEB サイト

今回は CloudFront や SSL/TLS 証明書回りがメインです。

S3 に静的 WEB サイトをホスティングする部分については前回の記事にまとめています。

今回は前回の記事の続きになるので、環境変数周りや Route53 や S3 の設定等もこちらを確認してください。

www.ritolab.com

実行環境

今回の実行環境は以下になります。

  • Terraform v0.15.1

コードはモジュール化を行わずに一つの tf ファイルに書いていきます。

AWS プロバイダの追加

まずは aws プロバイダを追加します。

既に前回で東京リージョンの aws プロバイダを作成済みですが、ACM で作成した SSL/TLS 証明書を CloudFront にアタッチすためには証明書をバージニア北部リージョンで作成する必要があるため(2021/05/04現在)、別途こちらの aws プロバイダを作成しておきます。

main.tf

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = "us-east-1"
  alias      = "virginia"
}

リージョンをバージニア北部にし、エイリアスを付けてデフォルトとは別にこちらを指定して使用できるようにしています。

ログ用の S3 バケット作成

CloudFront のアクセスログを格納するバケットを S3 に作成します。

main.tf

## S3 for cloudfront logs
resource "aws_s3_bucket" "cloudfront_logs" {
  bucket = "${local.fqdn.name}-cloudfront-logs"
  acl    = "private"

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

CloudFront ディストリビューション作成

CloudFront のディストリビューションを作成します。

main.tf

## キャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_optimized" {
  name = "Managed-CachingOptimized"
}

## ディストリビューション
resource "aws_cloudfront_distribution" "main" {
  origin {
    domain_name = "${local.bucket.name}.s3-${var.aws_region}.amazonaws.com"
    origin_id   = "S3-${local.fqdn.name}"
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  # Alternate Domain Names (CNAMEs)
  aliases = [local.fqdn.name]

  # 証明書の設定
  viewer_certificate {
    cloudfront_default_certificate = false
    acm_certificate_arn            = aws_acm_certificate.main.arn
    minimum_protocol_version       = "TLSv1.2_2019"
    ssl_support_method             = "sni-only"
  }

  retain_on_delete = false

  logging_config {
    include_cookies = true
    bucket          = "${aws_s3_bucket.cloudfront_logs.id}.s3.amazonaws.com"
    prefix          = "log/"
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${local.fqdn.name}"
    viewer_protocol_policy = "allow-all"
    compress               = true
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_optimized.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
  • キャッシュポリシー
    • 今回は特に細かく設定する必要がないためマネージドのものを使用しています。
    • 管理キャッシュポリシーの使用
  • ディストリビューション
    • 証明書の設定
      • acm_certificate_arn は、この次で設定する ACM の値を設定します。
      • 前項でバージニア北部リージョンのプロバイダを作成しましたが、記事執筆時点では us-east-1 region のものしか指定できません。

SSL/TLS 証明書 作成

ACM(AWS Certificate Manager) にて SSL/TLS 証明書を作成し、独自ドメインに紐づけていきます。

aws.amazon.com

証明書のリクエス

SSL/TLS 証明書の発行を ACM にリクエストします。

main.tf

resource "aws_acm_certificate" "main" {
  provider                  = aws.virginia
  domain_name               = local.fqdn.name
  validation_method         = "DNS"
}
  • プロバイダはバージニア北部リージョンの方を指定しています。
  • 今回使用する独自ドメインFQDN を指定しています。
  • 検証は DNS で行なうように指定しています。

CNAME レコード 作成

証明書リクエストに対して DNS で検証を行なうため、CNAME レコードを追加します。

main.tf

## CNAME レコード
resource "aws_route53_record" "main_acm_c" {
  for_each = {
    for d in aws_acm_certificate.main.domain_validation_options : d.domain_name => {
      name   = d.resource_record_name
      record = d.resource_record_value
      type   = d.resource_record_type
    }
  }
  zone_id = data.aws_route53_zone.naked.id
  name    = each.value.name
  type    = each.value.type
  ttl     = 172800
  records = [each.value.record]
  allow_overwrite = true
}

証明書の検証

証明書のリクエストに CNAME レコードを連携させ、DNS 検証の完了を確認します。

main.tf

## ACM 証明書 / CNAME レコード 連携
resource "aws_acm_certificate_validation" "main" {
  provider        = aws.virginia
  certificate_arn = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.main_acm_c : record.fqdn]
}

独自ドメインの向き先を変更する

最後に、前回までは S3 に向いていた独自ドメインの向き先を CloudFront に変更します。

main.tf

## A レコード(to CloudFront)
resource "aws_route53_record" "main_cdn_a" {
  zone_id = data.aws_route53_zone.naked.zone_id
  name    = local.fqdn.name
  type    = "A"
  alias {
    evaluate_target_health = true
    name                   = aws_cloudfront_distribution.main.domain_name
    zone_id                = aws_cloudfront_distribution.main.hosted_zone_id
  }
}

CloudFront へ A レコードを設定しました。

前回の記事で S3 に向けた A レコードは削除します。

## 削除
## A レコード(to S3 Bucket)
//resource "aws_route53_record" "main_a" {
//  zone_id = data.aws_route53_zone.naked.zone_id
//  name    = local.fqdn.name
//  type    = "A"
//  alias {
//    evaluate_target_health = true
//    name = "s3-website-${var.aws_region}.amazonaws.com"
//    zone_id = aws_s3_bucket.app.hosted_zone_id
//  }
//}

動作確認

ここまでで terraform から環境を構築すると、CloudFront 及び HTTPS でのアクセスが可能となっている事が確認できます。

f:id:ro9rito:20210514061244p:plain

HTTPS でのみのアクセスにする

現時点では http と https どちらでもアクセス可能な状態なので、http でアクセスがきたものは https にリダイレクトするようにします。

main.tf

# allow-all を redirect-to-https に変更する
# viewer_protocol_policy = "allow-all"
viewer_protocol_policy = "redirect-to-https"

aws_cloudfront_distribution リソースの default_cache_behavior にある viewer_protocol_policy の値を allow-all から redirect-to-https へ変更します。

これで http でアクセスがきたものは https にリダイレクトされるようになります。

S3 へのアクセスを制限する

訪問者が S3 のバケットへ直接アクセスしないように制限を掛け、CloudFront ディストリビューションを介してのみアクセスできるようにします。

docs.aws.amazon.com

Origin Access Identity(OAI)作成

Origin Access Identity(以下、OAI)を作成します。

main.tf

## CloudFront OAI 作成
resource "aws_cloudfront_origin_access_identity" "main" {
  comment = "Origin Access Identity for s3 ${local.bucket.name} bucket"
}

OAI をディストーションに関連付ける

OAI で作成した CloudFront ユーザーをディストリビューションに関連付けます。

main.tf

resource "aws_cloudfront_distribution" "main" {
  origin {
    # 追加
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.main.cloudfront_access_identity_path
    }
  }
  .
  .
  .

aws_cloudfront_distribution リソースの origin に s3_origin_config として origin_access_identity を設定します。

IAMポリシードキュメント作成

アクセスを制御するポリシードキュメントを作成します。(ここで作成したポリシーを json としてアタッチする)

main.tf

# IAMポリシードキュメント作成
data "aws_iam_policy_document" "s3_policy" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app.arn}/*"]

    principals {
      identifiers = [aws_cloudfront_origin_access_identity.main.iam_arn]
      type        = "AWS"
    }
  }
}

ポリシーをバケットにアタッチ

作成したポリシーをアプリケーションのバケットに紐付けます。

main.tf

# ポリシーをバケットに紐付け
resource "aws_s3_bucket_policy" "main" {
  bucket = aws_s3_bucket.app.id
  policy = data.aws_iam_policy_document.s3_policy.json
}

アプリケーションのバケットへのアクセス制御変更

前回の記事で作成したアプリケーションを配置するバケットでは、PublicRead が ON になっている状態です。これをプライベートに変更します。

main.tf

## S3 for Static Website Hosting
resource "aws_s3_bucket" "app" {
  bucket = local.bucket.name
  acl    = "private"

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

これで、作成した OAI での CloudFront からのアクセスのみ S3 バケットへのアクセスが可能になりました。

S3 アプリケーションログ用のバケット削除

この時点で S3 へのアクセスは全て CloudFront 経由になり、S3 への訪問者のアクセスログバケットは不要になるので、前回の記事で作成したバケットは削除します。

main.tf

# 削除
//data "aws_canonical_user_id" "current_user" {}

# 削除
## S3 for app logs
## https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket
//resource "aws_s3_bucket" "app_logs" {
//  bucket = "${local.bucket.name}-logs"
//
//  grant {
//    id          = data.aws_canonical_user_id.current_user.id
//    permissions = ["FULL_CONTROL"]
//    type        = "CanonicalUser"
//  }
//  grant {
//    permissions = ["READ_ACP", "WRITE"]
//    type        = "Group"
//    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
//  }
//
//  tags = {
//    Name        = var.project_tag_name
//    Environment = var.project_environment_name
//  }
//}

動作確認

ここまでの設定を適用させると、ブラウザから S3 の URL でコンテンツへアクセスできなくなった事が確認できました。

f:id:ro9rito:20210514061805p:plain

f:id:ro9rito:20210514061813p:plain

まとめ

静的な WEB を公開するのであればコスト面で優れている S3 へのホスティングはおすすめです。

また、CloudFront も使うと高速にコンテンツを配信する、独自ドメインHTTPS 化するなど、できる幅も広がるのでおすすめです。

AWS S3 に静的ウェブサイトをホスティングして独自ドメインで公開するまで

この記事は個人ブログと同じ内容です

www.ritolab.com


静的ウェブサイトを Amazon S3ホスティングし、独自ドメインで公開するまでの環境構築を terraform を使って行っていきます。

実行環境

今回の実行環境は以下になります

  • Terraform v0.15.1

コードはモジュール化を行わずに一つの tf ファイルに書いていきます。

今回使う AWS のサービスは、S3 と Route53 です。

環境変数について

terraform で構成を定義していくにあたり、環境変数を tfvars に記述しておきますが、以下のようになっています。

terraform.tfvars

aws_id           = 123456789
aws_access_key   = "XXXXXXXXXXXXXXXXXXXX"
aws_secret_key   = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
aws_region       = "ap-northeast-1"
domain_name      = "example.com"
domain_host_name = "www"
  • AWS のアカウント情報と、デフォルトのリージョンとして東京リージョンを指定しています。
  • ネイキッドドメインとホスト名をそれぞれ分けて定義しています。

変数の設定

これから使用する値について変数とプロバイダを設定しておきます。

main.tf

# variables
variable "aws_id" {}
variable "aws_access_key" {}
variable "aws_secret_key" {}
variable "aws_region" {}
variable "domain_name" {}
variable "domain_host_name" {}

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = var.aws_region
}

locals {
  fqdn = {
    name = "${var.domain_host_name}.${var.domain_name}"
  }
  bucket = {
    name = local.fqdn.name
  }
}
  • AWS プロバイダを定義しています。
  • Route53 や S3 で使用するための FQDNBucket 名の変数を作成しています。

アクセスログ用のバケット作成

S3 にホスティングする場合でも、アクセスログを取る事ができます。

docs.aws.amazon.com

そのためのバケットを S3 に作成します。(これが無くてもサイト公開はできるので、必要なら作成してください)

main.tf

## AWS アカウントのユーザー
data "aws_canonical_user_id" "current_user" {}

## S3 for app logs
resource "aws_s3_bucket" "app_logs" {
  bucket = "${local.bucket.name}-logs"

  grant {
    id          = data.aws_canonical_user_id.current_user.id
    permissions = ["FULL_CONTROL"]
    type        = "CanonicalUser"
  }
  grant {
    permissions = ["READ_ACP", "WRITE"]
    type        = "Group"
    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
  }
}
  • 権限は ACL ポリシーで設定しています。
    • 前者(1 つめの grant)は、コンソール画面から操作できるように、AWS アカウントの正規ユーザーの権限を設定しています。
    • 後者(2 つめの grant)は、ログを配信するための権限です。

アプリケーション用のバケット作成

静的なウェブサイトのソースコードを配置する S3 バケットを作成します。

main.tf

## S3 for Static Website Hosting
resource "aws_s3_bucket" "app" {
  bucket = local.bucket.name
  acl    = "public-read"
  policy = templatefile("bucket-policy.json", {
    "bucket_name" = local.bucket.name
  })

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  logging {
    target_bucket = aws_s3_bucket.app_logs.id
    target_prefix = "log/"
  }
}

バケット名は FQDN と同じにします。

templatefile として挿入している json は以下のようになっています。

bucket-policy.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::${bucket_name}/*"
            ]
        }
    ]
}

logging の部分は、前項でアクセスログ用のバケットを作成した場合に指定するので、作っていなければ指定不要です。

バケットを作成したら index.html を配置してアクセスしてみると表示されます。

f:id:ro9rito:20210507113705p:plain

独自ドメインの設定

WEB サイトの公開はできましたが、ここまでだと URL が amazonaws.com なので、独自ドメインを設定します。

ドメインとホストゾーンの作成について

ドメインですが、前提として、Route53 に登録済みのドメインを使用します。

また、ネイキッドドメインのホストゾーン登録は済ませてある前提で進めるので、例えば www.example.com で S3 にホスティングしたサイトを公開するなら、そのネイキッドドメインである example.com のホストゾーンは作成しておいてくだださい。

docs.aws.amazon.com

これだけです。

ちなみに、ドメインのネームサーバーと、作成したホストゾーンの NS レコードに設定されているネームサーバーが同じ事を確認して、もし違うようならどちらかに揃える必要があります。

レコードの設定

terraform で Route53 にレコードを作成します。

main.tf

# Route 53
## ネイキッドドメインのホストゾーン情報
data aws_route53_zone "naked" {
  name = var.domain_name
}

## A レコード作成(to S3 Bucket)
resource "aws_route53_record" "main_a" {
  zone_id = data.aws_route53_zone.naked.zone_id
  name    = local.fqdn.name
  type    = "A"
  alias {
    evaluate_target_health = true
    name = "s3-website-${var.aws_region}.amazonaws.com"
    zone_id = aws_s3_bucket.app.hosted_zone_id
  }
}
  • A レコードを作成しています。
  • ホストゾーンは、作成済みのネイキッドドメインのホストゾーン情報を取得して zone_id に指定しています。
  • エイリアスの設定として、S3 のウェブサイトエンドポイントを設定します。

docs.aws.amazon.com

動作確認

レコードを作成したら、あとは DNS の浸透を待ってアクセスすると表示されます。

f:id:ro9rito:20210507113920p:plain

S3 にホスティングしたウェブサイトを独自ドメインで公開できました。

OASとTypescript ベネフィット編

4月からRoxxに合流したkotamatsuiです(※kotamatさんとは別人です)。よろしくおねがいします。 こちらの記事はZennからの転載になります zenn.dev


はじめに

スキーマ駆動開発という手法が数年前からたびたび話題にあがっています。 スキーマ駆動開発の紹介は既に多数の先人が記事を書いておられるのでそちらをご覧いただくこととして、この記事ではOAS、特にOpenAPI Genertorを利用したスキーマ駆動開発を導入することによる、フロントエンド目線で見たときに実作業者が得られるベネフィットをまとめてみたいと思います。

実際筆者は最近、APIの仕様書を渡されて2つのエンドポイントからなるSPA(サーバー側はもう動いている状態)を0ベースで開発する機会がありましたが、渡されたAPI仕様書をOAS定義に翻訳し、各種ジェネレーターを駆使してAJAX部分を実装することで、Nuxt typescriptのプロジェクト作成からアプリケーション完成まで4~5時間程度で終わらせることができました。

とにかく使いこなせれば、病みつきになるレベルの爆速開発が可能になります

スタブサーバーが用意できる

OpenAPI generatorには各種スタブサーバー用のジェネレーターが用意されています。 これによりサーバー側の実装が進んでいない状態でも、OASの定義にexampleを書いておけばそのレスポンスを使ってフロント側の実装を進めたり、デモ画面を作成したりすることが可能になります。 フロントエンドエンジニアはexampleだけでもいじれるようになっておけば、様々なデザインパターンを想定したデータを作って開発を進めることができます。

スタブサーバーの実装方法にこだわりが無ければAPI Sproutを利用するのが良いでしょう。 dockerですぐ動く状態のイメージが用意されていますので、docker-composeに追加してOASの定義ファイルをマウントするだけでお手軽にスタブサーバーが用意できます。更新検知もしてくれて、OASの定義を変更すればすぐさまレスポンスも追従してくれます。

API Sproutをローカルの開発サーバーと別のポートで建てておくと、フロント側で開発サーバー見に行くかスタブ見に行くか切り替えるなど出来ていろいろと使い勝手が良いです。

なお、exampleを書かなかった場合でも、例えば文字列なら"String"のような何かしらスキーマに合わせた値を返してくれます。

IDEの強力な支援が受けられる

pathsからAPIクライアントを生成

上記該当箇所のコード

OpenAPI generatorには各種APIクライアント用のジェネレーターも用意されています。

上の図はtypescript-axios用のジェネレーターの例です。ジェネレーターはOASのpathsの内容に応じて上記のようなメンバメソッドを持ったクラスを生成してくれますので、BaseURLや認証情報などのConfigをクラスのコンストラクタに渡してしまえば、あとはメソッドを叩くだけでリクエストが可能です。

youtu.be

上記の例ではリクエストにパラメーターが不要だったので引数がありませんが、パラメーターが必要な場合は引数に何が必要かIDEが表示してくれます。また、Promiseが解決された場合の戻り値の型をAxiosReasponseのGenericsに埋め込んでくれるため、IDEはレスポンスの型を静的に推論することができます。

components/schemas(など)からIntefaceを生成

ジェネレーターは上記APIクライアントと同時にcomponents/schemasの内容やpathsのレスポンスボディなどのスキーマから、TSのInterfaceを生成してくれます。

これによりAPIのレスポンスが返ってくる前の初期値を作ったり、

import { DefaultApi, SomeModel } from "path/to/api";
class Hoge {
  private response: null | SomeModel = null

  async request (): Promise<void> {
    this.response = await new DefaultApi().someRequestGet().data
  }
}

のような形でレスポンスを格納する変数を先にnullで初期化しておくなどの実装も簡単に行うことができます。

この生成されたInterfaceはOASのdescriptionがTSDocの書式で埋め込まれていますので、型をきちんと保ち続けていればマウスホバーするだけでIDEがdescriptionの内容を表示してくれるようになります。このため実装作業者はTSのコードとAPI仕様書やAPIの実際のレスポンスの画面とを行ったり来たりする頻度を大幅に減らすことができます。

また、typescrit-axios用のジェネレーターはenumにも対応しており、x-enum-varnamesというプロパティを使うことでstring enumを生成することも可能です。

~~
components:
  schemas:
    Genre:
      type: string
      enum:
        - rock
        - jazz
        - progress
      x-enum-varnames:
        - rock
        - jazz
        - progress
~~

↓生成物

~~
/**
 * 
 * @export
 * @enum {string}
 */
export enum Genre {
    rock = 'rock',
    jazz = 'jazz',
    progress = 'progress'
}
~~

スキーマ変更したときに影響範囲を一網打尽にできる(実装側の型の健全性が保たれていれば)

これは前項の内容とほぼ被りますが、TSの型検査とスキーマ定義が結合できるということは、スキーマを変更した場合にその影響を受ける部分が型チェックであぶりだせるということになります。

上記の例は、IDはintegerで来ると思って実装していたが実はstringが来ることが分かったのでスキーマを変更したケースです。定義ファイルの該当箇所を変更してジェネレーターでAPIクライアントを生成しなおしてから再度型検査を行ったことで、型が不一致になった個所が画面下のターミナルに羅列されています。

(補)Vueで型健全性をちょっと良くする小技

Vueのtemplate内はただの文字列なので、TSの型検査時には型検査が行われません1

型検査を抜けてしまうということは実際にコンポーネントをマウントして表示させてみるまで壊れていることに気づけないということになり、デバッグの負担が非常に増えます。これを避けるために、computedの戻り値の型指定を使う方法があります。

<template>
  <div>
    {{ hoge.some.property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
}
</script>

例えば上記のような場合、Hogeインターフェースの構造が変わった際にhoge.some.propertyに対する型検査は行われないため実行時エラーになります。 これを下記のように変更すると、トランスパイル時にエラーに気づくことができ、修正の負担を軽減することができます。

<template>
  <div>
    {{ property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
  get property(): string {
    return this.hoge.some.property
  }
}
</script>

template内が簡素になり可読性も上がるため、個人的にはかなり好んで多用しています。 もちろんこの方法はあくまで修正の負担を軽減してくれるだけで、完全に安全であることを保障してくれるわけではありません。テストは別途しっかり用意する必要があります。

実行時に実装とスキーマ定義の不整合を検査できる

スキーマ定義をしっかり決めて開発を進めても、実際に統合テストしてみたらスキーマとずれが生じることは(特に型が怪しい言語を使っている場合)よく起こります。

その際レスポンスやリクエストを目視してどこが不整合を起こしているか確認するというのはあまり賢い選択肢ではありません。OASを利用することでこの不整合カ所の調査を半自動化することができます。

OASopenapi2schemaというツールを使うことでJSON Schemaに変換することが可能です。JSON Schemaはオブジェクトの構造を検証することができるスキーマで、言語に依存しない中立的な仕様としてそれなりに知名度があり、様々な言語に対応しているバリデーターが存在します。

例えばJavascriptであればajvなどがあります。 これらを用いることでリクエストボディを送信する前にOAS定義との整合性を検査したり、レスポンスを受け取った後にレスポンスボディを検査したりすることができます。

ajvによるバリデーションの例

筆者が試した時点のopenapi2schemaはデフォルトでJSON Schema Draft v.4で出力してくれたので、まずはajvに使用するスキーマのバージョンを伝えます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);

次に、このajvと生成したスキーマをセットにしてexportしておきます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
import schema from 'path/to/scheme.json'; // openapi2schemaで生成したjsonファイル

const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);
const validator: {schema: typeof schema; ajv: Ajv.Ajv} = {
  schema,
  ajv
};

export default validator;

この時一つ注意しておくべきポイントとして、tsconfigの"resolveJsonModule"をtrueにしておく必要があります。"resolveJsonModule"を有効にしておくと、jsonをimportした際にtypescriptが中身を見て自動的に型推論してくれるようになります。 もしプロジェクトの事情により"resolveJsonModule"を有効にできない場合、あまりきれいな方法ではありませんがjson-to-ts-cliを使用してshcema.jsonからinterfaceの定義ファイルを生成することが可能です。

あとは実際にリクエストを飛ばす際に

import validator from "path/to/validator";
import { DefaultAPI } from "path/to/api";

export async function fetch() {
  const api = new DefaultAPI();                  // typescript-axiosクラスを初期化
  const res = await api.someRequestGet();        // リクエストを実行
  const path = "/some/request";
  const validate = validator.ajv.compile(
    validator.schema[path].get.responses[200]
  );                                             // テスト対象のスキーマをajvに流し込む
  const valid = validate(res.data);              // バリデーションを実行
  if (!valid) {
    console.warn(`validation error: ${path}`);
    console.warn(validate.errors);               // バリデーションエラーが発生した箇所をコンソールに表示
  }
}

のようにすることでバリデーションが可能です。

ただ、このスキーマ定義用のjsonはそれなりのファイルサイズになります。本番ビルドにこのjsonファイルを含めるのはあまり賢明ではないと思いますので、適宜ビルド環境に応じてバリデーション関連のファイルとロジックを切り離せるような仕組みにしておくとよいでしょう。

実装と連動した「信頼できる」API仕様書が出来上がる

これまで見てきたようにどっぷりOASに依存してクライアントの開発を進めた場合、必然的にOASスキーマ定義は実装と乖離することができない(乖離してしまうとそもそもまともに動かない)状態に持っていくことができます。

そのため、「OpenAPI generatorが動いている」かつ、「ジェネレーターで生成されたAPIクライアントが実装に組み込まれている」という二点さえ確認できれば、「OASの定義ファイルに書かれている内容は概ね最新の状態に保守されている」ということを保障することができます。

全てのAJAXOASベースに置き換えることができてしまえば、もう二度と「微妙に実装とずれてる仕様書やInterface定義」に苦しめられることはありません。

なお、VScodeにはopenapi-previewというプラグインがありますので、APIドキュメントとして利用するにはこのプラグインyamlファイルだけあれば事足ります。専用のプレビュー用サーバーなどを立てるのは、ほとんどの場合お金と時間の無駄になるだけでしょう。

※ただし、descriptionの文言だけはただの文字列なので、そこに嘘が書かれていてもTypescriptは何も指摘してくれません。信頼できるのはあくまでスキーマです。

まとめ

OASとTSの連携は、一度はまると病みつきになるレベルに爆速でコーディングが可能になるポテンシャルを持っていますが、使ったことがないとどうしても定義ファイルを管理するコストが気になって二の足を踏んでしまうケースが多いかと思います。

OASの定義ファイルと生成物自体は既存の実装を全く破壊しないので、部分的にじわじわとOASに置き換えておくことも可能です。 まずはエンドポイント一つでもOAS化してみて実際にどんなものか体験してもらうのが、ベネフィットを感じるには手っ取り早いかと思います。


  1. VScodeであればVeturのvetur.experimental.templateInterpolationServiceを使うことでtemplate内の型検査も行ってくれますが、今のところエディタ上で警告を出してくれるだけなのでトランスパイルは通ってしまうのと、ものすごくVScodeの負荷が大きくなるので動作が重くなるといった問題があります。