HasuraとGraphQL Codegen(TypeScript)のこだわり設定
はじめに
先日、Hasura User Group Tokyo Meetup #2のTalk 2にて登壇してきたのですが、うまく資料に起こせず会場にてデモをする形で発表してしまったので、内容も拡張した上で改めて記事に書き起こしました。
Hasuraの本番運用経験を網羅的にまとめた「本番運用できるHasuraの組み立て方」という記事/本の一部になる予定です。
想定読者
- HasuraとTypeScriptを採用しようとしている
- すでに採用しているが、生成されたコードにちょっと違和感を感じる
- TypeScriptならcamelCaseなコードを書きたい!
- GraphQL Codegenの設定を見たい
概要とTL;DR
-
x-hasura-role
を指定して、権限に沿わないCodegenを弾く- ≒ 権限に沿ったコードを生成する
- TypeScriptの記法に合わせたコードを生成する
- A: HasuraのNaming Convention(Experimental)を使う
- B: HasuraのCustom field name, Custom root field nameとCodegenの
namingConvention
で頑張る
- モダンなTypeScriptを生成する
- enumsAsConstを指定して、Enumをモダンに取り扱う
- scalars, defaultScalarTypeを指定して、GraphQLの型をうまくTSの世界とマッピングする
- INSERT Mutationの型はNot NULLを表現していないので注意する
- Ref: How to mark certain columns as required fields in the generated GraphQL schema
- 「Issue読んでください!」以上のことはないので、記事では触れません
ハンズオンリポジトリ
このリポジトリをクローンすれば、この記事に書いてある内容を一通り手元で試せます。
一部の挙動は記事内の差分を適用しないと再現できません(異常系など)。
遊び心でTurborepoを使って組んでみてます。見辛かったらごめんなさい。
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
-
Naming Convention(Experimental) 指定なし
- デフォルトは
hasura-default
になります
- デフォルトは
- Custom field name 指定なし
-
Naming Convention(Experimental) 指定なし
-
codegen.ts
- ヘッダの指定は
x-hasura-admin-secret
のみ -
x-hasura-role
指定なし -
namingConvention
指定なし -
scalars
指定なし - 生成物
-
src/graphql/types.ts
- introspectionによって得られる型情報
-
src/pages/**/*.generated.ts
- 各
.gql
ファイルの近くにHooksのコードを生成します(near-operation-file-preset
)
- 各
-
- ヘッダの指定は
ハンズオンリポジトリを現在位置とした前提で、以下のコマンドで試せます。
❯ 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
そうすると、以下のような結果が得られます。一部抜粋しながらお気持ちをコメントで書きます。
// ...
/** 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
が含まれています。
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
}
}
}
}
ですが、実行に成功してしまっており、以下のように型も生成されています。
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 });
};
微妙な点まとめ
- 権限設定を無視して生成できてしまう
- 権限設定に違反しているクエリは弾きたい
-
PascalCaseとsnake_caseが混ざる
- ちゃんとTypeScriptっぽく生成されて欲しい
-
any
が出力されている- せめて
unknown
にしたい
- せめて
- 取り回しづらいenumで生成される
という具合になります。記事の後半でこれらを解決していきます。
権限設定に沿って生成する
x-hasura-role
をリクエストヘッダに入れてCodegenをすることで、該当ロールの権限でアクセスできる範囲のみのコードを生成できます。
以下のような感じ(一部抜粋)。ハンズオンリポジトリではapps/hasura-default/codegen-role.ts
や、apps/graphql-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!
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のNaming Convention(Experimental)を使う
- B: HasuraのCustom field name, Custom root field nameとCodegenの
namingConvention
で頑張る
という感じです。
Aの手段が出てきたのは嬉しいニュースなんですが、既にHasuraを運用している身からすると「既にあるコードベース直すのめんど...」という感じなので、筆者が採用経験があるのはB、本当はAでやりたいという感じです。
この記事では、これから採用する方向けにサクッとAのやり方と生成結果をお見せして、その後にBのやり方を丁寧に説明していきます。
Naming Convention(Experimental)を使う
A: Hasuraのハンズオンリポジトリを見ている方は、apps/graphql-default/
に生成結果が入っています。
まずは有効化の方法から。
公式のガイドに従えばできますので簡単にいきます。
- Hasura起動時の環境変数に
HASURA_GRAPHQL_EXPERIMENTAL_FEATURES
:naming_convention
を指定しコンテナを立ち上げ直す -
hasura console
でコンソール(localhost:9695
, not:8080
)を立ち上げる - Hasuraにデータベースを接続する際のオプションから
hasura-default
/graphql-default
を選択 - Connect Database
以上の手順を踏むと、metadataのファイルとして永続化されます。つまり、以下のYAMLを書き換えてhasura metadata apply
するだけでGraphQLで提供されるスキーマの命名規則が変わります。
- 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を除く)
// ...
/** 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を採用するなら間違いなく有効化して良さそうな機能になっています。
Custom field name, Custom root field nameとCodegenのnamingConvention
で頑張る
B: Hasuraのさて、問題の 「頑張る」 ほうのやり方です。
軽く概要をいうと、
- フィールド名はHasuraのCustom field nameで頑張る
- Query root名、Mutation root名はCustom root field nameで頑張る
- camelCase, PascalCase, snake_caseの問題はCodegenの
namingConvention
で頑張る
という感じです。
コンソールからやる方法を丁寧にスクリーンショットで解説してもいいんですが、それは添付したリンク先に任せて、metadataに出てくる差分を提示しながら進めていきます。
まずCustom field nameですが、これを設定すると次のような差分が出てきます。
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を設定すると以下のような差分がでます。
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は取り残されてしまいます。
query latest10Posts {
post(limit: 10, order_by: {publishedAt: desc_nulls_last}) {
slug
author {
id
labelName
}
likes_aggregate {
aggregate {
count
}
}
}
}
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
を見てみますが、ここはまだ気持ち悪さが残ります。
// ...
/** 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
に既に記述してあります。
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
// ...
/** 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
オプションひとつで解決できるようになっています。
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
にする
そのまんまです。scalars
とdefaultScalarType
を指定することで実現します。
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
// ...
/** 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