create next appした時のグローバルなCSSファイル、exampleによってglobals.cssだったりglobal.cssだったりする

小ネタ。

yarn create next-app したプロジェクトの設定ファイルなどを、 yarn create next-app --example with-jest したプロジェクトにコピーし、 yarn build したら Module not found: Can't resolve 'styles/globals.css' が発生。

TypeScriptプロジェクトにし、baseUrl を src にして、 pages や styles は src 配下に移動済みだったので、なんでだろうと思ったら、 styles 配下のCSSファイル名が異なっていた。

yarn create next-app だと globals.css だが、 yarn create next-app --example with-jest だと global.css と、拡張子前の s がない。

Next.js v12.2.0 のexamplesを検索すると、 globals.css が31件、 global.css が以下の9件。 with-jest 以外に、 with-react-hook-formglobal.css だった。

  • fast-refresh-demo
  • layout-component
  • script-component
  • with-ant-design
  • with-jest
  • with-jest-babel
  • with-react-hook-form
  • with-semantic-ui
  • with-unsplash

気づかずファイルを消してしまったのかと思い、結構慌てた。

Next.js + TypeScript + AWS AmplifyのプロジェクトでJest実行時に「SyntaxError: Unexpected token 'export'」が発生する

Next.js と AWS Amplify を使ったプロジェクトで、TypeScriptのファイルに対してJestのテストを書いたら、 SyntaxError: Unexpected token 'export' エラーが発生。

このエラー自体はよくあるやつだが、設定を大きくいじらず解決する方法を調べるのに結構時間がかかったので、対処法をメモ。

環境

名前 バージョン
Node.js 16.15.0
Next.js 12.1.5
TypeScript 4.6.3
Jest 28.1.0
aws-amplify*1 4.3.24
Amplify CLI 8.0.2
Yarn 1.22.15

環境についての備考

Amplifyは、記事作成時点ではNext.js v12をサポートしていない。

Support Next.js 12 · Issue #2343 · aws-amplify/amplify-hosting · GitHub

middlewareが動かないなどの問題がある模様。

NextJs 12 / Middleware feature is not working · Issue #2353 · aws-amplify/amplify-hosting · GitHub

事前設定

Next.jsのドキュメントに沿って、JestおよびReact Testing Libraryを導入。

依存パッケージを yarn add -D jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom で追加し、 jest.config.js をプロジェクトルート直下に作成する。

ほぼドキュメント通りだが、TypeScriptのbaseUrlを src/ に変更しているため、 moduleDirectories だけ変更している。

const nextJest = require('next/jest')

const createJestConfig = nextJest({ dir: './' })

const customJestConfig = {
  moduleDirectories: ['node_modules', '<rootDir>/src/'],
  testEnvironment: 'jest-environment-jsdom',
}

module.exports = createJestConfig(customJestConfig)

なお、 jest-environment-jsdom は、Jest v28より同梱されなくなった模様。明示的に追加しないと、テスト実行時に以下のエラーが発生する。

● Validation Error:

  Test environment jest-environment-jsdom cannot be found. Make sure the testEnv
ironment configuration option points to an existing node module.

  Configuration Documentation:
  https://jestjs.io/docs/configuration


As of Jest 28 "jest-environment-jsdom" is no longer shipped by default, make sur
e to install it separately.

問題

Amplify関連のモジュールをimportしたファイルを、テストファイルから読み込むと、 SyntaxError: Unexpected token 'export' エラーが発生する。内部で利用しているAWS SDKが原因の模様。

● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    <rootDir>\node_modules\@aws-sdk\client-location\node_modules\uuid\dist\esm-browser\index.js:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){export { default as v1 } from './v1.js';
                                                                                      ^^^^^^

    SyntaxError: Unexpected token 'export'

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1773:14)
      at Object.<anonymous> (node_modules/@aws-sdk/client-location/node_modules/@aws-sdk/middleware-retry/dist-cjs/StandardRetryStrategy.js:6:16)

対応

これ自体はよくあるやつで、Nodeモジュール内にCommonJSとESM(ECMAScript modules)が混在している模様。

エラーメッセージに含まれる通り To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config. してやればよい。

nextJest では transformIgnorePatterns に何が設定されているかと思い、ソースを確認。トランスパイルされたJavaScriptは、 node_modules/next/dist/build/jest/jest.js に出力されている。

Next.js v12.1.5では、 transformIgnorePatterns には固定値で /node_modules/ が指定されており、引数で渡したJest設定は追記されるようになっている。

        transformIgnorePatterns: [
          // To match Next.js behavior node_modules is not transformed
          '/node_modules/',
          // CSS modules are mocked so they don't need to be transformed
          '^.+\\.module\\.(css|sass|scss)$',

          // Custom config can append to transformIgnorePatterns but not modify it
          // This is to ensure `node_modules` and .module.css/sass/scss are always excluded
          ...(resolvedJestConfig.transformIgnorePatterns || []),
        ],

今回の場合、エラーが出ているパッケージはトランスパイルの対象にしたいので、 /node_modules/ を上書きしたい。

どうにかできないかとリポジトリを検索してみると、同じ内容のissueがあった。

github.com

変更可能とするプルリクエストもあったが、コメントを見ると現状のままにしておくためCloseされている。ただ、issue#35634のコメントや、PR#35635からリンクされたdiscussion#31152のコメントに対応方法が記載されていた。

単純に、 createJestConfig の結果をスプレッド構文で別オブジェクトに展開し、そこで transformIgnorePatterns を上書きしている。

以下のように jext.config.js を変更。 @aws-sdk/client-location 以外にも同様のエラーが発生したため、適宜追加し、シンタックスエラーを解消できた。

const nextJest = require('next/jest')

const createJestConfig = nextJest({ dir: './' })

const customJestConfig = {
  moduleDirectories: ['node_modules', '<rootDir>/src/'],
  testEnvironment: 'jest-environment-jsdom',
}

const jestConfig = async () => {
  // createJestConfig の戻り値は関数なので、さらに実行している
  const nextJestConfig = await createJestConfig(customJestConfig)()
  return {
    ...nextJestConfig,
    transformIgnorePatterns: [
      '/node_modules/@aws-sdk/(?!(client-(location|sts|sso))/)',
      '^.+\\.module\\.(css|sass|scss)$',
    ],
  }
}

module.exports = jestConfig

その他の問題

@aws-amplify/ui-react をimportしている部分で、 TypeError: window.URL.createObjectURL is not a function が発生。

これもよくある問題で、jsdomで実装されていない模様。

以下のモック設定ファイルを作成し、 jext.config.jssetupFiles で読み込ませて解消。

Object.defineProperty(URL, 'createObjectURL', {
  writable: true,
  value: jest.fn().mockImplementation(() => ''),
})

振り返り

nextJestでほとんど設定なしにJestが使えるようになったのに、やっぱりBabelでいろいろやらないとダメかと思ったが、大した手間もなく対応できてよかった。

ただ、 transformIgnorePatterns を上書きしてしまっているので、Next.jsのバージョンを変更した場合、追加・削除されたパスがないか都度確認してやる必要がありそう。

transformIgnorePatterns の配列を取得し、そこから /node_modules/ を削除、という形も考えたが、 /node_modules/ からスラッシュが消されたりする可能性も0ではないと思うので、どっちみちソース確認の必要性は変わらないか。

*1:Node Package

WindowsへのWSL2のインストールが、コマンド一発でできるようになっていた

仕事で使っているPCが新しくなったので、WSL(Windows Subsystem for Linux)2を使えるようにしようと思ったら、以前に比べて格段に楽になっていたのでメモ。

WSLインストールコマンド

docs.microsoft.com

Windows 10 バージョン 2004 以降、または Windows 11 の場合、管理者権限でPowerShellまたはコマンドプロンプトを実行し、 wsl --install を実行するだけで、必要な以下の設定を行ってくれる。

  1. Windowsの機能の有効化
  2. WSL カーネルのインストール
  3. WSL 2 の既定としての設定
  4. Linux ディストリビューションのインストール

完了したら、Windowsを再起動すればいい。

C:\WINDOWS\system32>wsl --install
インストール中: 仮想マシン プラットフォーム
仮想マシン プラットフォーム はインストールされました。
インストール中: Linux 用 Windows サブシステム
Linux 用 Windows サブシステム はインストールされました。
ダウンロード中: WSL カーネル
インストール中: WSL カーネル
WSL カーネル はインストールされました。
ダウンロード中: Ubuntu
要求された操作は正常に終了しました。変更を有効にするには、システムを再起動する必要があります。

コマンドを打つとwsl.exeの使用法が表示される場合

使用法: wsl.exe [Argument] [Options...] [CommandLine] のように、wsl.exeのヘルプが表示される場合、すでに「LinuxWindows サブシステム」が有効化されていると思われる。

Windows の機能の有効化または無効化」から、「LinuxWindows サブシステム」を無効化してWindowsを再起動し、再度コマンドを実行することで解消する。

インストールするLinux ディストリビューションの変更

検証していないが、 wsl --install -d <Distribution Name> で、Ubuntu以外のインストールができる模様。

Microsoft Store経由でダウンロードしているので、Ubuntu以外に指定できるのはDebianopenSUSE、Kali Linuxなど。

一覧はこちら。また、一覧にはないがOracle LinuxMicrosoft Store上で確認できた。

しかし、コマンドだとなんて指定するんだろ?

振り返り

従来は、あちこち行ったり来たりWindowsの再起動したりと面倒だったが、非常に簡単になった。

Docker Desktopをインストールしたら、しっかりWSL 2 バックエンドで動いてくれた。

ESLintの解析結果をGitLabのMRに連携する

以前の記事で、Violationsツールを使い、Javaプロジェクトの静的解析結果をGitLabのマージリクエストに連携した。

最近、Next.jsによる開発を行っており、エディタとして使用しているVSCode上ではリアルタイムでESLintによる解析をしているため、エラーがpushされることはほとんどない(あっても next build でエラーになる)が、警告は見過ごされることがある。

ViolationsがESLintに対応しているのは以前の記事の段階で確認しており、今回のプロジェクトでも、静的解析結果をGitLabのMRに連携してみたのでメモ。

環境

Violation Comments To GitLab Command Line v1.30.2。

Gitサーバーとしては、GitLab.comを使用。

Node.jsのパッケージマネージャーにはYarn v1.22.15を使用。

ツール概要

以前の記事を参照。

今回はGitLabのマージリクエスト(MR)連携を行うが、GitHubやBitbucketのプルリクエスト(PR)連携も可能。

これらのコマンドラインツールは、いずれも npx で実行可能。ただし、内部で利用されるViolations LibJavaライブラリのため、Java実行環境が必要。ないと Error: spawn java ENOENT といったエラーが発生する。

使用例

今回は、GitLab CI/CDによるパイプライン実行にて、ESLintの解析およびMR連携を行う。GitLabのCIランナーや、ESLintの設定等は省略。

GitLab設定

以前の記事と同様に、コメント用のユーザーを作成し、ユーザー設定 > アクセストークンから、apiスコープのパーソナルアクセストークンを作成。

GitLab CI/CD Variablesとして、作成したユーザーのアクセストークンを保存する。名前も前回と同様、 VIOLATION_API_TOKEN とした。

ESLint実行コマンドの作成

src/ 配下のJavaScriptまたはTypeScriptを対象として、解析結果をCheckstyle形式で lint/eslint.xml に出力する。ディレクトリが存在しなくても、ファイルごと作成してくれた。

yarn eslint --ext .js,.jsx,.ts,.tsx -o lint/eslint.xml -f checkstyle src/

GitLab MR連携コマンドの作成

Javaインストール済みの環境で、 npx violation-comments-to-gitlab-command-line にて実行可能。指定可能な引数はGitHubのREADME参照。

以前の記事で、Gradleにて設定したものと基本は同じ。

severitycomment-only-changed-contentsingle-file-comments 系の設定など、省略可能なものは省略。デフォルトで、差分のあるファイルにのみ、ディスカッション形式でコメントされるようになっていた。

コメントのテンプレートは文字列で指定する必要あり。複雑なテンプレートであれば、ファイルに記述して $(cat commentTemplate.txt) あたりで読み込んだほうがスッキリしそう。

npx violation-comments-to-gitlab-command-line \
  -gitlab-url "<GitLab URL>" \
  -project-id "<GitLab Project ID>" \
  -mr-iid "<Merge Request IID>" \
  -api-token "<API Token>" \
  -comment-template "**重大度**: {{violation.severity}}, **解析ツール**: {{violation.reporter}}{{#violation.rule}}, **ルール**: {{violation.rule}}{{/violation.rule}}\n\n**対象**: {{changedFile.filename}} \\# {{violation.startLine}}行目{{#violation.endLine}}~{{violation.endLine}}行目{{/violation.endLine}}\n\n**内容**: {{violation.message}}" \
  --violations "CHECKSTYLE" "lint" ".*/eslint.xml$" "ESLint"

ファイルパスの指定が上手くいかないことが多かったが、第2引数で指定したディレクトリ配下のファイルを再帰し、そのファイルパスが第3引数の正規表現パターン(java.util.regex.Pattern を使用)にマッチすれば処理対象として抽出している模様。第3引数の先頭には .*/ を指定しないと、まずマッチしない。

.gitlab-ci.yml設定

CI実行時のDockerイメージとして node:<version>-alpine を利用している。

Node.jsやYarnはインストール済みだが、Javaは未インストールされていないので、 before_script でインストールした。

コマンドで指定したGitLabのURL、プロジェクトID、MR IIDは、すべて対応する変数が存在するため、置き換えている。

また、 yarn eslint を実行した際に、解析結果にエラーが含まれると、exit codeが1となり、ジョブが中断してしまうため、 yarn eslint ... || true で、エラーが含まれる場合もexit codeを0にしている。

default:
  image: node:16.15-alpine

stages:
  - violation

violation:
  stage: violation
  allow_failure: true
  before_script:
    - apk update
    - apk --no-cache add openjdk17
    - yarn install
  script:
    - yarn eslint --ext .js,.jsx,.ts,.tsx -o lint/eslint.xml -f checkstyle src/ || true
    - |-
      npx violation-comments-to-gitlab-command-line \
        -gitlab-url "${CI_SERVER_URL}" \
        -project-id "${CI_PROJECT_ID}" \
        -mr-iid "${CI_MERGE_REQUEST_IID}" \
        -api-token "${VIOLATION_API_TOKEN}" \
        -comment-template "**重大度**: {{violation.severity}}, **解析ツール**: {{violation.reporter}}{{#violation.rule}}, **ルール**: {{violation.rule}}{{/violation.rule}}\n\n**対象**: {{changedFile.filename}} \\# {{violation.startLine}}行目{{#violation.endLine}}~{{violation.endLine}}行目{{/violation.endLine}}\n\n**内容**: {{violation.message}}" \
        --violations "CHECKSTYLE" "lint" ".*/eslint.xml$" "ESLint"
  rules:
    # マージリクエストの場合のみ実行
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
  tags:
    - violation

実行結果

以下のようなコメントが、解析結果に対応する変更行にコメントされるようになった。

振り返り

「なぜかわからないが出ている警告」みたいなものが、今回の作業でレビューしやすくなった。

早く修正したほうが、コストが少なく済むので、こうした警告を見落としにくくなったのはありがたい。

余談

久しぶりにGitLabのCI/CD設定を触ったら、デフォルトで Protect variable が有効になっていたのに気づかず。

保護されていないブランチだと、APIトークンが取れずに結構悩んだ。

Next.jsプロジェクトでTypeScriptのimportの並び順を指定する

JavaやKotlinを長く書いてきて、importの並び順、ソートはフォーマッターにお任せだった。

Next.jsをTypeScriptで書いているプロジェクトでも、Javaと同様、importの順序を統一したいと思い、設定したのでメモ。

環境

Next.js v12.1.5。パッケージマネージャーには Yarn v1.22.15を使用。

ESLintプラグインの導入

importの順序やソート順を変更するプラグインは複数あるが、よく使われている eslint-plugin-import は、Next.jsの公式ESLint設定である eslint-config-next含まれている

そのため、 create-next-app でプロジェクトを作成し、 .eslintrcnext/core-web-vitalsnextextends していれば、プラグインを明示的にインストールしなくても、importの順序の設定が可能。

ただ、ソート順の設定だけでなく、不要なimportの削除もしたいので、eslint-plugin-unused-imports を追加した。また、Prettierとの共存のため、 eslint-config-prettier も追加している。

yarn add -D eslint-config-prettier eslint-plugin-unused-imports

ちなみに、 eslint-plugin-import は、 eslint-config-react-appeslint-config-airbnb などにも含まれている模様。

.eslintrc の記述

以下、JSONCで例を記述。

import順の設定

import/order の記述方法は以下。

github.com

こちらの記事も参考になった。

qiita.com

基本的な並び順は groups で、パッケージ名などによるカスタマイズは pathGroups で行う。

react, nextが上に、CSSファイルは最下段に来るようにしている。

また、Amplifyを使用しているため、APIやgraphqlのimportは上に、Figma連携で作成されたUIコンポーネント(ui-components 配下)は下に来るようにし、Figma連携で作られたファイルはESLintの対象外とした。

{
  "extends": [
    "next/core-web-vitals",
    // eslint-config-prettier を有効化
    "prettier"
  ],
  "ignorePatterns": ["src/ui-components/**"],
  "rules": {
    // importの並び順設定
    "import/order": [
      "error",
      {
        "groups": [
          "builtin",
          "external",
          "internal",
          ["parent", "sibling"],
          "object",
          "type",
          "index"
        ],
        "pathGroups": [
          {
            "pattern": "react",
            "group": "external",
            "position": "before"
          },
          {
            "pattern": "next/**",
            "group": "external",
            "position": "before"
          },
          {
            "pattern": "API",
            "group": "internal",
            "position": "before"
          },
          {
            "pattern": "graphql/**",
            "group": "internal",
            "position": "before"
          },
          {
            "pattern": "ui-components/**",
            "group": "index",
            "position": "after"
          },
          {
            "pattern": "**\\.css",
            "group": "index",
            "position": "after"
          }
        ],
        "pathGroupsExcludedImportTypes": ["react", "next/**"],
        "newlines-between": "always",
        "alphabetize": { "order": "asc", "caseInsensitive": false }
      }
    ],
    // importをファイル先頭に記述
    "import/first": "error",
    // 最後のimportの後に空行を追加
    "import/newline-after-import": "error"
  }
}

ちなみに、importだけでなくrequireを用いてもこの設定は有効になる。Amplifyで追加したLambda関数内ではrequireを使っているが、そちらにも効いてくれた。

不要なimportの削除

前述の記事の通り、 pluginsrules の追加だけで有効となる。

{
  "plugins": ["unused-imports"],
  "rules": {
    "unused-imports/no-unused-imports": "error"
  }
}

波かっこ内のソート

これでimportの順序についてはソートできるようになったが、 import { b, a } from ...import { a, b } from ... にするような設定は、 eslint-plugin-import には存在しない。

調べると、ESLintの標準ルールセットである sort-imports に存在した。

eslint.org

ignoreMemberSort が対象だが、オプションの1つであり、これだけを有効化することはできない模様。

もう少し調べてみると、 eslint-plugin-import に同様の機能追加希望のissueが上がっており、そのコメントに共存させる設定方法が記載されていた。

github.com

シンプルに、 ignoreMemberSort 以外はすべてoffにすればいい。

{
  "rules": {
    "sort-imports": [
      "error",
      {
        "allowSeparatedGroups": true,
        "ignoreCase": true,
        "ignoreDeclarationSort": true,
        "ignoreMemberSort": false,
        "memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
      }
    ],
    "import/order": [...]
  }
}

VSCodeの設定

エディタとしてVSCodeを使用しているので、ファイル保存時に自動保存をかけたい。

また、これまたJavaのように、不足しているimportがあれば、自動で追加させたい。

以下のように .vscode/settings.json を記述することで対応できた。

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  // 保存時のアクション
  "editor.codeActionsOnSave": {
    // 不足しているimportの追加
    "source.addMissingImports": true,
    // ファイルのfix
    "source.fixAll": true
  }
}

どんな形式のファイルでもフォーマットしてほしいので、デフォルトのフォーマッターはPrettierにして、保存時にフォーマットするよう設定している。

ESLintの対象となるTypeScriptやJavaScriptファイルは、 editor.formatOnSave をoffにしておいたほうがいいかとも思ったが、 eslint-config-prettier を使っているおかげか、今のところ特に問題はなく使えている。

振り返り

importの順序はけっこう癖が出ると思う。

ファイルの修正のついでに人力ソートしてくれる人もいたりするが、それはそれで差分が増えてコードレビューの時に気になったりするので、機械的に統一できるようになってよかった。

eslint-config-next でルールを決めてくれれば、何もしなくていいので最高なんだけど、そこまではやらないか。本質的な部分ではないし。

Amplifyで作成したLambdaにロールを付与してSESでメール送信する

前回の続き。

LambdaからSESによるメール送信を行う方法として、前回はIAMを作成し、アクセスキーを使ったが、今回はLambdaにロールを付与してみる。

環境

前回と同様。

実装

以下のissueに記載の通り、Lambda関数を作成し、生成されたCloudFormation定義を編集して、ロールを付与できる。

github.com

SESのドメインの検証は省略。

Lambdaの作成

amplify add function で、メール送信用のLambdaを作成する。関数名や言語は前回と同じものを指定。

記事作成時点で、コマンド実行時のオプション等で任意のロール付与は行えないため、advanced settingsでは何も設定しない。

また、前回は Select the categories you want this function to have access toAPIを選択したが、不要だったため除外している。

amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: sendMail
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World

Available advanced settings:
- Resource access permissions
- Scheduled recurring invocation
- Lambda layers configuration
- Environment variables configuration
- Secret values configuration

? Do you want to configure advanced settings? No
? Do you want to edit the local lambda function now? No
Successfully added resource sendMail locally.

Lambdaへのロール付与

生成された amplify/backend/function/<関数名>/<関数名>-cloudformation-template.json がCloudFormation定義ファイル。

Resources > lambdaexecutionpolicy > Properties > PolicyDocument > Statement に追加すればいい。

今回の作成方法の場合、 amplify/backend/function/sendMail/sendMail-cloudformation-template.json が対象。

生成段階で、ログ用にリソース指定された定義が存在するため、それとは別に "Resource": "*" で追加する。

// 前略
"Statement": [
// ここから追加
  {
    "Effect": "Allow",
    "Action": [
      "ses:SendEmail",
      "ses:SendRawEmail"
    ],
    "Resource": "*"
  },
// ここまで追加
// 以下、もともと存在していたログ用設定
  {
    "Effect": "Allow",
    "Action": [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ],
    "Resource": {
      "Fn::Sub": [
        "arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:*",
        {
          "region": {
            "Ref": "AWS::Region"
          },
          "account": {
            "Ref": "AWS::AccountId"
          },
          "lambda": {
            "Ref": "LambdaFunction"
          }
        }
      ]
    }
  }
]
// 後略

Lambdaの実装

今回はSystems Managerが不要なので、SESのパッケージだけインストールする。

cd amplify/backend/function/sendMail/src
yarn add @aws-sdk/client-ses

Lambdaの内容も前回と変わらず、 amplify/backend/function/<関数名>/src/index.js だが、実装はシークレットが不要になったのでシンプルになった。

// ファイル先頭の Amplify Params コメントは省略

const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses')

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async event => {
  console.log(JSON.stringify(event))

  const sesClient = new SESClient({
    region: 'ap-northeast-1',
  })

  const params = event.arguments
  const sendEmailCommandInput = {
    Source: params.from,
    Destination: {
      ToAddresses: params.to,
      CcAddresses: params.cc,
      BccAddresses: params.bcc,
    },
    Message: {
      Subject: { Data: params.subject },
      Body: {
        Text: {
          Data: params.body,
        },
      },
    },
  }

  const sendEmailCommand = new SendEmailCommand(sendEmailCommandInput)
  const sendEmailCommandOutput = await sesClient.send(sendEmailCommand)

  return {
    statusCode: 200,
    body: sendEmailCommandOutput.MessageId,
  }
}

自分の設定が悪いのか、この方法だと amplify mock function <関数名> でのテストが失敗する。

amplify function pushAWS側に反映し、AWS ConsoleのLambdaのテストから実行できることを確認。

GraphQL定義およびコードからの呼び出し

前回と同様のため省略。

振り返り

Amplify CLIから設定できないAWSのサービスも、今回の方法を応用すれば、Lambda経由でどうとでもなりそう。

一方で、Amplifyの仕組みの外となるので、使いすぎるのは管理対象が増えるなど、デメリットにもなりそう。使いどころは考える必要があると感じた。

Amplifyで作成したLambdaからアクセスキーを用いてSESでメール送信する

Amplifyで作成したアプリケーションでメール送信をしようと思い、Lambda経由でSESを呼んでみた。

メール送信用のIAMを作成してアクセスキーを使うパターンと、LambdaにSESでのメール送信権限を付与するパターン、両方試したが、まずはアクセスキーを用いるパターンをメモ。

環境

Amplify CLI v8.0.2。

パッケージマネージャーにはYarn、AWS SDK for JavaScriptはv3を使用。

フロントエンドの実装にはTypeScriptを使用。

実装

メール送信用のIAMの作成や、SESのドメインの検証は省略。アクセスキーIDとシークレットアクセスキーを確認しておく。

方法はこちらを参照。

Lambdaの作成

amplify add function で、メール送信用のLambdaを作成する。関数名は sendMail 、言語はNode.jsを指定した。

amplify add function

> Lambda function (serverless function)

? Provide an AWS Lambda function name: sendMail

? Choose the runtime that you want to use: (Use arrow keys)
> NodeJS

? Choose the function template that you want to use: (Use arrow keys)
> Hello World

? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this project from your Lambda function? Yes

? Select the categories you want this function to have access to. (Press <space> to select, <a> to toggle all, <i> to in
>(*) api

? Select the operations you want to permit on amplifyexample (Press <space> to select, <a> to toggle all, <i> to invert
selection)
>(*) Mutation

? Do you want to invoke this function on a recurring schedule? No
? Do you want to enable Lambda layers for this function? No
? Do you want to configure environment variables for this function? No
? Do you want to configure secret values this function can access? Yes
? Enter a secret name (this is the key used to look up the secret value): AWS_SES_ACCESS_KEY_ID
? Enter the value for AWS_SES_ACCESS_KEY_ID: [input is hidden]
? What do you want to do? Add a secret
? Enter a secret name (this is the key used to look up the secret value): AWS_SES_SECRET_ACCESS_KEY
? Enter the value for AWS_SES_SECRET_ACCESS_KEY: [input is hidden]
? What do you want to do? I'm done

Select the categories you want this function to have access toAPIを選択しているが、これは不要だったかも。

Do you want to configure secret values this function can access にて、シークレットを指定できるため、そこでアクセスキーIDとシークレットアクセスキーを指定した。

シークレットはAWS Systems Managerのパラメータストアに、 /amplify/<AmplifyアプリID>/<環境名>/AMPLIFY_<関数名>_<シークレット名> という名前の、SecureStringで保存される。

これで amplify/backend/function/<関数名> というディレクトリが生成される。

Lambdaの実装

Systems ManagerおよびSESを使うので、作成されたディレクトリ配下のsrcディレクトリに、AWS SDKをインストールする。

cd amplify/backend/function/sendMail/src
yarn add @aws-sdk/client-ssm @aws-sdk/client-ses

Lambdaの内容は、 amplify/backend/function/<関数名>/src/index.js に記述する。

GraphQL の引数は、 handler関数の引数.arguments に設定されるので、メール送信に必要な情報を取得するようにした。

// ファイル先頭の Amplify Params コメントは省略

const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm')
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses')

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async event => {
  // シークレットの取得
  const ssmClient = new SSMClient()
  const ssmGetParametersCommand = new GetParametersCommand({
    Names: ['AWS_SES_ACCESS_KEY_ID', 'AWS_SES_SECRET_ACCESS_KEY'].map(
      secretName => process.env[secretName],
    ),
    WithDecryption: true,
  })

  const { Parameters } = await ssmClient.send(ssmGetParametersCommand)

  // SESクライアントの作成
  const sesClient = new SESClient({
    region: 'ap-northeast-1',
    credentials: {
      accessKeyId: Parameters[0].Value,
      secretAccessKey: Parameters[1].Value,
    },
  })

  const params = event.arguments
  const sendEmailCommandInput = {
    Source: params.from,
    Destination: {
      ToAddresses: params.to,
      CcAddresses: params.cc,
      BccAddresses: params.bcc,
    },
    Message: {
      Subject: { Data: params.subject },
      Body: {
        Text: {
          Data: params.body,
        },
      },
    },
  }

  const sendEmailCommand = new SendEmailCommand(sendEmailCommandInput)
  const sendEmailCommandOutput = await sesClient.send(sendEmailCommand)

  // メッセージIDを返す
  return {
    statusCode: 200,
    body: sendEmailCommandOutput.MessageId,
  }
}

実装が終わったら、 amplify mock function <関数名> で動作確認できる。

以下のような event.json でメール送信できることを確認。

{
  "arguments": {
    "subject": "mail subject",
    "body": "mail body",
    "from": "from@example.com",
    "to": ["to@example.com"],
    "cc": [],
    "bcc": []
  }
}

amplify function push で、AWS側に反映。

GraphQL定義

schema.graphqltype Mutation に、Lambdaの呼び出しを記述する。

Lambda関数は、<関数名>-<環境名> のように、環境ごとに作成されるため、 @functionname関数名-${env} のように指定する。

type Mutation {
  sendMail(
    subject: String!
    body: String!
    from: String!
    to: [String!]!
    cc: [String!]
    bcc: [String!]
  ): String
    @function(name: "sendMail-${env}")
    @auth(rules: [{ allow: private }])
}

amplify api push で、AWS側に反映。また、push時にファイル定義の更新を行うと、 graphql/mutations.tssendMailAPI.tsSendMailMutationVariables が追加される

GraphQL定義の注意点

GraphQLではデフォルト値を指定できるため、当初、from: String = "from@example.com" のように記述していた。

だが、これだとLambdaから event.arguments.from のように値を参照した際、 from 未指定だと null になっていたため、デフォルト値は無視される模様。

コードから呼び出し

APIのpushで追加されたインターフェースを使用して、呼び出しを実装する。

Cognitoでログインしているユーザーにメールを送りたい場合、以下のようになる。

import { Amplify, API, Auth, graphqlOperation } from 'aws-amplify'
import { sendMail } from 'graphql/mutations'
import { SendMailMutationVariables } from 'API'

const sendMail = async () => {
  const currentUser = await Auth.currentAuthenticatedUser()
  const sendMailParams: SendMailMutationVariables = {
    subject: 'subject',
    body: 'body',
    to: [currentUser.attributes.email],
  }

  return await API.graphql(
    graphqlOperation(sendMail, sendMailParams),
  )
}

振り返り

ひとまず実装できた。Amplifyに含まれない仕組みは、Lambdaを使って何とかするのが想定されているのだろうか?

シークレットを指定した場合の動きを確認したかったのでアクセスキーを使用したが、実際にはLambdaにSESでのメール送信権限を付与するパターンで実装したので、そちらもまとめる予定。