🐱

Pothos GraphQLのカスタムプラグインを作成してsemanticNonNullを使用できるようにする

に公開

今回はGraphQLの実験的な仕様であるsemanticNonNullをPothos GraphQLで実現するためにカスタムプラグインを自作する方法を紹介します。

GraphQLのNullabilityの問題点

GraphQLでは以下のようにフィールドに ! を付けることで、そのフィールドがNonNull(nullを返さない)であることを明示できます。

type User implements Node {
  id: String!
  name: String!
}

しかしこの場合、たとえば nameがサーバー側の予期しない問題によってnullになった場合、GraphQLの仕様上「nullの伝播」が発生し、User型の値全体がnullとして扱われてしまいます。

そのため、UIを部分的にでも表示したい場合は、次のようにnullableとして定義することが一般的です。

type User implements Node {
  id: String!
  name: String # nullを許容
}

このようにしておけば、nameがnullになってもUser全体がnullになることはなく、UIの一部は正常に描画され続けます。

ただしこの手法にも欠点があります。
クライアント側ではすべてのフィールドに対して逐一nullチェックを行う必要があり、記述が冗長になってしまうのです。

こうした課題に対する解決策として、GraphQLの実験的な仕様である @semanticNonNull が提案されています。

semanticNonNullの基本仕様

  • ドメイン上、値がnullになることは本来あり得ないことを明示する
  • サーバー側でエラーが発生した場合に限り、nullが返される可能性がある
  • クライアント側でこのフィールドをどう扱うか(エラーを投げるか、nullチェックを行うか)を選択可能
  • levelsを指定することでネストされたフィールドのnullabilityを制御可能

より詳細な仕様は以下の資料が参考になるかと思います。

https://youtu.be/kVYlplb1gKk

つまり、以下のようなschemaを用意できれば、Relay側でsemanticNonNullを正しく解釈することができそうです。

directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
}

type User implements Node {
  id: ID!
  name: String @semanticNonNull
}

semanticNonNullのディレクティブを定義する

仕様に従って以下のようにディレクティブを定義します。

src/directives/semantic-non-null.ts
import { GraphQLDirective, GraphQLList, GraphQLInt, DirectiveLocation } from 'graphql';

export const semanticNonNullDirective = new GraphQLDirective({
  name: 'semanticNonNull',
  locations: [DirectiveLocation.FIELD_DEFINITION],
  args: {
    levels: {
      type: new GraphQLList(GraphQLInt),
      defaultValue: [0],
    },
  },
});

schemaにdirectiveを登録します。

src/schema.ts
import { builder } from './builder';
import { semanticNonNullDirective } from './directives/semantic-non-null';

export const schema = builder.toSchema({
  directives: [semanticNonNullDirective]
});

出力したschemaにdirectiveを書き出せるようにprintSchemaWithDirectivesを設定します。

src/yoga.ts
import { schema } from './schema';
import { createYoga } from 'graphql-yoga';
import { lexicographicSortSchema } from 'graphql';
import { writeFileSync } from 'fs';
import { printSchemaWithDirectives } from '@graphql-tools/utils'

// ./schema.graphqlを出力
writeFileSync('./schema.graphql', printSchemaWithDirectives(lexicographicSortSchema(schema), {
  pathToDirectivesInExtensions: ['semanticNonNull'],
}));

export const yoga = createYoga({
  graphqlEndpoint: '/',
  fetchAPI: {
    fetch,
    Request,
    ReadableStream,
    Response,
  },
  schema,
});

動作検証のため、Pothosのbuilderを作成します。

src/builder.ts
import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';

export const builder = new SchemaBuilder({
  plugins: [RelayPlugin],
});

const User = builder.objectRef<{
  name: string | undefined;
}>('User');

builder.node(User, {
  id: {
    resolve: () => `User:1`,
  },
  fields: (t) => ({
    name: t.field({
      type: 'String',
      resolve: () => {
        // 動作検証のためにダミーのエラーをthrow
        throw new Error('User name is not available');
      },
    }),
  }),
  loadOne: async () => ({
    name: undefined,
  }),
});

builder.queryType();

Honoを使用してサーバーを起動できるようにします。

src/index.ts
import { yoga } from './yoga';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';

const app = new Hono();

app.mount('/graphql', yoga);

serve(
  {
    fetch: app.fetch,
    port: Number(3000),
  },
  (info) => {
    console.log(`Server is running on ${info.port}`);
  }
);

サーバーを立ち上げると、以下のようなschemaが出力されているはずです。

schema.graphql
schema {
  query: Query
}

directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
}

type User implements Node {
  id: ID!
  name: String
}

プラグインを作成し、フィールドのoptionとしてsemanticNonNullを指定できるようにする

Pothos GraphQLではDirective Pluginを活用することで自由にディレクティブを使用できますが、今回は以下のようにフィールドのオプションとしてsemanticNonNullを指定したいため、カスタムプラグインを作成し、フィールドオプションを拡張します。

User.implement({
  fields: (t) => ({
    name: t.exposeString('name', {
      semanticNonNull: true, // or { levels: [0] }
    }),
  }),
});

Pothos GraphQLのカスタムプラグインの作成方法を確認する

公式ドキュメントによれば、最低限、以下のようなファイル構成を用意すればプラグインを作成することができそうです。

└── plugin-semantic-non-null
    ├── global-types.ts # PothosのSchemaTypeの型拡張
    ├── index.ts # プラグインの実装
    └── types.ts # グローバルではない型

プラグインの雛形を準備する

実際の挙動は後回しにして、まずはプラグインをPothosで読み込むために雛形を作成します。

プラグインの実態を定義します。

src/plugin-semantic-non-null/index.ts
import SchemaBuilder, {
  BasePlugin,
  type PothosOutputFieldConfig,
  type SchemaTypes,
} from '@pothos/core';

const name = 'semanticNonNull' as const;

export default name;

export class SemanticNonNullPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {}

SchemaBuilder.registerPlugin(name, SemanticNonNullPlugin);

PothosSchemaTypesの拡張でsemanticNonNullというプラグインを使用可能にしておきます。

src/plugin-semantic-non-null/global-types.ts
import type { SchemaTypes } from '@pothos/core';
import type { SemanticNonNullPlugin } from '.';

declare global {
  export namespace PothosSchemaTypes {
    export interface Plugins<Types extends SchemaTypes> {
      semanticNonNull: SemanticNonNullPlugin<Types>;
    }
  }
}

SchemaBuilderで読み込みます。

src/builder.ts
 import SchemaBuilder from '@pothos/core';
+import DirectivePlugin from '@pothos/plugin-directives';
 import RelayPlugin from '@pothos/plugin-relay';
+import SemanticNonNullPlugin from './plugin-semantic-non-null';
 
 export const builder = new SchemaBuilder({
-  plugins: [RelayPlugin],
+  // CustomDirectiveを使用するにはDirectivePluginも必要であるため追加しておく
+  plugins: [RelayPlugin, DirectivePlugin, SemanticNonNullPlugin],
 });

現状、何も変化しませんが、ひとまずプラグインを読み込んでもエラーが発生しないことがわかります。

PothosのFieldOptionsの型を拡張する

公式ドキュメント内部実装を確認すると、FieldOptionsの型を拡張することで任意のプロパティを受け取ることができそうです。

MutationFieldOptionsやQueryFieldOptionsは全てFieldOptionsから派生しているため、FieldOptions側を拡張すれば全てのフィールドで任意のプロパティを受け取ることができます。

型の拡張方法がわかったため、global-types.tsで以下のようにsemanticNonNullをプロパティとして受け取れるようにします。

src/plugin-semantic-non-null/global-types.ts
 import type { SchemaTypes } from '@pothos/core';
 import type { SemanticNonNullPlugin } from '.';
+import type { SemanticNonNullArgs } from './types';
 
 declare global {
   export namespace PothosSchemaTypes {
     export interface Plugins<Types extends SchemaTypes> {
       semanticNonNull: SemanticNonNullPlugin<Types>;
     }
 
+    export interface FieldOptions {
+      semanticNonNull?: SemanticNonNullArgs;
+    }
   }
 }
src/plugin-semantic-non-null/types.ts
export type SemanticNonNullArgs =
  | boolean
  | {
      levels: number[];
    };

これで、以下のようにsemanticNonNullのオプションをfieldの定義時に指定できるようになりました。

src/builder.ts
 builder.node(User, {
   id: {
     resolve: () => `User:1`,
   },
   fields: (t) => ({
     name: t.field({
       type: 'String',
+      semanticNonNull: true,
       resolve: () => {
         // 動作検証のためにダミーのエラーをthrow
         throw new Error('User name is not available');
       },
     }),
   }),
   loadOne: async () => ({
     name: undefined,
   }),
});

fieldにdirectiveを付与する

現状のままではsemanticNonNullのオプションを指定してもfield側にディレクティブは付与されません。

公式ドキュメントを確認すると、Pothosのライフサイクルにプラグイン側からフックすることができることがわかります。
onOutputFieldConfigをオーバーライドし、フィールドのdirectiveのオプションを追加すれば良さそうです。

fieldConfigのpothosOptionsを確認すると、先ほどtrueにしたsemanticNonNullがtrueになっていることがconsoleからわかります。

src/plugin-semantic-non-null/index.ts
 export class SemanticNonNullPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
+  override onOutputFieldConfig(
+    fieldConfig: PothosOutputFieldConfig<Types>
+  ): PothosOutputFieldConfig<Types> | null {
+    console.log(fieldConfig.pothosOptions)
+
+    return fieldConfig;
+  }
 }
console.logの結果
{
  semanticNonNull: true,
  type: 'String',
  extensions: { pothosExposedField: 'name' },
  resolve: [Function: resolve]
}

Pothosのdirectiveは通常の方法で指定した場合、fieldConfig.extensions.directivesに格納されるため、pothosOptionsのsemanticNonNullが渡された場合にdirectivesにpushするように実装します。

今回はフィールド側でエラーをthrowしていますが、必要に応じてwrapResolveでnullチェックを行うことも可能です。

src/plugin-semantic-non-null/index.ts
 import SchemaBuilder, {
   BasePlugin,
+  type PothosOutputFieldConfig,
   type SchemaTypes,
 } from '@pothos/core';
+import type { SemanticNonNullArgs } from './types';
 
 const name = 'semanticNonNull' as const;
 
 export default name;
 
 export class SemanticNonNullPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
   override onOutputFieldConfig(
     fieldConfig: PothosOutputFieldConfig<Types>
   ): PothosOutputFieldConfig<Types> | null {
-    console.log(fieldConfig.pothosOptions)
+    const semanticNonNullArgs = fieldConfig.pothosOptions.semanticNonNull;
+
+    if (!semanticNonNullArgs) return fieldConfig;
+
+    // 他のdirectivesが指定されていない場合は配列ごと追加する
+    if (!Array.isArray(fieldConfig.extensions?.directives)) {
+      fieldConfig.extensions = {
+        ...fieldConfig.extensions,
+        directives: [this.transformDirective(semanticNonNullArgs)],
+      };
+    } else {
+      // 既存のdirectivesがある場合はpushする
+      fieldConfig.extensions?.directives.push(this.transformDirective(semanticNonNullArgs));
+    }
 
     return fieldConfig;
   }
+
+  // フィールドの解決時にプラグイン側でnullチェックを行うことも可能
+  // override wrapResolve(
+  //   resolver: GraphQLFieldResolver<unknown, Types['Context'], object>,
+  //   fieldConfig: PothosOutputFieldConfig<Types>
+  // ): GraphQLFieldResolver<unknown, Types['Context'], object> {
+  //   if (!fieldConfig.pothosOptions.semanticNonNull) {
+  //     return resolver;
+  //   }
+
+  //   return async (parent, args, context, info) => {
+  //     const result = await resolver(parent, args, context, info);
+
+  //     if (result === null || result === undefined) {
+  //       throw new Error(
+  //         `Field "${info.fieldName}" is non-nullable but received null or undefined.`)
+  //     }
+
+  //     return result;
+  //   };
+  // }
+
+  // semanticNonNull: trueなどの場合はデフォルト値を設定できるようにargsの変換関数を用意
+  private transformDirective(args: SemanticNonNullArgs) {
+    if (typeof args === 'boolean') return { name, args: {} };
+ 
+    return { name, args };
+  };
 }
 
 SchemaBuilder.registerPlugin(name, SemanticNonNullPlugin);

これでプラグインの実装が完了しました。  
出力されたschemaを確認すると、@semanticNonNullが正しくフィールドに適用されていることがわかります。

schema.graphql
 schema {
   query: Query
 }
 
 directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION
 
 interface Node {
   id: ID!
 }
 
 type Query {
   node(id: ID!): Node
   nodes(ids: [ID!]!): [Node]!
 }
 
 type User implements Node {
   id: ID!
-  name: String
+  name: String @semanticNonNull
 }

Relayで動作検証をする

Relayでは@semanticNonNullをサポートしており、非nullフィールドをどのように取り扱うかを@throwOnFieldErrorで制御することができます。

例えば、以下のようにuserフィールドの取得時に@throwOnFieldErrorを指定することで、@semanticNonNullが指定されたフィールドの細かなnullチェックをスキップすることができます。

フィールドがエラーとなった場合にRelay Runtimeがエラーをthrowする可能性があるため、別途ErrorBoundaryでの制御が必要となります。

import { graphql, useFragment } from 'react-relay';
import type { UserFragment$key } from './__generated__/UserFragment.graphql';

type Props = {
  user: UserFragment$key;
};

export const User = ({ user }: Props) => {
  const data = useFragment(
    graphql`
      fragment UserFragment on User @throwOnFieldError {
        name
      }
    `,
    user
  );

  return <div>{data.name}</div>;
};

@throwOnFieldError未指定
@throwOnFieldError指定

@throwOnFieldErrorを指定した場合はnameがNonNullとなっていることがわかります。

今回の実装サンプルは以下のリポジトリからご覧いただけます。

以上!

Discussion