🙆‍♀️

GPT-4に6時間でGraphQLサーバーのGeneratorを実装させてみた

2023/03/17に公開

こちらで、こりゃイケるわーってなったので、もうちょっと突っ込んだ実装を試してみました。
https://zenn.dev/masaori/articles/e7e5e38953bc9b

やったこと

  1. ドメインモデルをtypescriptの型として用意
  2. それをパースしたASTからEntityの定義を生成
  3. そこから以下をGenerateするスクリプトを書くようにお願い
    • GraphQLスキーマ
    • AppSyncをデプロイするCDKスタック

ポイントは、GraphQLスキーマを生成させたのではなく、Entityの型セットからスキーマを生成するGeneratorを実装させたというところです。
これがあれば、他のプロダクトにもGraphQLサーバーが実際に動くところまでスクリプト一発で作れます。要はAmplifyの再発明?

紆余曲折のGPTさんとのやり取りの細かい話はこの辺でしゃべってますので、お時間ある方はこちらをご参考くださいませ!
https://www.youtube.com/watch?v=NYZwCYRCoPU

成果

とりま、色々すっとばして成果だけ述べます。

インプット

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

ログインするとコメントできます