🛠️

HasuraとGraphQL Codegen(TypeScript)のこだわり設定

2023/02/01に公開

はじめに

先日、Hasura User Group Tokyo Meetup #2のTalk 2にて登壇してきたのですが、うまく資料に起こせず会場にてデモをする形で発表してしまったので、内容も拡張した上で改めて記事に書き起こしました。

Hasuraの本番運用経験を網羅的にまとめた「本番運用できるHasuraの組み立て方」という記事/本の一部になる予定です。

想定読者

  • HasuraとTypeScriptを採用しようとしている
    • すでに採用しているが、生成されたコードにちょっと違和感を感じる
    • TypeScriptならcamelCaseなコードを書きたい!
  • GraphQL Codegenの設定を見たい

概要とTL;DR

ハンズオンリポジトリ

このリポジトリをクローンすれば、この記事に書いてある内容を一通り手元で試せます。
一部の挙動は記事内の差分を適用しないと再現できません(異常系など)。

遊び心でTurborepoを使って組んでみてます。見辛かったらごめんなさい。

https://github.com/NamedPython/hasura-codegen-config-samples

HasuraおよびPostgreSQL DBをDocker Composeで組んでいますので起動しておくとハンズオンが進めやすいです。

git clone https://github.com/NamedPython/hasura-codegen-config-samples
❯ cd hasura-codegen-config-samples

❯ pnpm i
❯ docker compose up -d

Hasura CLI

実はHasura CLIをうまく使おうとすると少し面倒な設定をしなければいけません。
そこで、ハンズオンリポジトリにはショートハンドを定義してありますので、記事内のhasura *系のコマンドはpnpm run hasura:dev *として実行すると面倒なく実行できます。

cd apps/hasura

❯ pnpm run hasura:dev version

> hasura@0.0.0 hasura:dev /Users/namedpython/workspaces/playground/hasura-codegen-configs/apps/hasura
> hasura --envfile node_modules/dev-envs/.dev.hasura.env --skip-update-check "version"

INFO hasura cli                                    version=v2.17.0
INFO hasura graphql engine                         endpoint="http://localhost:8080" version=v2.17.1-ce

コンセプト

階層によってHasuraへリクエストするロールが変わるというのがポイントです。
ログイン・非ログイン問わず、管理画面以外のページはviewerでアクセスすることを想定しています。

  • viewerロールでのアクセス
    • /: トップページ
    • /[readableId].[tagLine]: ユーザーのプロフィールページ
      • 例: /namedpython.a2fx8
    • /[readableId].[tagLine]/[slug]: 記事ページ
      • 例: /namedpython.a2fx8/hasura-codegen-practices
  • authorロールでのアクセス
    • /my, /my/*: 自分の記事ダッシュボード

権限をだいぶ細かく設定してはいますが、簡単にいうと以下のような感じ。

  • author
    • 自分が所有しているコンテンツ(記事、アカウント情報)のみ、幅広くアクセス可能
    • いいねできない
  • viewer
    • 自分が所有していないコンテンツも参照できるが、アクセス可能なカラムは絞られている
    • いいねができる

データ構造

サンプル用なのにちょっと時間かけすぎましたので、手書きの図も供養します。
色が薄くなっているところはコードに含んでいません。ノイズです。

デフォルト設定でCodegen

以下のような設定をデフォルトとして進めていきます。ハンズオンリポジトリのapps/hasura-defaultがこのような状況です。

  • Hasura
  • codegen.ts
    • ヘッダの指定は x-hasura-admin-secret のみ
    • x-hasura-role 指定なし
    • namingConvention 指定なし
    • scalars 指定なし
    • 生成物
      • src/graphql/types.ts
        • introspectionによって得られる型情報
      • src/pages/**/*.generated.ts

ハンズオンリポジトリを現在位置とした前提で、以下のコマンドで試せます。

cd apps/hasura-default

❯ pnpm run hasura:default
> hasura-default@0.0.0 codegen:default /Users/namedpython/workspaces/playground/hasura-codegen-configs/apps/hasura-default
> graphql-codegen --require dotenv/config --config codegen-default.ts dotenv_config_path=node_modules/dev-envs/.dev.hasura.env

-- read schema from: http://localhost:8080/v1/graphql
-- read schema from: http://localhost:8080/v1/graphql
-- read schema from: http://localhost:8080/v1/graphql
✔ Parse Configuration
✔ Generate outputs

そうすると、以下のような結果が得られます。一部抜粋しながらお気持ちをコメントで書きます。

apps/hasura-default/src/graphql/types.ts
// ...
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  timestamptz: any;             // ---- anyとして出ている
  uuid: any;
};

// ...

/** columns and relationships of "post_status" */
export type Post_Status = {     // ---- PascalCaseとsnake_caseが混ざる
  __typename?: 'post_status';
  key: Scalars['String'];
  label: Scalars['String'];
  /** An array relationship */
  posts: Array<Post>;
  /** An aggregate relationship */
  posts_aggregate: Post_Aggregate;
};

// ...

export enum Post_Status_Enum {  // ---- 取り回しづらいenumで生成される
  /** 下書き */
  Draft = 'DRAFT',
  /** 公開済み */
  Published = 'PUBLISHED',
  /** 公開予約済み */
  PublishScheduled = 'PUBLISH_SCHEDULED'
}

次の生成物に行く前に、今回のサンプルにおけるPostの権限設定を載せておきます。

簡単にいうと、viewerにはpublish_at(公開予定日)のSELECT権限を持たせていません。
ですが、このサンプルではviewerロールでアクセス予定のトップページのクエリにpublish_atが含まれています。

apps/hasura-default/src/pages/index.gql
query latest10Posts {
  post(limit: 10, order_by: {published_at: desc_nulls_last}, offset: 0) {
    slug
    title
    publish_at # viewerにはSELECT権限がない
    author {
      id
      label_name
    }
    likes_aggregate {
      aggregate {
        count
      }
    }
  }
}

ですが、実行に成功してしまっており、以下のように型も生成されています。

apps/hasura-default/src/pages/index.generated.ts
import * as Types from '../graphql/types';

import { gql } from '@urql/core';
import * as Urql from 'urql';
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type Latest10PostsQueryVariables = Types.Exact<{ [key: string]: never; }>;


export type Latest10PostsQuery = { __typename?: 'query_root', post: Array<{ __typename?: 'post', slug: string, title: string, publish_at?: any | null, author: { __typename?: 'people', id: any, label_name?: string | null }, likes_aggregate: { __typename?: 'post_liked_aggregate', aggregate?: { __typename?: 'post_liked_aggregate_fields', count: number } | null } }> };


export const Latest10PostsDocument = gql`
    query latest10Posts {
  post(limit: 10, order_by: {published_at: desc_nulls_last}, offset: 0) {
    slug
    title
    publish_at
    author {
      id
      label_name
    }
    likes_aggregate {
      aggregate {
        count
      }
    }
  }
}
    `;

export function useLatest10PostsQuery(options?: Omit<Urql.UseQueryArgs<Latest10PostsQueryVariables>, 'query'>) {
  return Urql.useQuery<Latest10PostsQuery, Latest10PostsQueryVariables>({ query: Latest10PostsDocument, ...options });
};

微妙な点まとめ

  • 権限設定を無視して生成できてしまう
    • 権限設定に違反しているクエリは弾きたい
  • PascalCasesnake_caseが混ざる
    • ちゃんとTypeScriptっぽく生成されて欲しい
  • anyが出力されている
    • せめてunknownにしたい
  • 取り回しづらいenumで生成される
    • suin氏著、サバイバルTypeScriptでも、enumの問題点について触れられています
    • できれば避けたい

という具合になります。記事の後半でこれらを解決していきます。

権限設定に沿って生成する

x-hasura-roleをリクエストヘッダに入れてCodegenをすることで、該当ロールの権限でアクセスできる範囲のみのコードを生成できます。

以下のような感じ(一部抜粋)。ハンズオンリポジトリではapps/hasura-default/codegen-role.tsや、apps/graphql-default/codegen-role.tsに既に記述済みです。

apps/hasura-default/codegen-role.ts
 const hasuraSchema = (
+  role = "admin",
   endpoint = process.env.HASURA_GRAPHQL_ENDPOINT,
   secret = process.env.HASURA_GRAPHQL_ADMIN_SECRET
 ): Types.UrlSchemaWithOptions => {
   const hasuraSchemaEndpoint = `${endpoint}/v1/graphql`;
   const schemaObj: Types.UrlSchemaWithOptions = {};
 
-  console.log(`-- read schema from: ${hasuraSchemaEndpoint}`);
+  console.log(`-- read schema as ${role} from: ${hasuraSchemaEndpoint}`);
 
   schemaObj[hasuraSchemaEndpoint] = {
     headers: {
-      "x-hasura-admin-secret": secret || ""
+      "x-hasura-admin-secret": secret || "",
+      "x-hasura-role": role,
     },
   };
 
   return schemaObj;
 };
 
 // ...
 
 const config: CodegenConfig = {
 // ...
   generates: {
     "./src/graphql/types.ts": {
       plugins: ["typescript"],
       schema: [hasuraSchema()],
     },
     "./src/": {
       documents: ["src/**/*.gql", "!src/pages/my/**/*.gql"],
-      schema: [hasuraSchema()],
+      schema: [hasuraSchema("viewer")],
       preset: "near-operation-file",
       presetConfig: {
         extension: ".generated.ts",
         baseTypesPath: "graphql/types.ts",
       },
       plugins: ["typescript-operations", "typescript-urql"],
     },
     "./src/pages/my/": {
       documents: "src/pages/my/**/*.gql",
-      schema: [hasuraSchema()],
+      schema: [hasuraSchema("author")],
       preset: "near-operation-file",
       presetConfig: {
         extension: ".generated.ts",
         baseTypesPath: "graphql/types.ts",
       },
       plugins: ["typescript-operations", "typescript-urql"],
     },
   },
 };

 // ...

YAMLでCodegen設定を書くことに慣れていた方にはちょっと見慣れないかもですが、TypeScriptで設定を書くとこんなことができます。
さて、準備はできたので実行してみましょう。失敗するのが正解です。

pnpm run codegen:role

> hasura-default@0.0.0 codegen:role /Users/namedpython/workspaces/playground/hasura-codegen-configs/apps/hasura-default
> graphql-codegen --require dotenv/config --config codegen-role.ts dotenv_config_path=node_modules/dev-envs/.dev.hasura.env

-- read schema as admin from: http://localhost:8080/v1/graphql
-- read schema as viewer from: http://localhost:8080/v1/graphql
-- read schema as author from: http://localhost:8080/v1/graphql
✔ Parse Configuration
⚠ Generate outputs
  ✔ Generate to ./src/graphql/types.ts
  ❯ Generate to ./src/
    ✔ Load GraphQL schemas
    ✔ Load GraphQL documents
    ✖ Unable to validate GraphQL document!
      File /Users/namedpython/workspaces/playground/hasura-cod…
      Unable to find field "publish_at" on type "post"!
  ✔ Generate to ./src/pages/my/
 ELIFECYCLE  Command failed with exit code 1.

ちゃんと失敗してくれましたね。これで一安心。
生成を完了させたい場合は、該当のクエリを削除すればOK!

apps/hasura-default/src/pages/index.gql
 query latest10Posts {
   post(limit: 10, order_by: {published_at: desc_nulls_last}, offset: 0) {
     slug
     title
-    publish_at
     author {
       id
       label_name
     }
     likes_aggregate {
       aggregate {
         count
       }
     }
   }
 }

TypeScriptの記法に合わせたコードを生成する

さて、権限の問題は解決したので次は記法が気持ち悪い問題を解消していきます。
この問題の解決方法は大きく2つあります。

という感じです。

Aの手段が出てきたのは嬉しいニュースなんですが、既にHasuraを運用している身からすると「既にあるコードベース直すのめんど...」という感じなので、筆者が採用経験があるのはB、本当はAでやりたいという感じです。

この記事では、これから採用する方向けにサクッとAのやり方と生成結果をお見せして、その後にBのやり方を丁寧に説明していきます。

A: HasuraのNaming Convention(Experimental)を使う

ハンズオンリポジトリを見ている方は、apps/graphql-default/に生成結果が入っています。

まずは有効化の方法から。
公式のガイドに従えばできますので簡単にいきます。

  1. Hasura起動時の環境変数にHASURA_GRAPHQL_EXPERIMENTAL_FEATURES: naming_conventionを指定しコンテナを立ち上げ直す
  2. hasura consoleでコンソール(localhost:9695, not :8080)を立ち上げる
  3. Hasuraにデータベースを接続する際のオプションからhasura-default / graphql-defaultを選択
  4. Connect Database

以上の手順を踏むと、metadataのファイルとして永続化されます。つまり、以下のYAMLを書き換えてhasura metadata applyするだけでGraphQLで提供されるスキーマの命名規則が変わります。

apps/hasura/metadata/databases/databases.yaml
- name: default
  kind: postgres
  configuration:
    connection_info:
      database_url:
        from_env: HASURA_GRAPHQL_DATABASE_URL
      isolation_level: read-committed
      use_prepared_statements: true
  customization:
    naming_convention: graphql-default          # ココ
  tables: "!include default/tables/tables.yaml"

この状態でHasuraコンソールのAPI Explorerを見ると、「もうこれでよくね...?」くらい記法が整った感じになります。

さらにCodegenもして、types.tsも覗いてみますがこれも最高...(enumを除く)

apps/graphql-default/src/graphql/types.ts
// ...
/** columns and relationships of "post_status" */
export type PostStatus = {
  __typename?: 'PostStatus';
  key: Scalars['String'];
  label: Scalars['String'];
  /** An array relationship */
  posts: Array<Post>;
  /** An aggregate relationship */
  postsAggregate: PostAggregate;
};

// ...

export enum PostStatusEnum {
  /** 下書き */
  Draft = 'DRAFT',
  /** 公開済み */
  Published = 'PUBLISHED',
  /** 公開予約済み */
  PublishScheduled = 'PUBLISH_SCHEDULED'
}

ということで、今からHasuraとTypeScriptを採用するなら間違いなく有効化して良さそうな機能になっています。

B: HasuraのCustom field name, Custom root field nameとCodegenのnamingConventionで頑張る

さて、問題の 「頑張る」 ほうのやり方です。

軽く概要をいうと、

  • フィールド名はHasuraのCustom field nameで頑張る
  • Query root名、Mutation root名はCustom root field nameで頑張る
  • camelCase, PascalCase, snake_caseの問題はCodegenのnamingConventionで頑張る

という感じです。
コンソールからやる方法を丁寧にスクリーンショットで解説してもいいんですが、それは添付したリンク先に任せて、metadataに出てくる差分を提示しながら進めていきます。

まずCustom field nameですが、これを設定すると次のような差分が出てきます。

apps/hasura/metadata/databases/default/tables/public_post.yaml
 table:
   name: post
   schema: public
+configuration:
+  column_config:
+    status_key:
+      custom_name: statusKey
+    publish_at:
+      custom_name: publishAt
+    published_at:
+      custom_name: publishedAt
+    created_at:
+      custom_name: createdAt
+    updated_at:
+      custom_name: updatedAt
+  custom_column_names:
+    status_key: statusKey
+    publish_at: publishAt
+    published_at: publishedAt
+    created_at: createdAt
+    updated_at: updatedAt
+  custom_root_fields: {}
 object_relationships:
 # ...

そしてさらにCustom root field nameを設定すると以下のような差分がでます。

apps/hasura/metadata/databases/default/tables/public_post.yaml
 table:
   name: post
   schema: public
 configuration:
   column_config:
     status_key:
       custom_name: statusKey
   custom_column_names:
     status_key: statusKey
-  custom_root_fields: {}
+  custom_root_fields:
+    delete: deletePost
+    delete_by_pk: deletePostByPk
+    insert: insertPost
+    insert_one: insertPostOne
+    select: post
+    select_aggregate: postAggregate
+    select_by_pk: postByPk
+    select_stream: postStream
+    update: updatePost
+    update_by_pk: updatePostByPk
+    update_many: updateManyPost
 object_relationships:
 # ...

というような調子で他のテーブルの設定もしていき、hasura metadata applyで適用するとそれっぽくなってきます。

それでも設定できる部分には限界があり、order_byや、likes_aggregateは取り残されてしまいます。

apps/hasura-default/src/pages/index.gql
query latest10Posts {
  post(limit: 10, order_by: {publishedAt: desc_nulls_last}) {
    slug
    author {
      id
      labelName
    }
    likes_aggregate {
      aggregate {
        count
      }
    }
  }
}
apps/hasura-default/src/pages/my/posts/index.gql
query my10PostsEach {
  post(limit: 10, order_by: {publishedAt: desc_nulls_last}, offset: 0) {
    slug
    title
    publishAt
    publishedAt
    author {
      id
      labelName
    }
    likes_aggregate {
      aggregate {
        count
      }
    }
  }
}

この状態でCodegenをしてtypes.tsを見てみますが、ここはまだ気持ち悪さが残ります。

apps/hasura-default/src/graphql/types.ts
// ...
/** columns and relationships of "post_status" */
export type Post_Status = {
  __typename?: 'post_status';
  key: Scalars['String'];
  label: Scalars['String'];
  /** An array relationship */
  posts: Array<Post>;
  /** An aggregate relationship */
  posts_aggregate: Post_Aggregate;
};

// ...

export enum Post_Status_Enum {
  /** 下書き */
  Draft = 'DRAFT',
  /** 公開済み */
  Published = 'PUBLISHED',
  /** 公開予約済み */
  PublishScheduled = 'PUBLISH_SCHEDULED'
}

// ...

Custom field name, Custom root field nameでできる範囲は終わったので、いよいよCodegenのnamingConventionで頑張っていきます。

設定するものは以下3つ!

  • enumValues
  • typeNames
  • transformUnderscore

以下のようにcodegen.tsに設定します。ハンズオンリポジトリを見ている方はapps/hasura-default/codegen-role-naming.tsに既に記述してあります。

apps/hasura-default/codegen-role-naming.ts
 const hasuraSchema = (
   role = "admin",
   endpoint = process.env.HASURA_GRAPHQL_ENDPOINT,
   secret = process.env.HASURA_GRAPHQL_ADMIN_SECRET
 ): Types.UrlSchemaWithOptions => {
   // ...
 };
 
 const config: CodegenConfig = {
   config: {
     skipTypename: false,
     withHooks: true,
     withHOC: false,
     withComponent: false,
     gqlImport: "@urql/core#gql",
+    namingConvention: {
+      enumValues: 'change-case-all#pascalCase',
+      typeNames: 'change-case-all#pascalCase',
+      transformUnderscore: true,
+    },
   },

change-case-all#pascalCaseという値は、「change-case-allというパッケージのpascalCaseメソッドで変換するよ」という指定になります。

デフォルトで change-case-all は使えるようです。

この状態でCodegenすると、ようやく求めていたような感じになってきます。

pnpm run codegen:role+naming
apps/hasura-default/src/graphql/types-naming.ts
// ...
/** columns and relationships of "post_status" */
export type PostStatus = {
  __typename?: 'post_status';
  key: Scalars['String'];
  label: Scalars['String'];
  /** An array relationship */
  posts: Array<Post>;
  /** An aggregate relationship */
  posts_aggregate: PostAggregate;
};

// ...

export enum PostStatusEnum {
  /** 下書き */
  Draft = 'DRAFT',
  /** 公開済み */
  Published = 'PUBLISHED',
  /** 公開予約済み */
  PublishScheduled = 'PUBLISH_SCHEDULED'
}

// ...

これにて頑張るほうのやり方は終了です。
transformUnderscoreのオプションに関しては、graphql-defaultなHasuraに対しても有効なので覚えておくと幸せになります。

enumの問題はこの次の章で解消させます。

モダンなTypeScriptを生成する

章のネーミングセンスが足りず大仰なことを言っていますが、要は以下二つを実現します。

  • TypeScriptのenumを使わずにGraphQL Enumのコードを生成する
  • anyになってしまう型をうまくマッピングし、フォールバックをunknownにする

TypeScriptのenumを使わずにGraphQL Enumのコードを生成する

やはりこの界隈の方々はこの問題に苦労していたようで、CodegenのenumsAsConstオプションひとつで解決できるようになっています。

apps/hasura-default/codegen-role-naming-enum.ts
 const hasuraSchema = (
   role = "admin",
   endpoint = process.env.HASURA_GRAPHQL_ENDPOINT,
   secret = process.env.HASURA_GRAPHQL_ADMIN_SECRET
 ): Types.UrlSchemaWithOptions => {
   // ...
 };
 
 const config: CodegenConfig = {
   config: {
     skipTypename: false,
     withHooks: true,
     withHOC: false,
     withComponent: false,
     gqlImport: "@urql/core#gql",
     namingConvention: {
       enumValues: 'change-case-all#pascalCase',
       typeNames: 'change-case-all#pascalCase',
       transformUnderscore: true,
     },
+    enumsAsConst: true,
   },

さて、Codegenしてみましょう

pnpm run codegen:role+naming+enum
// ...

export const PostStatusEnum = {
  /** 下書き */
  Draft: 'DRAFT',
  /** 公開済み */
  Published: 'PUBLISHED',
  /** 公開予約済み */
  PublishScheduled: 'PUBLISH_SCHEDULED'
} as const;

export type PostStatusEnum = typeof PostStatusEnum[keyof typeof PostStatusEnum];

// ...

ということで、めでたくas constによるオブジェクトリテラルが生成されました。
定数としても使えるし、型もあるしサイコー!

引用したサバイバルTypeScriptのページで代替案2として提案されているものになります。

anyになってしまう型をうまくマッピングし、フォールバックをunknownにする

そのまんまです。scalarsdefaultScalarTypeを指定することで実現します。

apps/hasura-default/codegen-role-naming-enum-scalars.ts
 const hasuraSchema = (
   role = "admin",
   endpoint = process.env.HASURA_GRAPHQL_ENDPOINT,
   secret = process.env.HASURA_GRAPHQL_ADMIN_SECRET
 ): Types.UrlSchemaWithOptions => {
   const hasuraSchemaEndpoint = `${endpoint}/v1/graphql`;
   const schemaObj: Types.UrlSchemaWithOptions = {};
 
   console.log(`-- read schema as ${role} from: ${hasuraSchemaEndpoint}`);
 
   schemaObj[hasuraSchemaEndpoint] = {
     headers: {
       "x-hasura-admin-secret": secret || "",
       "x-hasura-role": role,
     },
   };
 
   return schemaObj;
 };
 
 const config: CodegenConfig = {
   config: {
     skipTypename: false,
     withHooks: true,
     withHOC: false,
     withComponent: false,
     gqlImport: "@urql/core#gql",
     namingConvention: {
       enumValues: 'change-case-all#pascalCase',
       typeNames: 'change-case-all#pascalCase',
       transformUnderscore: true,
     },
     enumsAsConst: true,
+    scalars: {
+      uuid: 'string',
+      bigint: 'number',
+      date: 'string',
+      timestamptz: 'string',
+    },
+    defaultScalarType: 'unknown'
+  },

さぁ、Codegenしてみましょう。

pnpm run codegen:role+naming+enum+scalars
apps/graphql-default/src/graphql/types-naming-enum-scalars.ts
// ...
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  timestamptz: string;
  uuid: string;
};

// ...

変化は大きくないですが、anyがなくなり、指定した型でマッピングされています。
defaultScalarTypeの恩恵は表出していませんが、今後データベースに新しいかつ未マッピングの型のカラムが追加された際に、unknownとして出力してくれます(jsonb型とか)。

おわりに

お疲れ様でした(自分も)。

記事のまとめは、序盤に書いた概要とTL;DRが担っていますので俯瞰したい方はスクロールアップ!

Zennに書く1記事目がこんなに長くなる予定はなかったので、読まれない記事になってしまった予感...

とはいえ2023年はサボっていたアウトプットを頑張る、という約束を師匠と交わしたので頑張っていきます(1ヶ月に1記事、というのをギリギリオーバーしているのはヒミツ㊙️)。

Hasuraや周辺のNode.js系バックエンドとの繋ぎ込み、AWSでのホスティングは経験あるので質問ある方はコメントやTwitter DM(オープンです)まで!

Discussion