🙆♀️
GPT-4に6時間でGraphQLサーバーのGeneratorを実装させてみた
こちらで、こりゃイケるわーってなったので、もうちょっと突っ込んだ実装を試してみました。
やったこと
- ドメインモデルをtypescriptの型として用意
- それをパースしたASTからEntityの定義を生成
- そこから以下をGenerateするスクリプトを書くようにお願い
- GraphQLスキーマ
- AppSyncをデプロイするCDKスタック
ポイントは、GraphQLスキーマを生成させたのではなく、Entityの型セットからスキーマを生成するGeneratorを実装させたというところです。
これがあれば、他のプロダクトにもGraphQLサーバーが実際に動くところまでスクリプト一発で作れます。要はAmplifyの再発明?
紆余曲折のGPTさんとのやり取りの細かい話はこの辺でしゃべってますので、お時間ある方はこちらをご参考くださいませ!
成果
とりま、色々すっとばして成果だけ述べます。
インプット
entityTypes.ts
// entityTypes.ts
export type User = { id: string; name: string };
export type Task = { id: string; description: string };
export type Assignment = { id: string; userId: User['id']; taskId: Task['id'] };
export type Organization = { id: string; name: string };
export type Group = { id: string; organizationId: Organization['id']; name: string };
export type Belonging = { id: string; userId: User['id']; groupId: Group['id'] };
User['id']
みたいな書き方にすることで、参照があることを明示しています。
Generatorの実行結果
schema.graphql
type Query {
user(id: ID!): UserResult
users(limit: Int, nextToken: String): UserConnectionResult
task(id: ID!): TaskResult
tasks(limit: Int, nextToken: String): TaskConnectionResult
assignment(id: ID!): AssignmentResult
assignments(limit: Int, nextToken: String): AssignmentConnectionResult
organization(id: ID!): OrganizationResult
organizations(limit: Int, nextToken: String): OrganizationConnectionResult
group(id: ID!): GroupResult
groups(limit: Int, nextToken: String): GroupConnectionResult
belonging(id: ID!): BelongingResult
belongings(limit: Int, nextToken: String): BelongingConnectionResult
}
type Mutation {
createUser(input: CreateUserInput!): UserResult!
updateUser(id: ID!, input: UpdateUserInput!): UserResult!
deleteUser(id: ID!): UserResult!
createTask(input: CreateTaskInput!): TaskResult!
updateTask(id: ID!, input: UpdateTaskInput!): TaskResult!
deleteTask(id: ID!): TaskResult!
createAssignment(input: CreateAssignmentInput!): AssignmentResult!
updateAssignment(id: ID!, input: UpdateAssignmentInput!): AssignmentResult!
deleteAssignment(id: ID!): AssignmentResult!
createOrganization(input: CreateOrganizationInput!): OrganizationResult!
updateOrganization(
id: ID!
input: UpdateOrganizationInput!
): OrganizationResult!
deleteOrganization(id: ID!): OrganizationResult!
createGroup(input: CreateGroupInput!): GroupResult!
updateGroup(id: ID!, input: UpdateGroupInput!): GroupResult!
deleteGroup(id: ID!): GroupResult!
createBelonging(input: CreateBelongingInput!): BelongingResult!
updateBelonging(id: ID!, input: UpdateBelongingInput!): BelongingResult!
deleteBelonging(id: ID!): BelongingResult!
}
type User {
id: String!
name: String!
assignments: [Assignment!]!
belongings: [Belonging!]!
}
type Task {
id: String!
description: String!
assignments: [Assignment!]!
}
type Assignment {
id: String!
userId: String!
taskId: String!
user: User!
task: Task!
}
type Organization {
id: String!
name: String!
groups: [Group!]!
}
type Group {
id: String!
organizationId: String!
name: String!
organization: Organization!
belongings: [Belonging!]!
}
type Belonging {
id: String!
userId: String!
groupId: String!
user: User!
group: Group!
}
type PermissionError {
code: String!
message: String!
}
type UnkownRuntimeError {
code: String!
message: String!
}
type NotFoundError {
code: String!
message: String!
}
union UserResult = User | PermissionError | UnkownRuntimeError | NotFoundError
type UserConnectionResult {
items: [UserResult]
nextToken: String
totalCount: Int
}
union TaskResult = Task | PermissionError | UnkownRuntimeError | NotFoundError
type TaskConnectionResult {
items: [TaskResult]
nextToken: String
totalCount: Int
}
union AssignmentResult =
Assignment
| PermissionError
| UnkownRuntimeError
| NotFoundError
type AssignmentConnectionResult {
items: [AssignmentResult]
nextToken: String
totalCount: Int
}
union OrganizationResult =
Organization
| PermissionError
| UnkownRuntimeError
| NotFoundError
type OrganizationConnectionResult {
items: [OrganizationResult]
nextToken: String
totalCount: Int
}
union GroupResult = Group | PermissionError | UnkownRuntimeError | NotFoundError
type GroupConnectionResult {
items: [GroupResult]
nextToken: String
totalCount: Int
}
union BelongingResult =
Belonging
| PermissionError
| UnkownRuntimeError
| NotFoundError
type BelongingConnectionResult {
items: [BelongingResult]
nextToken: String
totalCount: Int
}
graphql-api-stack.ts
import * as cdk from "@aws-cdk/core";
import * as appsync from "@aws-cdk/aws-appsync";
import * as ddb from "@aws-cdk/aws-dynamodb";
import * as iam from "@aws-cdk/aws-iam";
import { generateEntityDefinitions } from "../scripts/generateEntityDefinitions";
export class GraphqlApiStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const api = new appsync.GraphqlApi(this, "Api", {
name: "example-api",
schema: appsync.Schema.fromAsset("lib/graphql/schema.graphql"),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.API_KEY,
apiKeyConfig: {
expires: cdk.Expiration.after(cdk.Duration.days(365)),
},
},
},
xrayEnabled: true,
});
new cdk.CfnOutput(this, "GraphQLAPIURL", {
value: api.graphqlUrl,
});
new cdk.CfnOutput(this, "GraphQLAPIKey", {
value: api.apiKey || "",
});
const dynamoDbAppSyncRole = new iam.Role(this, "DynamoDbAppSyncRole", {
assumedBy: new iam.ServicePrincipal("appsync.amazonaws.com"),
});
// Mapping templates
const getById = () =>
appsync.MappingTemplate.dynamoDbGetItem("id", `ctx.args.id`);
const getList = () => appsync.MappingTemplate.dynamoDbScanTable();
const create = () =>
appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id").auto(),
appsync.Values.projecting("input")
);
const update = () =>
appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id").is("ctx.args.id"),
appsync.Values.projecting("input")
);
const deleteItem = () =>
appsync.MappingTemplate.dynamoDbDeleteItem("id", `ctx.args.id`);
const entityDefinitions = generateEntityDefinitions();
for (const entity of entityDefinitions) {
const table = new ddb.Table(this, `${entity.name}Table`, {
partitionKey: { name: "id", type: ddb.AttributeType.STRING },
billingMode: ddb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
});
dynamoDbAppSyncRole.addToPolicy(
new iam.PolicyStatement({
actions: [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan",
],
resources: [table.tableArn],
})
);
const dataSource = new appsync.DynamoDbDataSource(
this,
`${entity.name}DataSource`,
{
api: api,
name: `${entity.name}DataSource`,
table: table,
serviceRole: dynamoDbAppSyncRole,
}
);
const responseMappingTemplateItem = appsync.MappingTemplate.fromString(`
## If there's an error, return UnknownRuntimeError
#if($ctx.error)
$util.qr($ctx.result.put("__typename", "UnknownRuntimeError"))
$util.qr($ctx.result.put("message", "An unknown error occurred during runtime."))
$util.qr($ctx.result.put("details", $ctx.error))
$util.toJson($ctx.result)
#else
## If the result is not found, return NotFoundError
#if(!$ctx.result)
$util.qr($ctx.result.put("__typename", "NotFoundError"))
$util.qr($ctx.result.put("message", "${entity.name} not found."))
$util.toJson($ctx.result)
#else
$util.qr($ctx.result.put("__typename", "${entity.name}"))
$util.toJson($ctx.result)
#end
#end
`);
// Query resolvers
api.createResolver({
typeName: "Query",
fieldName: `${entity.name.toLowerCase()}`,
dataSource: dataSource,
requestMappingTemplate: getById(),
responseMappingTemplate: responseMappingTemplateItem,
});
api.createResolver({
typeName: "Query",
fieldName: `${entity.name.toLowerCase()}s`,
dataSource: dataSource,
requestMappingTemplate: getList(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
// Mutation resolvers
api.createResolver({
typeName: "Mutation",
fieldName: `create${entity.name}`,
dataSource: dataSource,
requestMappingTemplate: create(),
responseMappingTemplate: responseMappingTemplateItem,
});
api.createResolver({
typeName: "Mutation",
fieldName: `update${entity.name}`,
dataSource: dataSource,
requestMappingTemplate: update(),
responseMappingTemplate: responseMappingTemplateItem,
});
api.createResolver({
typeName: "Mutation",
fieldName: `delete${entity.name}`,
dataSource: dataSource,
requestMappingTemplate: deleteItem(),
responseMappingTemplate: responseMappingTemplateItem,
});
}
}
}
やーすごい。立派。
再度言いますが、GPTさんはこれを直接実装したわけではなく、Generatorを書きました。大体6時間くらいで。
感想
- 自分で書いたら数日はかかってた気がするので、だいぶ生産性はいい
- そもそもASTパースするところで、
ts-morph
っていう僕の知らないライブラリ使ってたり、僕より優秀
次への課題
- MappingTemplateを書いてもらうのがむずい!エラーハンドルとかいいところまで来てますが、まだ調整必要。
- 僕がAppSyncの仕様とかしっかり把握してないとどう依頼していいかわかんなくなるので、僕がわの依頼力に工夫がまだまだ要りそう。
今回の実装の中で工夫したところとか、実際のgeneratorのコードがどんな塩梅になっているかとかは次の記事で書きます〜
Discussion