カテゴリー
Serverless Frameworkを使ってApollo(v4)サーバーをAWS Lambdaと統合する
※ 当ページには【広告/PR】を含む場合があります。
2024/01/16
ApolloサーバーをLambdaに統合
1. (個人や組織の所有する)AWSアカウント
2. 適切な権限ロールのあるIAMユーザー
3. AWS-CLIコマンドが使える・ある程度知識がある
Apollo/Typescriptのインストール
$ yarn add @apollo/server graphql @as-integrations/aws-lambda
$ yarn add typescript -D
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});
typeDefs
resolvers
Apolloサーバーを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(),
);
graphqlHandler
@as-integrations/aws-lambda
Apolloサーバー化したLambdaをServerless Frameworkでデプロイ
$ yarn add serverless -D
$ npx serverless --version
Framework Core: 3.38.0 (local)
Plugin: 7.2.0
SDK: 4.5.1
オプションでserverless-plugin-typescriptを入れる
serverless-plugin-typescript
yarn add serverless-plugin-typescript -D
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を試すのは諦めよう
$ 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
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%動くエミュレータにはなっていない」
Lambdaへデプロイ&実行する
$ npx sls deploy --verbose
serverless/sls invoke
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"
}
LambdaからApolloへContextを受け流す
@as-integrations/aws-lambda
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
root:
第一引数。
親関係にあたるルートリゾルバーが返したオブジェクト
args:
第二引数。
外部のクライアントから渡されたオブジェクト
context:
第三引数。
単に"コンテクスト"というとこの引数を指す。
アプリケーション全体で共有されるオブジェクト(=ContextValue)
info:
第四引数。
レゾルバーでの処理に関する詳細情報の保持したオブジェクト
解説ポイント②
startServerAndCreateLambdaHandler
context
context
event
context
Apollo/Lambdaのミドルウェア
ミドルウェアの基本
middleware.MiddlewareFn<T>
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「ヨシッ」\"}}"
}
event
APIGatewayProxyEventV2
result
APIGatewayProxyStructuredResultV2
handlers.RequestHandler<APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2>
typeof
ミドルウェアで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
拡張イベントへの応用
handlers.createAPIGatewayProxyEventV2RequestHandler
APIGatewayProxyEventV2
APIGatewayProxyEventV2WithLambdaAuthorizer
contextValue
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;
}>
>(),
);
カスタムイベントをハンドリングする
handlers.createHandler
eventParser
resultGenerator
eventParser
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);
Apolloサーバーでイベント情報を取得する
context
context
const server = new ApolloServer<MyContext>({
typeDefs,
resolvers,
});
export const graphqlHandler = startServerAndCreateLambdaHandler(
server,
handlers.createAPIGatewayProxyEventV2RequestHandler(),
{
context: async ({ event, context }) => {
return {
lambdaEvent: event,
lambdaContext: context,
};
},
}
);
context
event
context
context
serverless-express(旧@vendia/serverless-express)と統合する
serverless-express(旧@vendia/serverless-express)
aws-serverless-express
@vendia/serverless-express
serverless-express
@vendia/serverless-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
ExpressとLambdaへのリクエスト・コンテクスト構造を分離する
req(express.Request)/res(express.Response)
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 });
lambdaEvent/lambdaContext
expressRequest/expressResponse
まとめ
記事を書いた人
ナンデモ系エンジニア
主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。
カテゴリー