Serverless Frameworkを使ってApollo(v4)サーバーをAWS Lambdaと統合する


※ 当ページには【広告/PR】を含む場合があります。
2024/01/16
CodeGenieApp/serverless-express(Express Adapter for AWS)のv4への更新方法
蛸壺の技術ブログ|Serverless Frameworkを使ってApollo(v4)サーバーをAWS Lambdaと統合する

AWSを使ったサーバレスベースのREST/HTTP APIをAPIGatewayをゴリゴリと開発を進めていく過程で、APIルーティングのパス構造が非常に深くなってしまったり、クエリパラメータが複雑化してくる問題は悩ましいものです。

そこでApolloを導入すれば、すべてのデータが一つのエンドポイントからGraphQLサーバー経由でアクセスすることができ、APIGateway/Lambdaのルーティング構造が単純化できるなど、大きなアドバンテージになります。

バックエンドの複雑性のほとんどをApolloが受け持ってくれることで、フロントエンドとバックエンドの開発者は双方がどのような技術でアプリケーションを構築しようと、お互いに意識することなく、とにかくApolloとの接続に注力すれば良くなります。

こういった側面もあり、Apolloに代表されるGraphQLを受け持つアプリケーションは、
「BFF(Backends for Frontends)」と呼ばれる第三勢力の比較的新しい技術分野に当たります。

BFFのテーマは、バックエンドの構造複雑化を吸収・隠蔽し、他方、フロントエンド側には理解しやすく利便性の高い機能を提供する、という地味に難しいところを狙っています。

とりあえず今回はApollo公式のガイドに従いながら、AWS APIGateway/LambdaにApolloサーバーを統合・運用する基礎を解説していきます。

参考|Deploying with AWS Lambda


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

ApolloサーバーをLambdaに統合

まず、ApolloをAWS Lambdaで動かすにあたり、前提条件として、

            
            1. (個人や組織の所有する)AWSアカウント
2. 適切な権限ロールのあるIAMユーザー
3. AWS-CLIコマンドが使える・ある程度知識がある
        
みたいなことがあげられます。

これら3つのセットアップ方法自体はこの記事では取り上げませんのでご了承ください。

Apollo/Typescriptのインストール

ここから簡単な関数でApolloをLambdaへデプロイをテストするまでの工程を検証していきます。

とりあえず、必須なライブラリモジュールを導入します。

            
            $ yarn add @apollo/server graphql @as-integrations/aws-lambda
$ yarn add typescript -D
        
Apolloサーバー開発に必ずしもtypescriptは必要ないかもしれませんが、後々、GraphQLスキーマとの型付けのやり取りを考えれば初手の時点でtypescriptを選択するほうが賢明です。

Apolloサーバーの実装

次にApolloサーバーのコアコードを追加・実装します。

            
            import { ApolloServer } from '@apollo/server';

//👇簡単なお試し用GraphQLスキーマ(クエリ)
const typeDefs = `#graphql
    type Query {
        hello: String
    }
`;

//👇先程のクエリでApolloが応答するためのリゾルバ
const resolvers = {
    Query: {
        hello: () => 'ようこそLambdaな世界!',
    },
};

//👇Apolloサーバー本体
const server = new ApolloServer({typeDefs, resolvers});
        
ここでのApolloサーバーの初期化はスキーマの型定義にあたるtypeDefsとApollo側がそれを解決するリゾルバセット・resolversのみを使っています。

開発が進んでくるとApolloサーバーの多様な初期化オプションが必要になってきますが、かなり細かい内容になるのでここでは触れません。

ApolloServerインスタンスのコンストラクタの詳細は、

参考|API Reference: ApolloServer

で確認できます。

ApolloサーバーをLambdaハンドラでラップする

先程のApolloサーバーを実装をローカルで試したい気持ちを抑えて、AWS Lambdaへデプロイさせることを先に考えます。

先程のコードをさらに以下のように編集しましょう。

            
            import { ApolloServer } from '@apollo/server';
//👇Lambda統合のためのランタイム&ハンドラを追加
import { startServerAndCreateLambdaHandler, handlers } from '@as-integrations/aws-lambda';

const typeDefs = `#graphql
    type Query {
        hello: String
    }
`;

const resolvers = {
    Query: {
        hello: () => 'ようこそLambdaな世界!',
    },
};

const server = new ApolloServer({typeDefs, resolvers});

//👇LambdaとAPIGatewayV2(HTTP API)の設定を上手くブリッジしてくれるお助けミドルウェアでApolloサーバーをラップ
export const graphqlHandler = startServerAndCreateLambdaHandler(
    server,
    handlers.createAPIGatewayProxyEventV2RequestHandler(),
);
        
これで、LambdaハンドラとしてそのままgraphqlHandler関数をデプロイすることが可能となりました。

かつてはApolloサーバーとAPIGateway/Lambdaとのリクエスト・レスポンスの設計構造の差を自前で補完するのには骨の折れるミドルウェアを書いた記憶もありますが...version4にもなると、ここらへんの機能も大変便利になりました。

@as-integrations/aws-lambdaが正式採用になった今となっては、そちらのライブラリにLambdaとの統合はすべておまかせになり、圧倒的にコーディング作業を簡素化できることにも感謝です。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Apolloサーバー化したLambdaをServerless Frameworkでデプロイ

上記の都合上、APIGatewayV2のHTTP APIの利用が前提となるので、手動でLambdaデプロイするのには結構骨が折れます。

そこで適当なIoCツールを使うのですが、とりわけAPIGateway前提な
「Serverless Framework(SLS)」との相性は抜群です。

ということで以降では、SLSを使って先程のLambdaをデプロイして動作確認までをやってみましょう。

なお、以下のコマンドでSLSをローカルに導入して利用します。

            
            $ yarn add serverless -D
$ npx serverless --version
Framework Core: 3.38.0 (local)
Plugin: 7.2.0
SDK: 4.5.1
        

オプションでserverless-plugin-typescriptを入れる

Lambdaのランタイムがnodejs18以降では、これまでのCommonjsのハンドラは非推奨になり、ESModuleベースのリソースコードの利用が促されています。

上の例のようにTypesriptのハンドラのままでは、Lambda側からは使えないので、デプロイ前にはESModuleコードへのトランスパイルが可能な適当なツールで変換する必要があります。

この一手間が面倒な人向けに、
serverless-plugin-typescriptというSLSのプラグインが用意されています。

            
            yarn add serverless-plugin-typescript -D
        
個人的には色々とデプロイ前に手動でesbuildで選択的にパッケージを固めたいので、serverless-plugin-typescriptを積極的に利用することはあまりないですが、ハンドラの軽量化などにこだわりがなければ便利なプラグインです。

serverless.ymlを準備する

ではデプロイするための設定を記述した
serverless.ymlをプロジェクトに追加し、以下のように編集します。

            
            service: apollo-lambda

provider:
  name: aws
  runtime: nodejs20.x
  httpApi:
    cors: true

functions:
  graphql:
    #👇ハンドラを指定(書式:<ファイルパス(.ts抜き)>.<ハンドラ名>)
    handler: server.graphqlHandler
    events:
      - httpApi:
          path: /
          method: POST
      - httpApi:
          path: /
          method: GET

#👇tsファイルを直接ハンドラ指定したい場合
plugins:
  - serverless-plugin-typescript
        

余談〜invoke localを試すのは諦めよう

さて、Serverless FrameworkにはLambda/APIGWv2のデプロイ前にローカルの開発環境で動作テストができる
「invoke local」が存在します。

オフラインでLambdaの動作試験ができるなんて、聞こえはとても便利なのですが、正常に動作するかどうかはローカルの実行環境に依存します。

ローカルでLambdaを起動させるための適切なエミュレータ・ランタイム等の設定がないと、おそらく以下のような感じで動作試験がコケるはずです。

            
            $ npx sls invoke local
× Error: Cannot find module '/usr/src/app/apigw-apollo/handler'
  Require stack:
  - /usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js
  - /usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/index.js
  - /usr/src/app/apigw-apollo/node_modules/serverless/lib/classes/plugin-manager.js
  - /usr/src/app/apigw-apollo/node_modules/serverless/lib/serverless.js
  - /usr/src/app/apigw-apollo/node_modules/serverless/scripts/serverless.js
  - /usr/src/app/apigw-apollo/node_modules/serverless/bin/serverless.js
      at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
      at Module._load (node:internal/modules/cjs/loader:920:27)
      at Module.require (node:internal/modules/cjs/loader:1141:19)
      at require (node:internal/modules/cjs/helpers:110:18)
      at loadModule (/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js:836:16)
      at AwsInvokeLocal.invokeLocalNodeJs (/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js:817:39)
      at AwsInvokeLocal.invokeLocal (/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js:248:19)
      at invoke:local:invoke (/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js:54:47)
      at PluginManager.runHooks (/usr/src/app/apigw-apollo/node_modules/serverless/lib/classes/plugin-manager.js:530:15)
      at PluginManager.invoke (/usr/src/app/apigw-apollo/node_modules/serverless/lib/classes/plugin-manager.js:564:20)
      at async PluginManager.run (/usr/src/app/apigw-apollo/node_modules/serverless/lib/classes/plugin-manager.js:604:7)
      at async Serverless.run (/usr/src/app/apigw-apollo/node_modules/serverless/lib/serverless.js:179:5)
      at async /usr/src/app/apigw-apollo/node_modules/serverless/scripts/serverless.js:819:9 {
    code: 'MODULE_NOT_FOUND',
    requireStack: [
      '/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/aws/invoke-local/index.js',
      '/usr/src/app/apigw-apollo/node_modules/serverless/lib/plugins/index.js',
      '/usr/src/app/apigw-apollo/node_modules/serverless/lib/classes/plugin-manager.js',
      '/usr/src/app/apigw-apollo/node_modules/serverless/lib/serverless.js',
      '/usr/src/app/apigw-apollo/node_modules/serverless/scripts/serverless.js',
      '/usr/src/app/apigw-apollo/node_modules/serverless/bin/serverless.js'
    ]
  }
Environment: linux, node 18.15.0, framework 3.30.1 (local), plugin 6.2.3, SDK 4.3.2
Credentials: Local, environment variables
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
Exception encountered when loading /usr/src/app/apigw-apollo/handler.cjs
        
公式の説明の冒頭には、以下の文言があります。

参考|AWS - Invoke Local

            
            This runs your code locally by emulating the AWS Lambda environment.
Please keep in mind, it's not a 100% perfect emulation,
there may be some differences, but it works for the vast majority of users.
We mock the context with simple mock data.
        
触れられているように、「100%動くエミュレータにはなっていない」ので、簡単な動作テスト程度なら良いのでしょうが、精度の必要な本番の試験には向きません。

どうしても信頼性・機密性の高いAWSマイクロサービスのモック環境がほしい場合には、「LocalStack」等のサービスを利用しましょう。

参考|LocalStack

そもそも、個人の趣味や学習程度の小規模なプロジェクトで、エミュレータによるローカル実行にこだわる必要も薄いです。

1回ごとのビルド出力物のデプロイは結構時間はかかりますが、待ち時間だと思って気長にAWSへデプロイして、問題なく動作するか否か泥臭くトライアンドエラーするほうがさほどお金も掛けずに確実な方法とも言えます。

Lambdaへデプロイ&実行する

さて、役者はそろったので、早速このLambdaハンドラをデプロイしてみます。

            
            $ npx sls deploy --verbose
        
で問題がなければ、スタックが順次生成されて、そのままアプリケーションがLambda側にデプロイされるはずです。

デプロイが完了したら、
serverless/sls invokeコマンドでレスポンスがきちんと応答するかを確認してみます。

Apolloサーバーに送信するAWS Lambdaベースのモック・リクエストデータをまとめた
query.jsonというファイルを新規追加し、以下のように内容を編集しておきます。

            
            {
    "version": "2",
    "headers": {
        "content-type": "application/json"
    },
    "isBase64Encoded": false,
    "rawQueryString": "",
    "requestContext": {
        "http": {
            "method": "POST"
        }
    },
    "rawPath": "/",
    "routeKey": "/",
    "body": "{\"operationName\": null, \"variables\": null, \"query\": \"{ hello }\"}"
}
        
これで動作テストの準備が整いました。

あとは以下のように実行するだけです。

            
            $ npx sls invoke -f graphql -p query.json --verbose
{
    "statusCode": 200,
    "headers": {
        "cache-control": "no-store",
        "content-type": "application/json; charset=utf-8",
        "content-length": "65"
    },
    "body": "{\"data\":{\"hello\":\"ようこそLambdaな世界!\"}}\n"
}
        
正常にレスポンスが得られたら、Apolloサーバー兼Lambdaの完成です。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

LambdaからApolloへContextを受け流す

開発が進んでくると、Apollo側のContext付きレゾルバをLambdaでどう扱うか、という悩ましい壁に直面するかもしれません。

@as-integrations/aws-lambdaの開発レポジトリにContext付きレゾルバの実装のヒントが解説されています。

参考|apollo-server-integration-aws-lambda

Apollo v4以降では、LambdaからContextデータを処理しやすい構文になりました。

            
            import { ApolloServer } from '@apollo/server';
import {
    startServerAndCreateLambdaHandler,
    handlers,
} from '@as-integrations/aws-lambda';

//Apolloサーバー内部処理で共通して利用するコンテクストデータの型を準備
//※ コンテクストの型はコードでインライン化するほか、GraphQL Codegenの
//   ような外部ツールから出力した定義セットから呼び出して利用することもできる
type ContextValue = {
    isAuthenticated: boolean;
};

const typeDefs = `#graphql
    type Query {
        hello: String!
        isAuthenticated: Boolean!
    }
`;

const server = new ApolloServer<ContextValue>({
    typeDefs,
    resolvers: {
        Query: {
            hello: () => 'world',
            //👇解説ポイント①
            isAuthenticated: (root, args, context) => {
                return context.isAuthenticated;
            },
        },
    },
});

export default startServerAndCreateLambdaHandler(
    server,
    handlers.createAPIGatewayProxyEventV2RequestHandler(),
    {
        //👇解説ポイント②
        context: async ({ event }) => {

            ///...event値を介して、JWT・クッキー・ヘッダーのAuth値などの抽出を行う処理

            return {
                isAuthenticated: true,
            };
        },
    },
);
        

まず上記のコードの
解説ポイント①の箇所ですが、リゾルバー関数・isAuthenticatedの実装に注目します。

そもそもApolloのリゾルバー関数は、省略可能な4つの引数で実装することが可能です。

            
            root:
    第一引数。
    親関係にあたるルートリゾルバーが返したオブジェクト
args:
    第二引数。
    外部のクライアントから渡されたオブジェクト
context:
    第三引数。
    単に"コンテクスト"というとこの引数を指す。
    アプリケーション全体で共有されるオブジェクト(=ContextValue)
info:
    第四引数。
    レゾルバーでの処理に関する詳細情報の保持したオブジェクト
        
複数のリゾルバーからコンテクストを利用したい場合には、関数の実装の第三引数を使うことで、アプリケーションで共有した値を使いまわすことができます。

解説ポイント②の位置で、startServerAndCreateLambdaHandlerの第三引数のオプションにあるcontextでは、Apolloのコンテクスト値の初期化処理を行うヘルパー関数を定義することができます。

この
context関数の第一引数・eventはAPIGatewayから渡されたリクエスト情報を保持していますので、ヘッダーやクエリパラメータなどのクライアントから送信された極めて重要な値が存在しています。

context関数によって、クライアント側に関する情報をApolloが利用するコンテクスト値へセットするように実装しています。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Apollo/Lambdaのミドルウェア

ここからはより突っ込んだ発展的なネタである「ミドルウェア」の例を幾つか紹介します。

なお、Express.jsにもミドルウェアが存在しますが、ここではそれとは別物のApollo側のミドルウェアです。

ミドルウェアはクラアントからのリクエストから、Apolloサーバーからのレスポンスが返させる途中に処理を記述することができる機能を指します。

ミドルウェアのユースケースに関しては以下にいくつか紹介があります。

参考|Middleware

ミドルウェアの基本

ミドルウェアの実装には、
middleware.MiddlewareFn<T>型に従うことに留意してください。

以下に、実装の基本として、簡単なミドルウェアを作成して、Apolloサーバーに登録してみましょう。

            
            import {
    middleware, startServerAndCreateLambdaHandler, handlers
} from '@as-integrations/aws-lambda';
import type {
    APIGatewayProxyEventV2,
    APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';

const server = new ApolloServer({
    typeDefs: `#graphql
        type Query { hello: String }
    `,
    resolvers: {
        Query: {
            hello: () => 'ようこそLambdaな世界〜ミドルウェアを添えて。'
        }
    }
});

const requestHandler = handlers.createAPIGatewayProxyEventV2RequestHandler();

//👇何もしないミドルウェア・その1
const middlewareFn1: middleware.MiddlewareFn<typeof requestHandler> = async (event) => {
    ///ここでリクエストイベントを取得したり、パラメータを変更する

    //👇リクエストヘッダに追加
    event.headers["my-custom-header"] = 'xxxxxxx';

    //👇場合によっては処理結果(result)を別の値に入れ替るコールバック
    return async (result) => {
        /// ここでApolloサーバーの処理結果の取得や結果に修正を加える

        if (result.body != null) {
            const body = JSON.parse(result.body);
            body.data.hello = body.data.hello != null ? body.data.hello + '\nミドルウェア1「ヨシッ」' : body.data.hello;
            result.body = JSON.stringify(body);
        }
    };
};

//👇何もしないミドルウェア・その2(ミドルウェアの関数の引数を明示に書く場合)
const middlewareFn2: middleware.MiddlewareFn<
    handlers.RequestHandler<APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2>
> = async (event) => {
    ///ここでリクエストイベントを取得したり、パラメータを変更する

    //👇先行のミドルウェアで書き換えられたリクエストヘッダはCloudWatchのログで確認できる
    console.dir(event);
    //  --> headers: { 'content-type': 'application/json', 'my-custom-header': 'xxxxxxx' },

    //👇場合によっては処理結果(result)を別の値に入れ替るコールバック
    return async (result) => {
        if (result.body != null) {
            const body = JSON.parse(result.body);
            body.data.hello = body.data.hello != null ? body.data.hello + '\nミドルウェア2「ヨシッ」' : body.data.hello;
            result.body = JSON.stringify(body);
        }
    };
};

startServerAndCreateLambdaHandler(server, requestHandler, {
    //👇ミドルウェアの登録(順序クリティカル)
    middleware: [middlewareFn1, middlewareFn2],
});
        
これをデプロイ後に実行すると、

            
            $ npx sls invoke --function graphql --path query.json --verbose
{
    "statusCode": 200,
    "headers": {
        "cache-control": "no-store",
        "content-type": "application/json; charset=utf-8",
        "content-length": "98"
    },
    "body": "{\"data\":{\"hello\":\"ようこそLambdaな世界〜ミドルウェアを添えて。\\nミドルウェア1「ヨシッ」\\nミドルウェア2「ヨシッ」\"}}"
}
        
となって、ミドルウェアがレスポンスの結果も書き変えていることが分かります。

ミドルウェアに渡る引数として、第一引数の
eventAPIGatewayProxyEventV2、第二引数のresultAPIGatewayProxyStructuredResultV2をそれぞれ型にとります。

ですので、2番目のミドルウェアで見て取れるように、ミドルウェアの基本の型は

            
            handlers.RequestHandler<APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2>
        
となります。

他にも、後述する「イベント拡張」でも説明しているようなミドルウェアの型もあるため、明示でのミドルウェアの型指定はコーディングに苦痛を伴うかもしれません。

ということでこの場合、1番目のミドルウェアで利用しているような、予め定義してあるハンドラから型を
typeofで取り出すテクニックを利用したほうが良いでしょう。

ミドルウェアの注意として、ミドルウェアは複数登録できますが、登録順でリクエストとレスポンスに何らかの操作を加えます。

ミドルウェアでcookieを操作する

ミドルウェアの代表的な使い方の例として、クライアント側から送信されたリクエストをAPIGWv2のイベントを経由して、ミドルウェアを挟んでcookieの値を操作する操作は以下のようなものになります。

            
            import {
    startServerAndCreateLambdaHandler, middleware, handlers
} from '@as-integrations/aws-lambda';

const server = new ApolloServer({
    typeDefs: `#graphql
        type Query { hello: String }
    `,
    resolvers: {
        Query: {
            hello: () => 'ようこそLambdaな世界〜ミドルウェアを添えて。'
        }
    }
});

//👇具体的な実装は省略するが、クッキーの期限切れを判別し、
//  期限が切れた場合には新しいクッキーを発行するメソッド
import { refreshCookie } from './cookies';

const requestHandler = handlers.createAPIGatewayProxyEventV2RequestHandler();

const cookieMiddleware: middleware.MiddlewareFn<typeof requestHandler> = async (event) => {
    //eventから現在のクッキーを取得し、期限が切れた場合に新しいクッキーを発行する
    const cookie = refreshCookie(event.cookies);
    return async (result) => {
        //👇resultのクッキーを更新(クッキーが未定義の場合には空の配列で初期化)
        result.cookies = result.cookies ?? [];
        //👇発行したクッキーをresultで返す
        result.cookies.push(cookie);
    };
};

export default startServerAndCreateLambdaHandler(
    server,
    requestHandler,
    { middleware: [cookieMiddleware],}
);
        

ミドルウェアでルーティングを遮断する

他には、認証トークンやセッションの有効期限が無効になった場合に、それ以降の処理をミドルウェアで中断させたいに有効な実装です。

            
            import {
    startServerAndCreateLambdaHandler,
    middleware,
    handlers,
} from '@as-integrations/aws-lambda';

const server = new ApolloServer({
    typeDefs: `#graphql
        type Query { hello: String }
    `,
    resolvers: {
        Query: {
            hello: () => 'ようこそLambdaな世界〜ミドルウェアを添えて。'
        }
    }
});

const requestHandler = handlers.createAPIGatewayProxyEventV2RequestHandler();

const sessionMiddleware: middleware.MiddlewareFn<typeof requestHandler> = (event) => {

    //セッションの有効期限をチェックする処理...

    if (!event.headers['X-Session-Key']) {
        //セッションが無効だった際には、認証できなかった旨を結果に上書きする
        return {
            statusCode: 401
            body: 'Unauthorized'
        }
    }
};

export default startServerAndCreateLambdaHandler(server, requestHandler, {
    middleware: [sessionMiddleware],
});
        
この例では、X-Session-Keyが無効になった際に、ミドルウェアはResultType型の結果を上書きして、クライアント側に認証できなかった旨を返します。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

拡張イベントへの応用

場合によっては、APIGW側から独自に追加したカスタムイベントのパラメータをGraphQL側のレゾルバに渡す必要があります。

代表的な例として、Lambdaオーソライザーによるユーザー認証ある場合にやりとりされるAuthorizationヘッダーのような情報です。

デフォルトでは、
handlers.createAPIGatewayProxyEventV2RequestHandlerメソッドから生成されるリクエストハンドラが受け取るイベントの型はAPIGatewayProxyEventV2であり、ベーシックなイベントパラメータのみが内部でやり取りされるような仕様になっています。

APIGatewayProxyEventV2WithLambdaAuthorizer

Lambdaオーソライザーを使った認証情報付きのリクエストイベントが漏れ落ちないように、リクエストハンドラが
APIGatewayProxyEventV2WithLambdaAuthorizerを引数にとり、そこでバイパスされるパラメータ(contextValue)の型を明示にアプリケーションへ教えてあげる必要があります。

以下のサンプルでは、Lambdaオーソライザー付きのイベントで、ユーザーの独自定義したパラメーター・
myAuthorizerContextの型を追加する際のサンプルコードになります。

            
            import {
    startServerAndCreateLambdaHandler, middleware, handlers
    } from '@as-integrations/aws-lambda';
import type {
    APIGatewayProxyEventV2WithLambdaAuthorizer
} from 'aws-lambda';
import { server } from './server';

export default startServerAndCreateLambdaHandler(
    server,
    handlers.createAPIGatewayProxyEventV2RequestHandler<
        APIGatewayProxyEventV2WithLambdaAuthorizer<{
            myAuthorizerContext: string;
        }>
    >(),
);
        

合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

カスタムイベントをハンドリングする

Lambdaからのイベント型を漏らすことなく取得させるためには、handlers.createHandler系メソッドを使って、その第一引数・eventParser型と第二引数・resultGenerator型を実装することで操作します。

まず、
eventParser型には、以下の4つのヘルパー関数を実装できます。

            
            parseHttpMethod:
    型: (event: EventType) => string
    HTTPメソッド(GET/POST/...)を返す
parseQueryParams:
    型: (event: EventType) => string
    リクエストのクエリパラメータ(例:foo=1&bar=2)を返す。
    リクエストで既にマップオブジェクト化していたら、
    ここではURLSearchParams関数等で再度文字列化する必要がある
parseHeaders:
    型: (event: EventType) => HeaderMap
    イベントからApollo形式のヘッダーマップを生成して返す。
parseBody:
    型: (event: EventType, headers?: HeaderMap) => string
    リクエストBodyを返す。
    ここではbase64や文字列エンコードを処理させる。
    また、headersからcontent-typeを読み出すことで、適切な文字列へ変換させる
        

resultGenerator型は、カスタムリクエストでの処理が成功したか失敗したかを、startServerAndCreateLambdaHandlerへコールバックを設定することが可能です。

            
            success:
    型: (response: HTTPGraphQLResponse) => ResultType
    処理が正常に終了した後のレスポンスを生成して返す
error:
    型: (e: unknown) => ResultType
    異常終了した際のレスポンスを生成して返す
        
以下は、カスタムイベントのハンドリングの一例を示したコードになります。

            
            import { startServerAndCreateLambdaHandler, handlers } from '@as-integrations/aws-lambda';
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import { HeaderMap } from '@apollo/server';
import { server } from './server';

type CustomInvokeEvent = {
    httpMethod: string;
    queryParams: string;
    headers: Record<string, string>;
    body: string;
};

type CustomInvokeResult = {
    success: true;
    body: string;
} | {
    success: false;
    error: string;
};

const requestHandler = handlers.createRequestHandler<CustomInvokeEvent, CustomInvokeResult>(
    {
        parseHttpMethod(event) {
            return event.httpMethod;
        },
        parseHeaders(event) {
            const headerMap = new HeaderMap();
            for (const [key, value] of Object.entries(event.headers)) {
                headerMap.set(key, value);
            }
            return headerMap;
        },
        parseQueryParams(event) {
            return event.queryParams;
        },
        parseBody(event) {
            return event.body;
        },
    },
    {
        success({ body }) {
            return {
                success: true,
                body: body.string,
            };
        },
        error(e) {
            if (e instanceof Error) {
                return {
                    success: false,
                    error: e.toString(),
                };
            }
            console.error('Unknown error type encountered!', e);
            throw e;
        },
    },
);

export default startServerAndCreateLambdaHandler(server, requestHandler);
        

合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Apolloサーバーでイベント情報を取得する

Lambdaを経由したデータ構造からApolloサーバーの使うコンテクスト情報を正しく引き抜きたい場合、context関数を初期化します。

context関数で、Apollo側で処理したい/させたデータを、Lambda側に伝達させることが可能になります。

            
            const server = new ApolloServer<MyContext>({
    typeDefs,
    resolvers,
});

export const graphqlHandler = startServerAndCreateLambdaHandler(
    server,
    handlers.createAPIGatewayProxyEventV2RequestHandler(),
    {
        context: async ({ event, context }) => {
            return {
                lambdaEvent: event,
                lambdaContext: context,
            };
        },
    }
);
        
Apolloに設定できるcontextコールバックの引数での、eventオブジェクトはAPIGateway経由のイベント(HTTP headers/HTTP method/body/path/etc...)を含みます。

もう一つの引数内の
contextオブジェクトはLambda経由のコンテクスト(関数名/関数バージョン/awsRequestId/実行時間/etc...)を含みます。

なお、Apolloでいう
context関数と、Lambdaのコンテクスト情報は混合しやすいですが別物です。これはしばしば混乱のもとになりますので、ご注意ください。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

serverless-express(旧@vendia/serverless-express)と統合する

既存のAWS Lambda上で運用しているExpressアプリにApolloサーバーの機能を新たに追加したい場合もあります。

多くの開発者がLambdaとExpress.jsを統合してSLSからデプロイまでを一括しておこなえる
serverless-express(旧@vendia/serverless-express)を採用している話前提ですが、Apolloサーバーの機能をExpressミドルウェアを使って簡単に組み込むことが可能です。

ちなみに、かつて
aws-serverless-express、最近までは@vendia/serverless-expressという名前を経て、このほどserverless-expressというプロジェクトにリニューアルしました。

参考|@codegenie/serverless-express

これにより、
@vendia/serverless-express時代に問題になっていたLambdaのNodejsランタイムに対応できていなかった問題も解決されると期待されます。

特定のHTTPルーティングをApolloサーバーの処理に割り当てたい場合は以下のようにExpressサーバーを修正すると良いでしょう。

            
            const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const serverlessExpress = require('@codegenie/serverless-express');
const express = require('express');
const cors = require('cors');

const server = new ApolloServer({
    typeDefs: 'type Query { x: ID }',
    resolvers: { Query: { x: () => 'hi!' } },
});

server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests();

const app = express();
app.use(cors(), express.json(), expressMiddleware(server));

exports.graphqlHandler = serverlessExpress({ app });
        

ここでの
serverless-expressの役割はLambdaの受け持つイベントと、Express側へのリクエストを正しく解釈し、分離させることです。

これにより、Apolloサーバーへのリクエストとレスポンスが通常通りに処理させることができます。

ExpressとLambdaへのリクエスト・コンテクスト構造を分離する

既存のルートが使っていた
req(express.Request)/res(express.Response)オブジェクトと、Lambda側が使うパラメータ内部衝突を回避したい場合があります。

            
            const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const serverlessExpress = require('@codegenie/serverless-express');
const express = require('express');
const cors = require('cors');

const server = new ApolloServer({
    typeDefs: 'type Query { x: ID }',
    resolvers: { Query: { x: () => 'hi!' } },
});

server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests();

const app = express();

app.use(
    cors(), express.json(),
    expressMiddleware(server, {
        //カスタマイズしたcontex関数の初期化:
        // -->デフォルトのExpress側のrequest/responseと
        //    Lambda側のevent/contextを統合・拡張
        context: async ({ req, res }) => {
            //👇APIGWイベントとLambdaコンテクストにアクセス
            const { event, context } = serverlessExpress.getCurrentInvoke();
            return {
                expressRequest: req,
                expressResponse: res,
                lambdaEvent: event,
                lambdaContext: context,
            };
        },
    }),
);

exports.handler = serverlessExpress({ app });
        
上のコード例では、Lambda/Apollo側に処理させたいリクエストイベントやコンテクストをlambdaEvent/lambdaContextで受けて、通常のExpress側の処理で利用するリクエスト・レスポンスはexpressRequest/expressResponseで受けます。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

まとめ

以上、公式で説明されているApollo v4とAWS Lambdaの統合するテクニックを一通りさらってみました。

ご自身の開発にあったサーバレスパターン内で、Apolloサーバーを実装されるのにお役に立てれば幸いです。

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集