⛳

AWS Amplify Gen2 のリゾルバを完全理解する:カスタムリゾルバ vs Lambda の使い分け

に公開
2026/01/21

はじめに

AWS Amplify Gen2 で GraphQL API を構築していると、「リゾルバ」という言葉に出会います。公式ドキュメントを読んでも、いまいちピンとこない方も多いのではないでしょうか。

この記事では、リゾルバの概念をシンプルな例えで解説し、カスタムリゾルバと Lambda の使い分け、そして認可(Authorization)との関係について、実際のコードを交えて説明します。

リゾルバとは? - レストランで例える

リゾルバ = 「GraphQL の質問に対する回答係」 です。

レストランで例えてみましょう。

GraphQL スキーマ = メニュー(何が注文できるか)
リゾルバ = シェフ(実際に料理を作る人)

お客さんがカレーを注文する流れを見てみます。

お客さん: 「カレーください」(GraphQL クエリ)
     ↓
メニュー: 「カレーあります」(スキーマで定義済み)
     ↓
シェフ: 冷蔵庫から材料を取り、調理し、お皿に盛る(リゾルバの仕事)
     ↓
お客さん: カレーを受け取る(レスポンス)

つまり、GraphQL スキーマは「何ができるか」を定義し、リゾルバは「どうやるか」を実装します。

コードで見るリゾルバ

// amplify/data/resource.ts

// スキーマ:「getMyMemberships という注文を受け付けます」
getMyMemberships: a
  .query()
  .returns(a.ref('Membership').array())
  .handler(
    a.handler.custom({
      dataSource: 'MembershipTable',
      entry: './resolvers/getMyMemberships.js',  // ← リゾルバ
    }),
  ),
// amplify/data/resolvers/getMyMemberships.js

// リゾルバ:「注文が来たら、こうやってデータを取ってくる」
export function request(ctx) {
  const userId = ctx.identity.sub;
  return {
    operation: 'Query',
    index: 'membershipsByUserId',
    query: {
      expression: 'userId = :userId',
      expressionValues: util.dynamodb.toMapValues({ ':userId': userId }),
    },
  };
}

export function response(ctx) {
  return ctx.result.items;
}

Amplify Gen2 における 3 種類のリゾルバ

Amplify Gen2 では、リゾルバの実装方法が 3 種類あります。

1. 自動生成リゾルバ(デフォルト)

モデルを定義するだけで、CRUD 操作のリゾルバが自動生成されます。

const schema = a.schema({
  Todo: a
    .model({
      title: a.string().required(),
      completed: a.boolean().default(false),
    })
    .authorization((allow) => [allow.owner()]),
});

これだけで createTodo、getTodo、listTodos、updateTodo、deleteTodo が使えるようになります。裏側では Amplify が自動的にリゾルバを生成しています。

2. カスタムリゾルバ(APPSYNC_JS)

自動生成では対応できないカスタムロジックが必要な場合に使用します。

getMyMemberships: a
  .query()
  .returns(a.ref('Membership').array())
  .authorization((allow) => [allow.authenticated()])
  .handler(
    a.handler.custom({
      dataSource: 'MembershipTable',
      entry: './resolvers/getMyMemberships.js',
    }),
  ),

3. Lambda リゾルバ

複雑なビジネスロジックや外部 API 連携が必要な場合に使用します。

acceptInvite: a
  .mutation()
  .arguments({ token: a.string().required() })
  .returns(a.ref('Membership'))
  .authorization((allow) => [allow.authenticated()])
  .handler(a.handler.function(acceptInvite)),

カスタムリゾルバ vs Lambda:どちらを使うべきか

比較表

項目 カスタムリゾルバ (APPSYNC_JS) Lambda
コールドスタート なし あり(数百ms〜数秒)
実行速度 高速 やや遅い
複雑なロジック 苦手 得意
外部 API 呼び出し 不可 可能
TypeScript 不可(JS のみ) 可能
複数テーブル操作 困難 容易
デバッグ 難しい CloudWatch で容易
コスト AppSync 料金のみ Lambda 料金が追加

使い分けの指針

カスタムリゾルバを選ぶケース:

  • シンプルな DynamoDB 操作
  • カスタム認可ロジック
  • 高速なレスポンスが必要
  • 単一テーブルへのアクセス

Lambda を選ぶケース:

  • 複雑なビジネスロジック
  • 複数テーブルの操作(トランザクション)
  • 外部 API との連携
  • TypeScript で書きたい
  • 詳細なログ・デバッグが必要

実装例:カスタムリゾルバ

「ログインユーザーの所属一覧を取得する」シンプルなクエリ。

// amplify/data/resolvers/getMyMemberships.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const userId = ctx.identity.sub;

  return {
    operation: 'Query',
    index: 'membershipsByUserId',
    query: {
      expression: 'userId = :userId',
      expressionValues: util.dynamodb.toMapValues({ ':userId': userId }),
    },
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result.items;
}

特徴:

  • 約 20 行のシンプルなコード
  • コールドスタートなし
  • DynamoDB への直接クエリ

実装例:Lambda リゾルバ

「招待トークンを検証し、メンバーシップを作成する」複雑なミューテーション。

// amplify/functions/accept-invite/handler.ts
import type { AppSyncResolverHandler } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  QueryCommand,
  PutCommand,
  UpdateCommand,
} from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

interface AcceptInviteArgs {
  token: string;
}

interface Membership {
  id: string;
  businessId: string;
  userId: string;
  role: string;
  status: string;
}

export const handler: AppSyncResolverHandler<AcceptInviteArgs, Membership> = async (event) => {
  const { token } = event.arguments;
  const userId = event.identity?.sub;

  if (!userId) {
    throw new Error('Unauthorized');
  }

  // 1. トークンで招待を検索
  const inviteResult = await client.send(
    new QueryCommand({
      TableName: process.env.INVITE_TABLE_NAME,
      IndexName: 'invitesByToken',
      KeyConditionExpression: '#token = :token',
      ExpressionAttributeNames: { '#token': 'token' },
      ExpressionAttributeValues: { ':token': token },
    }),
  );

  const invite = inviteResult.Items?.[0];

  if (!invite) {
    throw new Error('招待が見つかりません');
  }

  // 2. 招待の有効性を検証
  if (invite.status !== 'ACTIVE') {
    throw new Error('この招待は既に使用されているか、無効です');
  }

  if (new Date(invite.expiresAt) < new Date()) {
    throw new Error('この招待は有効期限が切れています');
  }

  // 3. メンバーシップを作成
  const membership: Membership = {
    id: uuidv4(),
    businessId: invite.businessId,
    userId,
    role: 'STAFF',
    status: 'ACTIVE',
  };

  await client.send(
    new PutCommand({
      TableName: process.env.MEMBERSHIP_TABLE_NAME,
      Item: membership,
    }),
  );

  // 4. 招待ステータスを更新
  await client.send(
    new UpdateCommand({
      TableName: process.env.INVITE_TABLE_NAME,
      Key: { id: invite.id },
      UpdateExpression: 'SET #status = :status',
      ExpressionAttributeNames: { '#status': 'status' },
      ExpressionAttributeValues: { ':status': 'USED' },
    }),
  );

  return membership;
};

特徴:

  • 複数テーブルへの操作
  • ビジネスロジック(検証、ステータス管理)
  • TypeScript による型安全性
  • エラーハンドリング

Pipeline Resolver:複数データソースへのアクセス

ここまで「単一リゾルバ」と「Lambda」を比較してきましたが、実は第 3 の選択肢があります。Pipeline Resolver です。

単一リゾルバの限界

現在のプロジェクトの getInvitesByBusinessId を見てみましょう。

// amplify/data/resolvers/getInvitesByBusinessId.js
export function request(ctx) {
  const { businessId } = ctx.arguments;

  return {
    operation: 'Query',
    index: 'invitesByBusinessId',
    query: {
      expression: 'businessId = :businessId',
      expressionValues: util.dynamodb.toMapValues({ ':businessId': businessId }),
    },
  };
}

export function response(ctx) {
  return ctx.result.items;
}

このリゾルバには問題があります。認証済みユーザーなら誰でも、任意の businessId の招待一覧を取得できてしまいます。本来は OWNER 権限を持つユーザーだけがアクセスできるべきです。

現状の問題:
  認証済みユーザー → getInvitesByBusinessId(businessId: "xxx")
                    → 他人のビジネスの招待一覧が見える 😱

解決策の比較:3 つのアプローチ

この「OWNER 権限チェック + データ取得」を実装する方法を比較してみましょう。


方法 1:単一リゾルバ(現状)

┌─────────────────────────────────────────┐
│ getInvitesByBusinessId                  │
│                                         │
│  request() → InviteTable → response()   │
│                                         │
│  ❌ 権限チェックなし                     │
└─────────────────────────────────────────┘

問題点:

  • 単一のデータソース(InviteTable)にしかアクセスできない
  • 権限チェックには Membership テーブルへのアクセスが必要
  • → 単一リゾルバでは実現不可能

方法 2:Lambda リゾルバ

┌──────────────────────────────────────────────────────────┐
│ getInvitesByBusinessId (Lambda)                          │
│                                                          │
│  1. Membership テーブルで OWNER 権限チェック              │
│  2. Invite テーブルから招待一覧を取得                     │
│  3. 結果を返す                                           │
│                                                          │
│  ✅ 複数テーブルアクセス可能                              │
│  ❌ コールドスタートあり                                  │
└──────────────────────────────────────────────────────────┘
// amplify/functions/get-invites-by-business/handler.ts
export const handler: AppSyncResolverHandler<Args, Invite[]> = async (event) => {
  const userId = event.identity?.sub;
  const { businessId } = event.arguments;

  // 1. OWNER 権限チェック
  const membershipResult = await client.send(
    new QueryCommand({
      TableName: process.env.MEMBERSHIP_TABLE_NAME,
      IndexName: 'membershipsByUserId',
      KeyConditionExpression: 'userId = :userId',
      ExpressionAttributeValues: { ':userId': userId },
    }),
  );

  const isOwner = membershipResult.Items?.some(
    (m) => m.businessId === businessId && m.role === 'OWNER',
  );

  if (!isOwner) {
    throw new Error('この操作を実行する権限がありません');
  }

  // 2. 招待一覧を取得
  const inviteResult = await client.send(
    new QueryCommand({
      TableName: process.env.INVITE_TABLE_NAME,
      IndexName: 'invitesByBusinessId',
      KeyConditionExpression: 'businessId = :businessId',
      ExpressionAttributeValues: { ':businessId': businessId },
    }),
  );

  return inviteResult.Items as Invite[];
};

メリット:

  • TypeScript で型安全に書ける
  • 複雑なロジックも自由に実装可能
  • デバッグしやすい(CloudWatch Logs)

デメリット:

  • コールドスタートがある(数百ms〜数秒)
  • Lambda 料金が発生
  • コード量が多い

方法 3:Pipeline Resolver ⭐

⚠️ Amplify Gen2 での注意点

2024 年現在、Amplify Gen2 のスキーマ DSL(resource.ts)では a.handler.pipeline() はサポートされていません。
Pipeline Resolver を使用するには backend.ts で CDK スタイルの API(backend.data.addFunction() + backend.data.addResolver())を使う必要があり、設定が複雑になります。

以下は AppSync の Pipeline Resolver の概念説明として記載しています。実際の Amplify Gen2 プロジェクトでは、複数テーブルアクセスが必要な場合は Lambda リゾルバを使うのが現実的です。

┌──────────────────────────────────────────────────────────────────┐
│ getInvitesByBusinessId (Pipeline)                                │
│                                                                  │
│  ┌────────────────┐          ┌─────────────────┐                │
│  │   Function 1   │    →     │   Function 2    │                │
│  │  権限チェック   │          │   招待取得      │                │
│  │ MembershipTable│          │  InviteTable    │                │
│  └────────────────┘          └─────────────────┘                │
│                                                                  │
│  ✅ 複数テーブルアクセス可能                                      │
│  ✅ コールドスタートなし                                          │
└──────────────────────────────────────────────────────────────────┘

AppSync での定義(参考):

Pipeline Resolver は AppSync コンソールや CloudFormation/CDK で直接定義します。
Amplify Gen2 で使用する場合は backend.ts で CDK API を使う必要があります。

// backend.ts での定義例(CDK スタイル)
import { Code, FunctionRuntime } from 'aws-cdk-lib/aws-appsync';

// 関数を定義
const checkOwnerFn = backend.data.addFunction('CheckOwnerFn', {
  name: 'checkOwnerPermission',
  dataSource: backend.data.resources.tables['Membership'],
  code: Code.fromAsset('./resolvers/pipeline/checkOwnerPermission.js'),
  runtime: FunctionRuntime.JS_1_0_0,
});

const fetchInvitesFn = backend.data.addFunction('FetchInvitesFn', {
  name: 'fetchInvites',
  dataSource: backend.data.resources.tables['Invite'],
  code: Code.fromAsset('./resolvers/pipeline/fetchInvites.js'),
  runtime: FunctionRuntime.JS_1_0_0,
});

// Pipeline Resolver を定義
backend.data.addResolver('GetInvitesByBusinessIdResolver', {
  typeName: 'Query',
  fieldName: 'getInvitesByBusinessId',
  pipelineConfig: [checkOwnerFn, fetchInvitesFn],
  code: Code.fromInline(`
    export const request = (ctx) => ({})
    export const response = (ctx) => ctx.prev.result
  `),
  runtime: FunctionRuntime.JS_1_0_0,
});

Note: この CDK スタイルの定義は冗長で、デプロイ時のエラーも報告されています。
実際のプロジェクトでは Lambda リゾルバを使う方が現実的です。

関数 1:権限チェック

// amplify/data/resolvers/pipeline/checkOwnerPermission.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const userId = ctx.identity.sub;
  const { businessId } = ctx.arguments;

  // stash に businessId を保存(次の関数で使う)
  ctx.stash.businessId = businessId;

  return {
    operation: 'Query',
    index: 'membershipsByUserId',
    query: {
      expression: 'userId = :userId',
      expressionValues: util.dynamodb.toMapValues({ ':userId': userId }),
    },
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  const memberships = ctx.result.items || [];
  const businessId = ctx.stash.businessId;

  // OWNER 権限チェック
  const isOwner = memberships.some((m) => m.businessId === businessId && m.role === 'OWNER');

  if (!isOwner) {
    util.unauthorized(); // 403 エラーを返す
  }

  return { authorized: true };
}

関数 2:招待取得

// amplify/data/resolvers/pipeline/fetchInvites.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  // stash から businessId を取得
  const { businessId } = ctx.stash;

  return {
    operation: 'Query',
    index: 'invitesByBusinessId',
    query: {
      expression: 'businessId = :businessId',
      expressionValues: util.dynamodb.toMapValues({ ':businessId': businessId }),
    },
    scanIndexForward: false, // 新しい順
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result.items;
}

メリット:

  • コールドスタートなし(高速)
  • 複数テーブルにアクセス可能
  • Lambda 料金が不要
  • シンプルなコード

デメリット:

  • JavaScript のみ(TypeScript 不可)
  • 複雑なロジックは書きづらい
  • デバッグが難しい

Pipeline Resolver の仕組み

実行フロー:

  ctx.arguments      ctx.stash        ctx.prev.result
       ↓                 ↓                  ↓
  ┌─────────┐      ┌──────────┐       ┌──────────┐
  │ before  │  →   │ function1│   →   │ function2│   →   response
  └─────────┘      └──────────┘       └──────────┘
                         ↓                  ↓
                   stash に保存      prev.result で
                   businessId      前の結果を参照

ctx オブジェクトの流れ:

// function1
ctx = {
  arguments: { businessId: 'xxx' }, // クエリ引数
  identity: { sub: 'user-123' }, // 認証情報
  stash: {}, // 空(これから保存する)
  prev: { result: null }, // 前の関数なし
};

// function2
ctx = {
  arguments: { businessId: 'xxx' },
  identity: { sub: 'user-123' },
  stash: { businessId: 'xxx' }, // function1 で保存した値
  prev: { result: { authorized: true } }, // function1 の戻り値
};

3 つのアプローチ比較表

項目 単一リゾルバ Pipeline Resolver Lambda
複数テーブル ❌ ✅ ✅
コールドスタート なし なし あり
コード量 少 中 多
TypeScript ❌ ❌ ✅
デバッグ 難 難 易
複雑なロジック ❌ △ ✅
コスト 低 低 中
Amplify Gen2 ✅ 簡単 ⚠️ 複雑(CDK) ✅ 簡単

使い分けの指針(更新版)

AppSync 単体での理想的な使い分け:

シンプルな単一テーブル操作
    → 単一リゾルバ

権限チェック + データ取得(シンプルなロジック)
    → Pipeline Resolver ⭐

複雑なビジネスロジック / 外部 API / TypeScript 必須
    → Lambda

Amplify Gen2 での現実的な使い分け:

シンプルな単一テーブル操作
    → 単一リゾルバ(a.handler.custom)

複数テーブル操作 / 権限チェック + データ取得
    → Lambda(a.handler.function)⚠️ Pipeline は DSL 非対応

複雑なビジネスロジック / 外部 API / TypeScript 必須
    → Lambda

このプロジェクトでの実装:

API 実装 理由
getMyMemberships 単一リゾルバ 単一テーブル、シンプル
getInvitesByBusinessId 単一リゾルバ ※本来は権限チェックが必要
getBusinessMembers 単一リゾルバ ※本来は権限チェックが必要
acceptInvite Lambda 複数テーブル操作、複雑なロジック
cancelInvite Lambda 複数テーブル操作(権限チェック + 更新)
issueInvite Lambda 複数テーブル操作(権限チェック + 作成)
getMyPendingInvites Lambda Cognito API 呼び出しあり

APPSYNC_JS ランタイム:書き方ガイド

カスタムリゾルバは APPSYNC_JS という AppSync 独自のランタイムで実行されます。Node.js とは異なる制約があるため、書き方のポイントを押さえておきましょう。

Node.js との違い

APPSYNC_JS ≠ Node.js

できないこと:
  ❌ console.log(存在しない)
  ❌ require / import(@aws-appsync/utils 以外)
  ❌ async/await
  ❌ fetch / HTTP リクエスト
  ❌ setTimeout / setInterval
  ❌ ファイル操作
  ❌ 環境変数(process.env)

できること:
  ✅ 基本的な JavaScript(ES6+)
  ✅ @aws-appsync/utils のユーティリティ
  ✅ JSON 操作
  ✅ 文字列・配列・オブジェクト操作

基本構造:request と response

すべてのリゾルバは request と response の 2 つの関数で構成されます。

// amplify/data/resolvers/example.js
import { util } from '@aws-appsync/utils';

/**
 * request: データソースへのリクエストを構築
 * @param {Context} ctx - コンテキストオブジェクト
 * @returns {Request} DynamoDB への操作指示
 */
export function request(ctx) {
  // ctx から必要な情報を取得
  const userId = ctx.identity.sub; // 認証ユーザーの ID
  const { businessId } = ctx.arguments; // クエリ/ミューテーションの引数

  // DynamoDB への操作を返す
  return {
    operation: 'Query',
    // ...
  };
}

/**
 * response: データソースからのレスポンスを加工
 * @param {Context} ctx - コンテキストオブジェクト
 * @returns {any} クライアントに返すデータ
 */
export function response(ctx) {
  // エラーハンドリング
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  // 結果を返す
  return ctx.result.items;
}

ctx オブジェクト詳解

ctx = {
  // 引数(GraphQL クエリ/ミューテーションの引数)
  arguments: {
    businessId: "xxx",
    limit: 10,
  },

  // 認証情報
  identity: {
    sub: "user-uuid",              // Cognito User ID
    username: "user@example.com",  // ユーザー名
    claims: { ... },               // JWT クレーム
    issuer: "...",                 // トークン発行者
  },

  // データソースからの結果(response 関数で使用)
  result: {
    items: [...],      // Query 結果
    nextToken: "...",  // ページネーショントークン
  },

  // エラー情報(あれば)
  error: {
    message: "...",
    type: "...",
  },

  // Pipeline Resolver 用
  stash: {},              // 関数間でデータを共有
  prev: { result: ... },  // 前の関数の結果
};

DynamoDB 操作パターン

Query(インデックス検索)

export function request(ctx) {
  const { businessId } = ctx.arguments;

  return {
    operation: 'Query',
    index: 'invitesByBusinessId', // GSI 名
    query: {
      expression: 'businessId = :businessId',
      expressionValues: util.dynamodb.toMapValues({
        ':businessId': businessId,
      }),
    },
    scanIndexForward: false, // false = 降順(新しい順)
    limit: 50,
  };
}

GetItem(単一アイテム取得)

export function request(ctx) {
  const { id } = ctx.arguments;

  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ id }),
  };
}

export function response(ctx) {
  return ctx.result; // items ではなく直接 result
}

PutItem(作成)

export function request(ctx) {
  const { input } = ctx.arguments;
  const now = util.time.nowISO8601();

  return {
    operation: 'PutItem',
    key: util.dynamodb.toMapValues({ id: util.autoId() }),
    attributeValues: util.dynamodb.toMapValues({
      ...input,
      createdAt: now,
      updatedAt: now,
    }),
  };
}

UpdateItem(更新)

export function request(ctx) {
  const { id, input } = ctx.arguments;
  const now = util.time.nowISO8601();

  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ id }),
    update: {
      expression: 'SET #status = :status, #updatedAt = :updatedAt',
      expressionNames: {
        '#status': 'status',
        '#updatedAt': 'updatedAt',
      },
      expressionValues: util.dynamodb.toMapValues({
        ':status': input.status,
        ':updatedAt': now,
      }),
    },
  };
}

DeleteItem(削除)

export function request(ctx) {
  const { id } = ctx.arguments;

  return {
    operation: 'DeleteItem',
    key: util.dynamodb.toMapValues({ id }),
  };
}

よく使う util 関数

import { util } from '@aws-appsync/utils';

// DynamoDB 型変換
util.dynamodb.toMapValues({ id: 'xxx', count: 10 });
// → { "id": { "S": "xxx" }, "count": { "N": "10" } }

// 自動 UUID 生成
util.autoId(); // → "550e8400-e29b-41d4-a716-446655440000"

// 現在時刻(ISO 8601 形式)
util.time.nowISO8601(); // → "2024-01-15T10:30:00.000Z"

// エポックミリ秒
util.time.nowEpochMilliSeconds(); // → 1705315800000

// エラーを投げる
util.error('エラーメッセージ', 'ErrorType');

// 認可エラー(403)
util.unauthorized();

// デバッグ用エラー追加(レスポンスに含まれる)
util.appendError('デバッグ情報', 'DebugInfo');

エラーハンドリングパターン

export function response(ctx) {
  // データソースエラー
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  // ビジネスロジックエラー
  const items = ctx.result.items || [];
  if (items.length === 0) {
    util.error('データが見つかりません', 'NotFoundError');
  }

  // 認可エラー
  const membership = items.find((m) => m.role === 'OWNER');
  if (!membership) {
    util.unauthorized(); // 403 Unauthorized を返す
  }

  return items;
}

デバッグテクニック

console.log が使えないため、デバッグには工夫が必要です。

方法 1:一時的にデータを返す

export function response(ctx) {
  // 開発時のみ:確認したいデータをそのまま返す
  return {
    _debug: {
      identity: ctx.identity,
      arguments: ctx.arguments,
      result: ctx.result,
      stash: ctx.stash,
    },
  };
}

方法 2:appendError でデバッグ情報を追加

export function response(ctx) {
  // エラー配列に情報を追加(本番では削除)
  util.appendError(JSON.stringify({ userId: ctx.identity.sub, args: ctx.arguments }), 'DebugInfo');

  return ctx.result.items;
}

方法 3:CloudWatch Logs を有効化

AppSync コンソール → Settings → Logging で有効化すると、リクエスト/レスポンスのログが CloudWatch に記録されます。

Field-level logging: ALL
Include verbose content: true

よくあるエラーと対処法

「util is not defined」

// ❌ import を忘れている
export function request(ctx) {
  return util.dynamodb.toMapValues({ id: 'xxx' });
}

// ✅ import を追加
import { util } from '@aws-appsync/utils';

「Cannot read property 'items' of undefined」

// ❌ GetItem の結果を items で参照
export function response(ctx) {
  return ctx.result.items; // GetItem には items がない
}

// ✅ GetItem は直接 result を返す
export function response(ctx) {
  return ctx.result;
}

「Unauthorized」が意図せず発生

// ❌ 認可設定を忘れている(スキーマ側)
getMyMemberships: a
  .query()
  .returns(a.ref('Membership').array())
  // .authorization() がない!
  .handler(...)

// ✅ 認可を追加
getMyMemberships: a
  .query()
  .returns(a.ref('Membership').array())
  .authorization((allow) => [allow.authenticated()])
  .handler(...)

Amplify Gen2 でのデータソース名

カスタムリゾルバで使用するデータソース名は、モデル名 + Table です。

// スキーマ定義
const schema = a.schema({
  Membership: a.model({ ... }),
  Invite: a.model({ ... }),
  Business: a.model({ ... }),
});

// データソース名
//   Membership → 'MembershipTable'
//   Invite     → 'InviteTable'
//   Business   → 'BusinessTable'
// リゾルバで使用
getMyMemberships: a
  .query()
  .handler(
    a.handler.custom({
      dataSource: 'MembershipTable',  // ← モデル名 + Table
      entry: './resolvers/getMyMemberships.js',
    }),
  ),

認可(Authorization)との関係

モデルの認可ルール

Amplify Gen2 では、モデルごとに認可ルールを定義できます。

Membership: a
  .model({
    businessId: a.id().required(),
    userId: a.id().required(),
    role: a.ref('MembershipRole').required(),
    status: a.ref('MembershipStatus').required(),
  })
  .authorization((allow) => [allow.authenticated().to(['read'])]),

この設定では:

  • ✅ 認証済みユーザーは read(個別取得)可能
  • ❌ create、update、delete は誰も不可
  • ❌ list(一覧取得)は定義されていない

リゾルバ単位の認可

カスタムクエリ/ミューテーションには、リゾルバ単位で認可を定義できます。

// カスタムクエリの認可
getMyMemberships: a
  .query()
  .returns(a.ref('Membership').array())
  .authorization((allow) => [allow.authenticated()])  // ← リゾルバの認可
  .handler(...)

// カスタムミューテーションの認可
acceptInvite: a
  .mutation()
  .arguments({ token: a.string().required() })
  .returns(a.ref('Membership'))
  .authorization((allow) => [allow.authenticated()])  // ← リゾルバの認可
  .handler(...)

2 つの認可レイヤー:

レイヤー 対象 適用タイミング
モデル認可 自動生成 CRUD(createTodo, getTodo 等) 自動生成リゾルバ実行時
リゾルバ認可 カスタムクエリ/ミューテーション カスタムリゾルバ/Lambda 呼び出し時

この 2 つは独立しており、カスタムリゾルバを呼び出す際は「リゾルバ認可」のみがチェックされます。

カスタムリゾルバ・Lambda はモデルの認可をバイパスする

重要なポイントとして、カスタムリゾルバと Lambda はモデルの認可ルールをバイパスします。

通常の GraphQL 操作(自動生成 API):
  クライアント → AppSync → モデルの認可チェック → 自動生成リゾルバ → DynamoDB
                            ↑ ここでブロックされる可能性

カスタムリゾルバ / Lambda:
  クライアント → AppSync → リゾルバの認可チェック → リゾルバ実行 → DynamoDB 直接アクセス
                            ↑ ここだけチェック        ↑ モデル認可のレイヤーを通らない

「モデルの認可は無視」というより、正確には 「モデル認可が適用されるレイヤー(自動生成リゾルバ)を通らない」 という意味です。DynamoDB 自体には GraphQL の認可ルールという概念がないため、直接アクセスすれば何でもできてしまいます。

なぜこの設計が必要なのか

「作成する人」と「読み取る人」が異なるユースケースでは、標準の認可ルールだけでは対応できません。

例:メンバーシップ管理

作成: 管理者またはシステム(招待受諾時の Lambda)
読取: メンバー本人

標準の owner 認可では:

  • owner = 作成者 = 管理者/システム
  • メンバー本人は自分のデータを読めない

カスタムリゾルバで解決:

  • getMyMemberships で userId が自分のものだけを返す
  • acceptInvite Lambda で認可ルールに関係なく作成

セキュリティの責任分担

レイヤー 責任
クエリ/ミューテーションの認可 誰が呼び出せるか を定義
リゾルバ内のロジック 何をするか を実装(開発者の責任)
// クエリの認可:認証済みユーザーのみ呼び出し可能
getMyMemberships: a
  .query()
  .authorization((allow) => [allow.authenticated()])
  .handler(...)
// リゾルバ内:自分のデータだけを返すように実装
export function request(ctx) {
  const userId = ctx.identity.sub; // ← これを忘れると全員のデータが見える!
  // ...
}

まとめ

リゾルバの役割

  • スキーマ: 「何ができるか」を定義
  • リゾルバ: 「どうやるか」を実装

Amplify Gen2 での使い分け

ユースケース 推奨
シンプルな CRUD 自動生成(デフォルト)
カスタム認可 + 単一テーブル カスタムリゾルバ
複数テーブル操作 / 外部 API Lambda(Pipeline は非推奨)
複雑なビジネスロジック Lambda

Note: Pipeline Resolver は AppSync の強力な機能ですが、Amplify Gen2 のスキーマ DSL では直接サポートされていません(2024 年現在)。複数テーブル操作が必要な場合は Lambda を使うのが現実的です。

認可の注意点

  • カスタムリゾルバ・Lambda はモデルの認可をバイパスする
  • セキュリティロジックは開発者の責任で実装する
  • 「作成者 ≠ 読取者」のユースケースではカスタムリゾルバが有効

参考リンク

Discussion