StepZen で PostgreSQL データを GraphQL API としてデプロイする

# はじめに
本記事では、[StepZen](https://stepzen.com/) を用いて PostgreSQL のデータを GraphQL API に変換する方法について解説します。
StepZen は GraphQL や REST API を簡単に連携、統合できる GraphQL API 構築サービスであり、複数のデータソースを一つのエンドポイントにまとめることが可能です。GraphQL スキーマの作成とデータの連携がとても容易で、これによりフロントエンド開発者はバックエンドの詳細を意識することなく、必要なデータにアクセスできます。

ここでの検証は、StepZenの公式ドキュメント（[Getting started with a PostgreSQL database using @dbquery custom directive](https://stepzen.com/docs/quick-start/with-database-postgresql)）を参考にモックの読み取り専用データベースを用いて行いました。
実際に試した際に気づいた点、感じた点を整理したいと思います。

# 想定読者
GraphQLのディレクティブ等を知っている方を想定しています。

参考
[Directives - Apollo GraphQL Docs](https://www.apollographql.com/docs/apollo-server/schema/directives/)

# 使用するデータベースの構成
本記事で使用するデータベースは、下記接続情報で接続できます。

```
host: postgresql.introspection.stepzen.net
database name: introspection
username: testUserIntrospection
password: HurricaneStartingSample1934
```

また、使用するデータベースは、顧客（customer）、住所（address）、注文（order）、商品（products）、顧客と住所の関連を管理する中間テーブル（customeraddress）、注文と商品の間の関連を管理する中間テーブル（lineitem）の 6つのテーブルから構成されています。各テーブルは以下のリレーションで関連しています。

顧客（customer）と住所（address）は多対多のリレーションで、中間テーブル（customeraddress）を通じて結びついています。
顧客（customer）と注文（oder）は1対多のリレーション。
注文（order）と商品（product）は多対多のリレーションで、中間テーブル（lineitem）を通じて結びついています。

以下の図に、ER図を示します。

![Screenshot 2023-07-02 at 22.54.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184221/8406b385-7cb5-888a-70e4-33a62e1f3e69.png)

# StepZen CLIのインストールとセットアップ
StepZen の公式ドキュメント（[StepZen Install and Set Up](https://stepzen.com/docs/quick-start/install-and-setup)）に沿って、まずはStepZen CLIをインストールします。Node.jsがインストールされていれば、`npm install -g stepzen`でインストールできます。次に、StepZenのアカウントを作成し、ダッシュボードからAdmin Keyを取得し、`stepzen login`コマンドでログインします。



# StepZen を用いた GraphQL API の作成

StepZen の公式ドキュメント（[Getting started with a PostgreSQL database using @dbquery custom directive](https://stepzen.com/docs/quick-start/with-database-postgresql)）に沿って、`stepzen import postgresql`コマンドで、データベースのスキーマ情報からGraphQLの構成ファイルを生成します。

下記のような質問に対して、データベースの接続情報等を対話的に回答していきます。
```
? What would you like your endpoint to be called? api/opining-condor
? What is your host? postgresql.introspection.stepzen.net
? What is the username? testUserIntrospection
? What is the password? [hidden]
? What is your database name? introspection
? What is your database schema (leave blank to use defaults)? 
? Automatically link types based on foreign key relationships using @materializer
 (https://stepzen.com/docs/features/linking-types) Yes
Starting... done
```

ここで、最後の質問である`? Automatically link types based on foreign key relationships using @materializer (https://stepzen.com/docs/features/linking-types)`にYesで答えることにより、外部キー制約によるリレーションを階層化されたGraphQL定義に反映させることができます。
上記コマンド実行後、下記ファイルが生成されます。

```tree
hello-stepzen
├── config.yaml
├── index.graphql
├── postgresql
│   └── index.graphql
└── stepzen.config.json
```

StepZenの設定ファイルとして、`stepzen.config.json`があり、エンドポイント名等が記載されます。

```stepzen.config.json
{
  "endpoint": "api/willing-dingo"
}
```

PostgreSQL接続用に`config.yaml`ファイルが生成されています。これは、後述する`postgresql/index.graphql`から参照されます。
```config.yaml
configurationset:
  - configuration:
      name: postgresql_config
      uri: postgresql://postgresql.introspection.stepzen.net/introspection?user=testUserIntrospection&password=HurricaneStartingSample1934
```

トップのGraphQLスキーマとして`index.graphql`が生成されています。`@sdl`ディレクティブを用いて他のスキーマファイルを指定することができます。StepZenが提供しているディレクティブについては、公式ドキュメント（[GraphQL Directives Reference @rest @dbquery @graphql @materializer](https://stepzen.com/docs/custom-graphql-directives)）に記載があります。

```index.graphql
schema @sdl(files: ["postgresql/index.graphql"]) {
  query: Query
}
```

PostgreSQLのテーブルに対応するGraphQLスキーマ`postgresql/index.graphql`が生成されています。本ファイルには、`@materializer`や`@dbquery`等のディレクティブが使われています。
StepZen を使用して作成された GraphQL API では、以下のようなクエリとミューテーションが生成されます。

* Query
  - id による個別の取得
  - List 全体の取得
  - PaginatedList 範囲指定による取得
  - ViaXXX 多対多リレーションのフィルタリング
  - UsingXXXfkey 外部キーによるフィルタリング
  - UsingXXXidx インデックス付きカラムによるフィルタリング
* Mutation
  - insert
  - delete
  - update

ただし、多対多のリレーションのテーブル（customeraddressやlineitem）を CRUD するエンドポイントはデフォルトでは生成されません。


<details><summary><b>postgresql/index.graphq (長いので折りたたみ)</b></summary><div>

```postgresql/index.graphql
type Address {
  city: String
  countryregion: String
  customer: [Customer] @materializer(query: "customerViaCustomeraddress")
  id: Int!
  postalcode: String
  stateprovince: String
  street: String
}

type Customer {
  address: [Address] @materializer(query: "addressViaCustomeraddress")
  email: String!
  id: Int!
  name: String!
  order: [Order] @materializer(query: "orderUsingOrder_customerid_fkey")
}

type Lineitem {
  order: Order @materializer(query: "orderUsingLineitem_orderid_fkey")
  orderid: Int!
  product: Product @materializer(query: "productUsingLineitem_productid_fkey")
  productid: Int!
  quantity: Int
}

type Order {
  carrier: String
  createdat: Date!
  customer: Customer @materializer(query: "customerUsingOrder_customerid_fkey")
  customerid: Int!
  id: Int!
  lineitem: Lineitem @materializer(query: "lineitemUsingLineitem_orderid_fkey")
  shippingcost: Float
  trackingid: String
}

type Product {
  description: String
  id: Int!
  image: String
  lineitem: Lineitem @materializer(query: "lineitem")
  title: String
}

"""
The following queries are just a set of examples of how to access your schema.
Feel free to modify them or aggregate more.
"""
type Query {
  " Queries for type 'Address' "
  address(id: Int!): Address
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "address"
      configuration: "postgresql_config"
    )
  addressList: [Address]
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "address"
      configuration: "postgresql_config"
    )
  addressPaginatedList(first: Int, after: Int): [Address]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "city", "countryregion", "id", "postalcode", "stateprovince", "street" FROM "address" ORDER BY "id" LIMIT $1 OFFSET $2
      """
      configuration: "postgresql_config"
    )
  customerViaCustomeraddress(id: Int!): [Customer]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT T."email", T."id", T."name"
        FROM "customer" T, "customeraddress" V
        WHERE
          V."addressid" = $1 AND
          V."customerid" = T."id"
      """
      configuration: "postgresql_config"
    )
  " Queries for type 'Customer' "
  addressViaCustomeraddress(id: Int!): [Address]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT T."city", T."countryregion", T."id", T."postalcode", T."stateprovince", T."street"
        FROM "address" T, "customeraddress" V
        WHERE
          V."customerid" = $1 AND
          V."addressid" = T."id"
      """
      configuration: "postgresql_config"
    )
  customer(id: Int!): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "customer"
      configuration: "postgresql_config"
    )
  customerList: [Customer]
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "customer"
      configuration: "postgresql_config"
    )
  customerPaginatedList(first: Int, after: Int): [Customer]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "email", "id", "name" FROM "customer" ORDER BY "id" LIMIT $1 OFFSET $2
      """
      configuration: "postgresql_config"
    )
  customerUsingOrder_customerid_fkey(customerid: Int!): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "email", "id", "name" FROM "customer" WHERE "id" = $1
      """
      configuration: "postgresql_config"
    )
  customerUsingcustomer_email_key(email: String!): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "email", "id", "name" FROM "customer" WHERE "email" = $1
      """
      configuration: "postgresql_config"
    )
  " Queries for type 'Lineitem' "
  lineitem(id: Int!): Lineitem
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "orderid", "productid", "quantity" FROM "lineitem" WHERE "productid" = $1
      """
      configuration: "postgresql_config"
    )
  lineitemList: [Lineitem]
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "lineitem"
      configuration: "postgresql_config"
    )
  lineitemPaginatedList(first: Int, after: Int): [Lineitem]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "orderid", "productid", "quantity" FROM "lineitem" LIMIT $1 OFFSET $2
      """
      configuration: "postgresql_config"
    )
  lineitemUsingLineitem_orderid_fkey(id: Int!): Lineitem
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "orderid", "productid", "quantity" FROM "lineitem" WHERE "orderid" = $1
      """
      configuration: "postgresql_config"
    )
  " Queries for type 'Order' "
  order(id: Int!): Order
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "order"
      configuration: "postgresql_config"
    )
  orderList: [Order]
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "order"
      configuration: "postgresql_config"
    )
  orderPaginatedList(first: Int, after: Int): [Order]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "carrier", "createdat", "customerid", "id", "shippingcost", "trackingid" FROM "order" ORDER BY "id" LIMIT $1 OFFSET $2
      """
      configuration: "postgresql_config"
    )
  orderUsingLineitem_orderid_fkey(orderid: Int!): Order
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "carrier", "createdat", "customerid", "id", "shippingcost", "trackingid" FROM "order" WHERE "id" = $1
      """
      configuration: "postgresql_config"
    )
  orderUsingOrder_customerid_fkey(id: Int!): [Order]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "carrier", "createdat", "customerid", "id", "shippingcost", "trackingid" FROM "order" WHERE "customerid" = $1
      """
      configuration: "postgresql_config"
    )
  " Queries for type 'Product' "
  product(id: Int!): Product
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "product"
      configuration: "postgresql_config"
    )
  productList: [Product]
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "product"
      configuration: "postgresql_config"
    )
  productPaginatedList(first: Int, after: Int): [Product]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "description", "id", "image", "title" FROM "product" ORDER BY "id" LIMIT $1 OFFSET $2
      """
      configuration: "postgresql_config"
    )
  productUsingLineitem_productid_fkey(productid: Int!): Product
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "description", "id", "image", "title" FROM "product" WHERE "id" = $1
      """
      configuration: "postgresql_config"
    )
  productUsingtitle_idx(title: String!): [Product]
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      SELECT "description", "id", "image", "title" FROM "product" WHERE "title" = $1
      """
      configuration: "postgresql_config"
    )
}

"""
The following mutations are just a set of examples of how to access your schema.
Feel free to modify them or aggregate more.
"""
type Mutation {
  " Mutations for type 'Address' "
  deleteAddress(id: Int!): Address
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "address"
      dml: DELETE
      configuration: "postgresql_config"
    )
  insertAddress(
    city: String
    id: Int!
    stateprovince: String
    postalcode: String
    street: String
    countryregion: String
  ): Address
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "address"
      dml: INSERT
      configuration: "postgresql_config"
    )
  updateAddress(
    id: Int!
    city: String
    countryregion: String
    postalcode: String
    stateprovince: String
    street: String
  ): Address
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      UPDATE "address" SET
        "city" = COALESCE($2, "city"),
        "countryregion" = COALESCE($3, "countryregion"),
        "postalcode" = COALESCE($4, "postalcode"),
        "stateprovince" = COALESCE($5, "stateprovince"),
        "street" = COALESCE($6, "street")
      WHERE
        "id" = $1
      RETURNING *
      """
      configuration: "postgresql_config"
    )
  " Mutations for type 'Customer' "
  deleteCustomer(id: Int!): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "customer"
      dml: DELETE
      configuration: "postgresql_config"
    )
  insertCustomer(name: String!, email: String!): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "customer"
      dml: INSERT
      configuration: "postgresql_config"
    )
  updateCustomer(id: Int!, email: String, name: String): Customer
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      UPDATE "customer" SET
        "email" = COALESCE($2, "email"),
        "name" = COALESCE($3, "name")
      WHERE
        "id" = $1
      RETURNING *
      """
      configuration: "postgresql_config"
    )
  " Mutations for type 'Lineitem' "
  deleteLineitem(quantity: Int!, productid: Int!, orderid: Int!): Lineitem
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "lineitem"
      dml: DELETE
      configuration: "postgresql_config"
    )
  insertLineitem(quantity: Int, productid: Int!, orderid: Int!): Lineitem
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "lineitem"
      dml: INSERT
      configuration: "postgresql_config"
    )
  " Mutations for type 'Order' "
  deleteOrder(id: Int!): Order
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "order"
      dml: DELETE
      configuration: "postgresql_config"
    )
  insertOrder(
    carrier: String
    customerid: Int!
    id: Int!
    shippingcost: Float
    trackingid: String
    createdat: Date!
  ): Order
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "order"
      dml: INSERT
      configuration: "postgresql_config"
    )
  updateOrder(
    id: Int!
    carrier: String
    createdat: Date
    customerid: Int
    shippingcost: Float
    trackingid: String
  ): Order
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      UPDATE "order" SET
        "carrier" = COALESCE($2, "carrier"),
        "createdat" = COALESCE($3, "createdat"),
        "customerid" = COALESCE($4, "customerid"),
        "shippingcost" = COALESCE($5, "shippingcost"),
        "trackingid" = COALESCE($6, "trackingid")
      WHERE
        "id" = $1
      RETURNING *
      """
      configuration: "postgresql_config"
    )
  " Mutations for type 'Product' "
  deleteProduct(id: Int!): Product
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "product"
      dml: DELETE
      configuration: "postgresql_config"
    )
  insertProduct(
    image: String
    description: String
    title: String
    id: Int!
  ): Product
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "product"
      dml: INSERT
      configuration: "postgresql_config"
    )
  updateProduct(
    id: Int!
    description: String
    image: String
    title: String
  ): Product
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      UPDATE "product" SET
        "description" = COALESCE($2, "description"),
        "image" = COALESCE($3, "image"),
        "title" = COALESCE($4, "title")
      WHERE
        "id" = $1
      RETURNING *
      """
      configuration: "postgresql_config"
    )
}
```
</div></details>

# APIをカスタマイズする際のポイント
StepZen では、生成された`postgresql/index.graphql`等を修正することでAPIをカスタマイズできます。`@dbquery`ディレクティブを使用して SQL を直接記述することにより、複雑なフィルタリングや結合を行うことが可能です。また、上記スキーマファイルに記載されているように `@materializer` ディレクティブを使用することで、階層化された GraphQL API の生成が可能になります。
詳しくは公式ドキュメント（[Getting started with a PostgreSQL database using @dbquery custom directive](https://stepzen.com/docs/quick-start/with-database-postgresql#extend-the-schema)）を参照ください。
これらのディレクティブを活用することで、API の振る舞いを細かくカスタマイズすることができます。

# 所感
調べた限りでは、StepZen ではバリデーションの処理ができないようです。しかし、スキーマに型を指定することが可能なため、副作用のないデータ取得の Query であれば問題ないと思われます。一方で、Mutation においてはバリデーションが使用できない点が課題となる可能性があります。これに対する一つの解決策としては、StepZenで生成するGraphQL APIは Query のみ使用する、というアプローチも考えられます。バリデーションの実装方法は引き続き調査したいと思います。

また、`@dbquery`や`@materializer`といったディレクティブを使用したカスタマイズは、多様な設定が容易に行えるため、大変便利です。これらの設定は `.graphql` ファイル内で記述できるため、独自の記法を覚える必要がないという点も利点と言えるでしょう。また、宣言的に記載できるため、コードとして管理できる（IaC: Infrastructure as Code）点も交換を持てます。

# おわりに
以上、StepZen を用いた PostgreSQL データの GraphQL API への変換方法について解説しました。生成されたファイルの中身まで見ることで、StepZenの動きを理解できました。他にも有用そうな機能があるので、引き続き調査していこうと思います。