🔥

よく使うカスタムリゾルバーまとめ(Appsync + Amplify)

2024/04/13に公開

はじめに

2023/08ごろにこちらのニュースで、全てのリゾルバーがjs対応していました。
これまではpache Velocity Template Language (VTL) という少し変わった記法でしか作成できなかったので、これからの開発が大分改善されそうです。

概要

Amplifyを用いて、カスタムResolverを作成します。
基本的にはAmplifyの公式に沿って進めていきます。

カスタムResolverとは

Amplify + Appsyncの開発を行っていると、データベースのCRUD機能のクエリー等は基本的にデフォルトのりゾルバーとして生成されます。一方で独自のロジックを実装し、Appsyncから呼び出すことをカスタムResolverと言います。
カスタムResolverを知ることで、Appsyncでできることがデフォルトのリゾルバに留まらず、非常に沢山のことをできるのが大きなメリットです。

紹介するカスタムResolver

  • Lambda Resolver
    • Lambda関数の呼び出し
    • パイプラインResolver
    • DynamoDBアクセス
  • Http Resolver
    • リクエストヘッダー
    • パスパラメータ
    • クエリパラメータ
    • リクエストボディ

Lambda Resolverのイベント

Lambda Resolverイベント特有のフィールドの値を紹介します。
Appsyncで呼び出されるLambdaから必要な情報を得るために非常に重要です。それぞれのeventのキーがResolver関数にどのような情報を提供するのかを理解することで、複雑なLambda Resolver操作が可能になります。

7個ありますのでそれぞれ紹介します。

1.typeName

役割

これはリゾルバが割り当てられている親オブジェクト(GraphQLスキーマ内で定義されている)型名を指します。
たとえば、Query 型のリゾルバであれば、typeName は "Query" となります。

使いどき

この情報は特に、同じResolverを複数のフィールドや型に再利用したい場合に役立ちます。typeNameをチェックすることで、異なる型でのリクエストを条件分岐させることができます。

値の例

例: "Query", "Mutation", "User", "Post" など

2.fieldName

役割

実行中のGraphQLクエリまたはミューテーション内のフィールド名です。
リクエストで getUser クエリを呼び出している場合、fieldName は "getUser" となります。

使いどき

複数のフィールドで同じResolver関数を使用する場合や、動的にResolverの挙動を変更したい場合に利用します。

値の例

"getUser", "listPosts", "createPost" など

3.arguments

役割

クライアントがフィールドに対して提供する引数のキーと値のペアです。
たとえば、ユーザーIDに基づいてユーザーを取得する場合、arguments には { id: "1" } が含まれます。

使いどき

Resolverがデータを取得または変更する際に、これらの引数を使ってクエリやミューテーションのパラメータを制御します。

値の例

{ id: "1", name: "John Doe" }

4.identity

役割

リクエストを行っているユーザーのアイデンティティ情報が含まれています。claimsキーが存在する場合、JWTのクレームが含まれます。

使いどき

ユーザー認証や権限に基づくロジックを実行する際に使用します。例えば、ユーザーが自分の情報のみを取得できるようにするなどです。

値の例

{
  "username": "johndoe",
  "sourceIp": ["192.168.1.1"],
  "claims": {
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022,
    ...
  }
}

5.source

役割

ネストされたフィールドを解決する際の親オブジェクトの値が含まれています。例えばPost.commentsを解決するとき、sourceはPostオブジェクトになります。

使いどき

ネストされたフィールドにアクセスする必要がある場合に使用します。この情報を利用して、関連する子オブジェクトを取得したり、親オブジェクトに基づいた計算を行ったりします。

値の例

{ id: "1", title: "Post Title", content: "Here is some content", authorId: "1" }

6.request

役割

クライアントが送信したリクエストのHTTPヘッダー情報が含まれます。

使いどき

リクエストのメタデータが必要な場合や、HTTPヘッダーに基づくロジックを実行するときに使用します。

値の例

{
  "headers": {
    "content-type": "application/json",
    "Authorization": "Bearer some-jwt-token",
    ...
  }
}

7.prev

役割

パイプラインResolverを使用する際に、前の関数が返したオブジェクトが含まれます。

使いどき

パイプラインResolverの各ステップ間でデータを受け渡す必要があるときや、監査用に前の値を追跡したい場合に使用します。

値の例

{ result: "Success", id: "1" }

カスタムResolverの種類

下記の3つのタイプがあります。

  • lambda Resolver
  • http Resolver
  • AppSync JavaScript or VTL Resolver
    • 今回は紹介しない為、説明は割愛します。

lambda Resolver

役割

AWS Lambda関数を呼び出して、その結果をGraphQL APIからのレスポンスに設定することが可能。
任意の複雑なロジックや外部APIとの統合をしたい時に使用します。
amplifyだと、amplify add functionによって作成したlambdaと簡単に連携できます。

使用例

例えば、複数のデータベースやAPIからデータを取得して組み合わせる、ユーザーに基づいたカスタム認証ロジックを実装する、または複雑なビジネスロジックを処理する場合などにLambda Resolverが有効です。

type Query {
  hello(msg: String): String @function(name: "hellofunction-${env}")
}

http Resolver

役割

任意のHTTPエンドポイントをデータソースとして使用することが可能。
その為、REST APIや他のHTTPベースのサービスと連携する際に特に有用です。

使用例:

例えば、既存のREST APIからデータを取得してGraphQL APIで利用可能にする、外部サービスへのデータ送信、外部の認証サービスを介してユーザー情報を取得する場合などにHTTP Resolverを利用できます。

type Query {
  listPosts: [Post] @http(url: "https://www.example.com/posts")
}

lambda Resolverのパターン紹介

シンプルなLambda呼び出し

引数にメッセージを入れてlambdaをコールし、加工したメッセージをレスポンスしてくるものです。

詳細

schema.graphql

type Query {
    lambdaResolver(msg: String): String @function(name: "lambdaResolver-${env}")
}

Resolverのロジック

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async (event) => {
    console.log(`EVENT: ${JSON.stringify(event)}`);
    return {
        statusCode: 200,
        body: JSON.stringify('Lambda Resolver body: ' + event.arguments.msg),
    };
};

リクエスト

※リクエストはAppsyncのクエリコンソールから実施しました。

query MyQuery {
  lambdaResolver(msg: "Zenn Blog")
}

レスポンス

{
  "data": {
    "lambdaPipelineResolver": "{statusCode=200, body=\"Lambda Resolver body: Zenn Blog\"}"
  }
}

Event内容

{
    "typeName": "Query",
    "fieldName": "lambdaPipelineResolver",
    "arguments": {
        "msg": "Zenn Blog"
    },
    "identity": null,
    "source": null,
    "request": {
        ...
    },
    "prev": {
        "result": {}
    }
}

パイプラインResolver

複数のResolver機能を一連のステップとして実行するものです。
単一のGraphQLオペレーション内で複数のデータソースからのデータ取得や加工を行い、これらを連携させることができる。
今回の詳細では、prev.result.bodyの中にある前回のResolverの結果を使用して出力しております。

詳細

schema.graphql

Query {
  lambdaPipelineResolver(msg: String): String @function(name: "lambdaResolver-${env}") @function(name: "lambdaPipelineResolver-${env}")
}

Resolverのロジック

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async (event) => {
    console.log(`EVENT: ${JSON.stringify(event)}`);
    return {
        statusCode: 200,
        body: JSON.stringify('Lambda Pipeline Resolver body: ' + event.arguments.msg + " : " + event.prev.result.body),
    };
};

リクエスト

query MyQuery {
  lambdaPipelineResolver(msg: "Zenn Blog")
}

レスポンス

{
  "data": {
    "lambdaPipelineResolver": "{statusCode=200, body=\"Lambda Pipeline Resolver body: Zenn Blog : \\\"Lambda Resolver body: Zenn Blog\\\"\"}"
  }
}

Event内容

{
    "typeName": "Query",
    "fieldName": "lambdaPipelineResolver",
    "arguments": {
        "msg": "Zenn Blog"
    },
    "identity": null,
    "source": null,
    "request": {
        ...
        "headers": {...}
    },
    "prev": {
        "result": {
            "statusCode": 200,
            "body": "\"Lambda Resolver body: Zenn Blog\""
        }
    }
}

DynamoDBアクセス

  1. AmplifyからDynamoDBをimportする
  2. ResolverからDynamoDBへのアクセス権限を与える設定を行います。
  3. lambdaのにpackage.jsonでaws-sdkをimportする。
DynamoDBのimportから権限付与の詳細設定
  1. 既存のDynamoDBのインポート
amplify import storage                                                                          2024-04-13 17:25
? Select from one of the below mentioned services: DynamoDB table - NoSQL Database
✔ Select the DynamoDB Table you want to import: · テーブル名
✅ DynamoDB Table 'テーブル名' was successfully imported.
Next steps:
- This resource can now be accessed from REST APIs (`amplify add api`) and Functions (`amplify add function`)
  1. Lambdaの権限付与コマンドを実行
    • lambdaの作成時に権限付与: amplify add function
    • 既存のlambdaに権限付与: amplify configure function

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

上記の質問にYesをと回答することで権限付与設定が可能になる。

% amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: lambdaAccessDynamoDBResolver
? 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? 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. storage
? Storage has 4 resources in this project. Select the one you would like your Lambda to access Message7e7uc22dcfemzglgfz3m3pkfcidev
? Select the operations you want to permit on Message7e7uc22dcfemzglgfz3m3pkfcidev read

You can access the following resource attributes as environment variables from your Lambda function
        ENV
        REGION
        STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_ARN
        STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_NAME
        STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_STREAMARN
? 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? No
✔ Choose the package manager that you want to use: · NPM
? Do you want to edit the local lambda function now? Yes
Edit the file in your editor: xxx/amplify/backend/function/lambdaAccessDynamoDBResolver/src/index.js
? Press enter to continue 
✅ Successfully added resource lambdaAccessDynamoDBResolver locally.

✅ Next steps:
Check out sample function code generated in <project-dir>/amplify/backend/function/lambdaAccessDynamoDBResolver/src
"amplify function build" builds all of your functions currently in the project
"amplify mock function <functionName>" runs your function locally
To access AWS resources outside of this Amplify app, edit the xxx/amplify/backend/function/lambdaAccessDynamoDBResolver/custom-policies.json
"amplify push" builds all of your local backend resources and provisions them in the cloud
"amplify publish" builds all of your local backend and front-end resources (if you added hosting category) and provisions them in the cloud
  1. aws-sdkのimport
% cd xxx/amplify/backend/function/lambdaAccessDynamoDBResolver/src
% npm i aws-sdk
詳細

schema.graphql

type Query {
  lambdaAccessDynamoDBResolver(id: String): String @function(name: "lambdaAccessDynamoDBResolver-${env}")
}

Resolverのロジック

const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

/* Amplify Params - DO NOT EDIT
	ENV
	REGION
	STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_ARN
	STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_NAME
	STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_STREAMARN
Amplify Params - DO NOT EDIT */

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */

exports.handler = async (event) => {
    console.log("received event:");
    const tableName = process.env.STORAGE_MESSAGE7E7UC22DCFEMZGLGFZ3M3PKFCIDEV_NAME;
    console.log(event, tableName, event.arguments.id);
    const id = event.arguments.id; // AppSync イベントから ID を取得
    try {
        const result = await dynamodb.get({
            TableName: tableName, // この行でテーブル名を指定
            Key: { id: id }
        }).promise();
        console.log(result.Item)
        return result.Item;
    } catch (error) {
        console.error(error);
        throw new Error("Failed to get item from DynamoDB");
    }
};

リクエスト

query MyQuery {
  lambdaAccessDynamoDBResolver(id: DynamoDBに存在するID)
}

レスポンス

{
  "data": {
    "lambdaAccessDynamoDBResolver": "取得したDynamoDBのデータ"
  }
}

Event内容

{
  typeName: 'Query',
  fieldName: 'lambdaAccessDynamoDBResolver',
  arguments: { id: 'xxxx' },
  identity: null,
  source: null,
  ...
}

http resolverの紹介

こちらはAPIをコールする為、事前に準備が必要になります。
筆者はSAMを用いて、apigateway + Lambdaの構成で構築したAPIを使用しております。

シンプルなAPIコール

詳細

graphlq.schema

type Query {
  httpResolver: String @http(url: "https://xxx.execute-api.ap-northeast-1.amazonaws.com/Prod/http-resolver")
}

APIロジック

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        // ここにLambdaのロジックを追加
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'Lambda Http Resolver Function',
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'An error occurred in Lambda Http Resolver Function',
            }),
        };
    }
};

リクエスト

query MyQuery {
  httpResolver
}

レスポンス

{
  "data": {
    "httpResolver": "{message=Lambda Http Resolver Function}"
  }
}

Event内容

{
  resource: '/http-resolver',
  path: '/http-resolver',
  httpMethod: 'GET',
  headers: {...},
  multiValueHeaders: {...}
  queryStringParameters: null,
  multiValueQueryStringParameters: null,
  pathParameters: null,
  stageVariables: null,
  requestContext: {
    resourceId: 'qw31tq',
    resourcePath: '/http-resolver',
    httpMethod: 'GET',
    path: '/Prod/http-resolver',
    identity: {...},
    ...
  },
  body: null,
  isBase64Encoded: false
}

リクエストヘッダー

Event内容のheadersの中に、設定したリクエストヘッダーが含まれる

詳細

schema.graphql

  httpQueryParamResolver(id: String! name: String! content: String!): String @http(
    method: GET
    url: "https://xxx.execute-api.ap-northeast-1.amazonaws.com/Prod/http_resolver_with_path/:id"
    headers: [{ key: "X-Header", value: "X-Header-Value" }]
  )

APIロジック

※ 「シンプルなAPIコール」のものと同じ

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        // ここにLambdaのロジックを追加
        console.log(event);
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'Lambda Http Resolver Function',
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'An error occurred in Lambda Http Resolver Function',
            }),
        };
    }
};

リクエスト

query MyQuery {
  httpResolver
}

レスポンス

{
  "data": {
    "httpResolver": "{message=Lambda Http Resolver Function}"
  }
}

Event内容

headersの中に設定した情報が存在することがわかる。

{
  resource: '/http-resolver',
  path: '/http-resolver',
  httpMethod: 'GET',
  headers: {
    ...
    'x-header': 'X-Header-Value'
  },
  ...
}

パスパラメーター

パスパラメータと同じフィールドを引数として設けることで、パラメータをセットできる。

詳細

schema.graphql

input QueryGetPostMessageInput {
  id: String!
}

type Query {
    httpPathParamResolver(params: QueryGetPostMessageInput!): String @http(url: "https://xxx.execute-api.ap-northeast-1.amazonaws.com/Prod/http_resolver_with_path/:id")
}

APIロジック

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

// パスパラメータを使用する例
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    console.log(event);
    try {
        const id = event.pathParameters?.id; // パスパラメータの取得
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: `HttpPathParamResolver Function PathParam: ${id}`,
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'An error occurred in HttpPathParamResolver Function',
            }),
        };
    }
};

リクエスト

query MyQuery {
  httpPathParamResolver(params: {id: "ZennBlog"})
}

レスポンス

{
  "data": {
    "httpPathParamResolver": "{message=HttpPathParamResolver Function PathParam: ZennBlog}"
  }
}

Event内容

{
  resource: '/http-path-param-resolver/{id}',
  path: '/http-path-param-resolver/ZennBlog',
  httpMethod: 'GET',
  queryStringParameters: null,
  multiValueQueryStringParameters: null,
  pathParameters: { id: 'ZennBlog' },
  stageVariables: null,
  ...
}

クエリパラメーター

引数がパスパラーメータと別のフィールド名の場合、クエリパラーメータとして設定できる。
紹介済みのパスパラメータをリクエストに含んで紹介します。

詳細

schema.graphql

type Query {
    httpQueryParamResolver(id: String! name: String! content: String!): String @http(url: "https://xxx.execute-api.ap-northeast-1.amazonaws.com/Prod/http-query-resolver/:id")
}

APIロジック

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

// クエリパラメータを使用する例
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    console.log(event);
    try {
        const id = event.pathParameters?.id; // パスパラメータの取得
        const queryParam = event.queryStringParameters; // クエリパラメータの取得
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: `HttpQueryResolver Function Query: ${queryParam.name} ${queryParam.content}, PathParam: ${id}`,
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'An error occurred in HttpQueryResolver Function',
            }),
        };
    }
};

リクエスト

query MyQuery {
  httpQueryParamResolver(params: {id: "Zenn_id"}, query: {content: "Zenn_content", name: "Zenn_name"})
}

レスポンス

{
  "data": {
    "httpQueryParamResolver": "{message=HttpQueryResolver Function Query: Zenn_name Zenn_content, PathParam: Zenn_id}"
  }
}

Event内容

{
  resource: '/http-query-resolver/{id}',
  path: '/http-query-resolver/Zenn_id',
  httpMethod: 'GET',
  queryStringParameters: { content: 'Zenn_content', name: 'Zenn_name' },
  multiValueQueryStringParameters: { content: [ 'Zenn_content' ], name: [ 'Zenn_name' ] },
  pathParameters: { id: 'Zenn_id' },
  stageVariables: null,
  ...
}

リクエストBody

POST、PUT、および PATCH リクエストに使用されるリクエストの本文を指定することもできます。
このリクエストはクエリ文字列もAmplifyはサポートしているため、query および body 入力オブジェクトを使用してフィールドを生成します。

詳細

schema.graphql

type Mutation {
  httpPostRequestHeaderResolver(name: String!, content: String!): String
    @http(
      url: "https://xxx.execute-api.ap-northeast-1.amazonaws.com/Prod/http-body-resolver",
      method: POST,
    )
}

APIロジック

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

// POSTリクエストのボディを扱う例
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    console.log(event);
    try {
        // リクエストボディの解析
        const requestBody = JSON.parse(event.body);
        const queryParam = event.queryStringParameters;

        return {
            statusCode: 200,
            body: JSON.stringify({
                message: `Received POST body: name = ${requestBody.name}, content = ${requestBody.content}, query: name = ${queryParam.name}, content = ${queryParam.content}`,
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'An error occurred while processing your request',
            }),
        };
    }
};

リクエスト

mutation MyMutation {
  httpPostRequestHeaderResolver(body: {content: "Zenn_body_content", name: "Zenn_body_name"}, query: {content: "Zenn_query_content", name: "Zenn_query_name"})
}

レスポンス

{
  "data": {
    "httpPostRequestHeaderResolver": "{message=Received POST body: name = Zenn_body_name, content = Zenn_body_content, query: name = Zenn_query_name, content = Zenn_query_content}"
  }
}

Event内容

{
  resource: '/http-body-resolver',
  path: '/http-body-resolver',
  httpMethod: 'POST',
  queryStringParameters: { content: 'Zenn_query_content', name: 'Zenn_query_name' },
  multiValueQueryStringParameters: { content: [ 'Zenn_query_content' ], name: [ 'Zenn_query_name' ] },
  pathParameters: null,
  stageVariables: null,
  body: '{"name":"Zenn_body_name","content":"Zenn_body_content"}',
}

まとめ

今回はLambda ResolverとHTTP Resolverという二つの強力なカスタムリゾルバーの知見を得ることができました。Lambda Resolverは、AWS Lambda関数を使用して複雑なビジネスロジックを処理し、複数のAWSサービスや外部サービスとの統合する際に利用できそうです。一方、HTTP Resolverは、HTTPエンドポイントへの直接的なリクエストを通じて、既存のREST APIとのシンプルな連携の際に利用できるかと感じました。今回学んだ、API Gatewayのパスパラメータ、クエリストリング、リクエストヘッダー、リクエストボディをフル活用して、アプリケーションの要件に応じた効果的なデータフェッチング戦略を設計できたら良いなと思いました。

Discussion