Aurora MySQL が RDS Data API を遂にサポート！AppSync で爆速 GraphQL API を作ってみた


## はじめに

:::note warn
本記事は 2024 年 10 月 2 日時点の情報に基づいて執筆しています。
内容に不備ありましたら気軽にコメントください！
:::

こんにちは。[いなりく](https://twitter.com/inada_riku)です✋

2023 年 11 月 27 日に、AWS AppSync は RDS Data API を使用して Amazon Aurora クラスター内のデータベースに対して[イントロスペクション](https://graphql.org/learn/introspection/)を行うことで、検出したテーブルに適合した GraphQL API のインターフェイスを簡単に作成することが可能になりました。

https://aws.amazon.com/jp/about-aws/whats-new/2023/11/aws-appsync-aurora-clusters-rds-data-api/

以下の記事では、このアップデートを Amazon Aurora Serverless v2 (PostgreSQL) に試したものです。しかし、前回記事の執筆時点では Aurora MySQL では、Data API は Aurora Serverless v1 データベースでのみサポートだったため、あと一歩の検証となりました。

https://qiita.com/inada_riku/items/5f058e34bf8c0737f588

そんな記事を書いたことすらさっぱりと忘れてたのですが、先日 X を眺めていると、こんなアップデートがありました。「遂に来た！」と思い、Aurora MySQL でも早速試してみることとします。

https://aws.amazon.com/jp/about-aws/whats-new/2024/09/amazon-aurora-mysql-rds-data-api/

## RDS Data API の対応状況
検証を始める前に、自分の理解のためにも RDS Data API の今までの対応状況について調べてみました。

<table>
  <thead>
    <tr>
      <th rowspan="2">エンジン</th>
      <th rowspan="2">クラスタータイプ</th>
      <th colspan="2">2023</th>
      <th>2024</th>
    </tr>
    <tr>
      <th>- 12/20</th>
      <th>12/21 -</th>
      <th>9/26 -</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td rowspan="2">PostgreSQL</td>
      <td>Serverless v2 / Provisioned</td>
      <td>×</td>
      <td>○</td>
      <td>○</td>
    </tr>
    <tr>
      <td>Serverless v1</td>
      <td>○</td>
      <td>○</td>
      <td>○</td>
    </tr>
    <tr>
      <td rowspan="2">MySQL</td>
      <td>Serverless v2 / Provisioned</td>
      <td>×</td>
      <td>×</td>
      <td>○</td>
    </tr>
    <tr>
      <td>Serverless v1</td>
      <td>○</td>
      <td>○</td>
      <td>○</td>
    </tr>
  </tbody>
</table>

2023 年 12 月 21 日に以下のアップデートが来ました。

https://aws.amazon.com/jp/about-aws/whats-new/2023/12/amazon-aurora-postgresql-rds-data-api/

2024 年 9 月 26 日に以下のアップデートが来ました。

https://aws.amazon.com/jp/about-aws/whats-new/2024/09/amazon-aurora-mysql-rds-data-api/

余談ですが、[公式ドキュメント](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless.html)にもあるように、MySQL 8.0 または PostgreSQL 13 上でアプリケーションを実行できる場合は、Aurora Serverless v2 を使用することをお勧めします。

:::note alert
**Important** ([公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/aurora-serverless.html)から引用)
Aurora has two generations of serverless technology, Aurora Serverless v2 and Aurora Serverless v1. If your application can run on MySQL 8.0 or PostgreSQL 13, we recommend that you use Aurora Serverless v2. Aurora Serverless v2 scales more quickly and in a more granular way. Aurora Serverless v2 also has more compatibility with other Aurora features such as reader DB instances. Thus, if you're already familiar with Aurora, you don't have to learn as many new procedures or limitations to use Aurora Serverless v2 as with Aurora Serverless v1.
:::

## やってみた

では、やっていきます。基本的な手順に関しては公式ドキュメントのリンクを載せているのでそちらを参照ください。

### Step1. Amazon Aurora Serverless v2 (MySQL) の作成

まず、Aurora Serverless v2 (MySQL) の DB クラスタを作成します。手順については「[Aurora MySQL DB クラスターの作成と接続](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/CHAP_GettingStartedAurora.CreatingConnecting.Aurora.html)」を参考にしてください。

:::note warn
注意
エンジンバージョンがデフォルトでは`Aurora MySQL 3.05.2 (compatible with MySQL 8.0.32) - メジャーバージョンのデフォルト 8.0` になっているので、`Aurora MySQL 3.07.0 (compatible with MySQL 8.0.36)` に変える必要がある点だけ注意してください。
![screenshot 8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/5ef97701-e68a-f607-2567-d78844989178.png)
:::

また、DB クラスター作成時に、以下のようなチェックボックスができるのでチェックを入れます。忘れてしまった場合もあとから有効化は可能です。

![screenshot 7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/7c0cfb24-51e6-5aca-f3d8-24a2b6011bf8.png)

認証情報は AWS Secret Manager で管理します。後ほど、認証情報は AWS AppSync からイントロスペクションを実施する際にも利用します。

![screenshot 9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/c3d223a0-3d99-bde4-19a0-9974ff4f0046.png)

DB クラスターの作成が完了したら、データベースの作成を行います。今回は「sample_db」とします。

```
CREATE DATABASE sample_db;
```

次に、テーブルの作成を行います。以下のようなテーブル定義にします。

```sql
CREATE TABLE conversations (  
id INT NOT NULL PRIMARY KEY,  
name VARCHAR(255) NOT NULL,  
created_at DATETIME DEFAULT CURRENT_TIMESTAMP  
);  
  
CREATE TABLE messages (  
id VARCHAR(36) PRIMARY KEY,  
conversation_id INT NOT NULL,  
sub VARCHAR(36) NOT NULL,  
body TEXT NOT NULL,  
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,  
FOREIGN KEY (conversation_id) REFERENCES conversations(id)  
);  
  
CREATE TABLE conversation_participants (  
conversation_id INT NOT NULL,  
sub varchar(36) NOT NULL,  
last_read_at DATETIME,  
PRIMARY KEY (conversation_id, sub),  
FOREIGN KEY (conversation_id) REFERENCES conversations(id)  
);
```

テーバルが正常に作成されたか確認します。
```
MySQL [sample_db]> show tables;
+---------------------------+
| Tables_in_sample_db       |
+---------------------------+
| conversation_participants |
| conversations             |
| messages                  |
+---------------------------
3 rows in set (0.002 sec)
```

### Step2. GraphQL API の作成

次に、GraphQL API を作成しましょう。GraphQL API データソースで「Amazon Aurora クラスターから始める - New」を選択し、次に進みます。

![screenshot 4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/4b56ac8c-aa13-75d5-2b8d-c2e935598186.png)

次に、API 名にわかり易い名前を入力します。今回はデフォルトの「My AppSync API」としました。

![screenshot 5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/f3d804da-6b93-ac9a-7427-aea95153d21d.png)

次にデータベースを選択します。

![スクリーンショット 2023-12-22 16.13.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/c105701d-1b6f-7771-1acf-a8f8fa93673f.png)

「データベースを選択」をクリックすると接続したい Amazon Aurora クラスターが選択出来ます。データベースには「sample_db」と入力します。AWS Secret  Manager シークレットは Step1 で作成済みのものを選択します。入力が終えたら「インポート」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/b46ab22c-2691-d48c-a5c6-ac554e38d8ad.png)

「インポート」をクリックするとイントロスペクションが開始され、下の画像のようにデータベースのテーブルがインポートされました。タイプ名はカスタマイズすることができるので、以下のように変更します。(画像は変更前です。)

* `Conversation_participants` : `Participant`
* `Conversations` : `Conversation`
* `Messages` : `Message`

問題なければ、「次へ」をクリックします。

![screenshot 13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/ca5c53f3-d26f-636b-1d8c-811f198d607b.png)

スキーマの設定では、クエリのみを作成するか、クエリ、ミューテーション、サブスクリプションを作成するかを選択することが出来ます。今回は、「すべてのモデルに対してクエリ、ミューテーション、サブスクリプションを作成」を選択します。これで GraphQL API の作成は完了です！

![スクリーンショット 2023-12-22 13.31.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625536/9be8fb68-3655-102d-1941-dd5928b7a5d1.png)

## Step4. 動作確認

では、実際にどんなスキーマやリゾルバーが作成されたか見てみましょう。まずはスキーマを見てみましょう。

### 確認 1 : GraphQL スキーマ

以下が自動で生成されたスキーマです。Aurora PostgreSQL で試したときと同様にスキーマが正しく作成できていることが確認できました。

<details><summary> schema.graphql </summary>

```graphql:schema.graphql
type Conversation {
	id: Int!
	name: String!
	created_at: String
}

type ConversationConnection {
	items: [Conversation]
	nextToken: String
}

input CreateConversationInput {
	id: Int!
	name: String!
	created_at: String
}

input CreateMessageInput {
	id: String!
	conversation_id: Int!
	sub: String!
	body: String!
	created_at: String
}

input CreateParticipantInput {
	conversation_id: Int!
	sub: String!
	last_read_at: String
}

input DeleteConversationInput {
	id: Int!
}

input DeleteMessageInput {
	id: String!
}

input DeleteParticipantInput {
	conversation_id: Int!
	sub: String!
}

type Message {
	id: String!
	conversation_id: Int!
	sub: String!
	body: String!
	created_at: String
}

type MessageConnection {
	items: [Message]
	nextToken: String
}

input ModelSizeInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	between: [Int]
}

enum ModelSortDirection {
	ASC
	DESC
}

input OrderByConversationInput {
	id: ModelSortDirection
	name: ModelSortDirection
	created_at: ModelSortDirection
}

input OrderByMessageInput {
	id: ModelSortDirection
	conversation_id: ModelSortDirection
	sub: ModelSortDirection
	body: ModelSortDirection
	created_at: ModelSortDirection
}

input OrderByParticipantInput {
	conversation_id: ModelSortDirection
	sub: ModelSortDirection
	last_read_at: ModelSortDirection
}

type Participant {
	conversation_id: Int!
	sub: String!
	last_read_at: String
}

type ParticipantConnection {
	items: [Participant]
	nextToken: String
}

input TableConversationConditionInput {
	name: TableStringFilterInput
	created_at: TableStringFilterInput
	and: [TableConversationConditionInput]
	or: [TableConversationConditionInput]
	not: [TableConversationConditionInput]
}

input TableConversationFilterInput {
	id: TableIntFilterInput
	name: TableStringFilterInput
	created_at: TableStringFilterInput
	and: [TableConversationFilterInput]
	or: [TableConversationFilterInput]
	not: [TableConversationFilterInput]
}

input TableIntFilterInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	between: [Int]
	attributeExists: Boolean
}

input TableMessageConditionInput {
	conversation_id: TableIntFilterInput
	sub: TableStringFilterInput
	body: TableStringFilterInput
	created_at: TableStringFilterInput
	and: [TableMessageConditionInput]
	or: [TableMessageConditionInput]
	not: [TableMessageConditionInput]
}

input TableMessageFilterInput {
	id: TableStringFilterInput
	conversation_id: TableIntFilterInput
	sub: TableStringFilterInput
	body: TableStringFilterInput
	created_at: TableStringFilterInput
	and: [TableMessageFilterInput]
	or: [TableMessageFilterInput]
	not: [TableMessageFilterInput]
}

input TableParticipantConditionInput {
	last_read_at: TableStringFilterInput
	and: [TableParticipantConditionInput]
	or: [TableParticipantConditionInput]
	not: [TableParticipantConditionInput]
}

input TableParticipantFilterInput {
	conversation_id: TableIntFilterInput
	sub: TableStringFilterInput
	last_read_at: TableStringFilterInput
	and: [TableParticipantFilterInput]
	or: [TableParticipantFilterInput]
	not: [TableParticipantFilterInput]
}

input TableStringFilterInput {
	ne: String
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	contains: String
	notContains: String
	between: [String]
	beginsWith: String
	attributeExists: Boolean
	size: ModelSizeInput
}

input UpdateConversationInput {
	id: Int!
	name: String
	created_at: String
}

input UpdateMessageInput {
	id: String!
	conversation_id: Int
	sub: String
	body: String
	created_at: String
}

input UpdateParticipantInput {
	conversation_id: Int!
	sub: String!
	last_read_at: String
}

type Mutation {
	createParticipant(input: CreateParticipantInput!): Participant
	updateParticipant(input: UpdateParticipantInput!, condition: TableParticipantConditionInput): Participant
	deleteParticipant(input: DeleteParticipantInput!, condition: TableParticipantConditionInput): Participant
	createConversation(input: CreateConversationInput!): Conversation
	updateConversation(input: UpdateConversationInput!, condition: TableConversationConditionInput): Conversation
	deleteConversation(input: DeleteConversationInput!, condition: TableConversationConditionInput): Conversation
	createMessage(input: CreateMessageInput!): Message
	updateMessage(input: UpdateMessageInput!, condition: TableMessageConditionInput): Message
	deleteMessage(input: DeleteMessageInput!, condition: TableMessageConditionInput): Message
}

type Query {
	getParticipant(conversation_id: Int!, sub: String!): Participant
	listParticipants(
		filter: TableParticipantFilterInput,
		limit: Int,
		orderBy: [OrderByParticipantInput],
		nextToken: String
	): ParticipantConnection
	getConversation(id: Int!): Conversation
	listConversations(
		filter: TableConversationFilterInput,
		limit: Int,
		orderBy: [OrderByConversationInput],
		nextToken: String
	): ConversationConnection
	getMessage(id: String!): Message
	listMessages(
		filter: TableMessageFilterInput,
		limit: Int,
		orderBy: [OrderByMessageInput],
		nextToken: String
	): MessageConnection
}

type Subscription {
	onCreateParticipant(conversation_id: Int, sub: String, last_read_at: String): Participant
		@aws_subscribe(mutations: ["createParticipant"])
	onUpdateParticipant(conversation_id: Int, sub: String, last_read_at: String): Participant
		@aws_subscribe(mutations: ["updateParticipant"])
	onDeleteParticipant(conversation_id: Int, sub: String, last_read_at: String): Participant
		@aws_subscribe(mutations: ["deleteParticipant"])
	onCreateConversation(id: Int, name: String, created_at: String): Conversation
		@aws_subscribe(mutations: ["createConversation"])
	onUpdateConversation(id: Int, name: String, created_at: String): Conversation
		@aws_subscribe(mutations: ["updateConversation"])
	onDeleteConversation(id: Int, name: String, created_at: String): Conversation
		@aws_subscribe(mutations: ["deleteConversation"])
	onCreateMessage(
		id: String,
		conversation_id: Int,
		sub: String,
		body: String,
		created_at: String
	): Message
		@aws_subscribe(mutations: ["createMessage"])
	onUpdateMessage(
		id: String,
		conversation_id: Int,
		sub: String,
		body: String,
		created_at: String
	): Message
		@aws_subscribe(mutations: ["updateMessage"])
	onDeleteMessage(
		id: String,
		conversation_id: Int,
		sub: String,
		body: String,
		created_at: String
	): Message
		@aws_subscribe(mutations: ["deleteMessage"])
}
```
</details>

### 確認 2 : リゾルバー
次に、リゾルバーを見てみます。以下は `createMessage` のリゾルバーコードです。
JavaScript で書かれているので読みやすいですね。

```javascript
import { util } from '@aws-appsync/utils';
import { insert, createPgStatement, toJsonObject } from '@aws-appsync/utils/rds';

/**
 * Puts an item into the messages table using the supplied input.
 * @param {import('@aws-appsync/utils').Context} ctx the context
 * @returns {*} the request
 */
export function request(ctx) {
    const { input } = ctx.args;
    const insertStatement = insert({
        table: 'messages',
        values: input,
        returning: '*',
    });
    return createPgStatement(insertStatement)
}

/**
 * Returns the result or throws an error if the operation failed.
 * @param {import('@aws-appsync/utils').Context} ctx the context
 * @returns {*} the result
 */
export function response(ctx) {
    const { error, result } = ctx;
    if (error) {
        return util.appendError(
            error.message,
            error.type,
            result
        )
    }
    return toJsonObject(result)[0][0]
}
```

### 確認 3 : GraphQL スキーマの作成タイミング
最後に AWS AppSync の GraphQL スキーマ作成のタイミングを確認してみました。
`conversations` テーブルに `status` というカラムを追加してみました。

```sql
ALTER TABLE conversations ADD COLUMN status VARCHAR(100);
```
これで、 AWS AppSync コンソール側でスキーマの反映ができれば...と思いましたが、現時点 (2024 年 10 月 2 日) 時点ではスキーマの反映は GraphQL API を新規作成するタイミングだけでした。作成された `schema.graphql` に手を加えている場合 (例えば、別のデータソースを接続した、など)、上書きされてしまうリスクもあるので、あまり必要ではないのかなと思いました。ここはぜひ色んな方の意見も伺いたいです。

## まとめ

今回は、AWS AppSync の RDS Data API を使用して Amazon Aurora Serverless v2 (MySQL) クラスター内のデータベースに対して[イントロスペクション](https://graphql.org/learn/introspection/)を行うことで、検出したテーブルに適合した GraphQL API のインターフェイスを作成する機能を試してみました。遂に、Aurora の任意のエンジン、クラスタータイプで RDS Data API を使って GraphQL API を爆速構築できるようになったことは嬉しいですね。