[Spring GraphQL] Schema Mapping Inspection でリゾルバの実装漏れを検出する

# 環境
- java: 17
- spring-core: 6.0.1
- spring-boot: 3.1.3
- spring-graphql: 1.2.2


# GraphQL API の 2 つの実装アプローチ
GraphQL API の実装方法には大きく分けて `Schema First` と `Code First` があります。

Schema First は最初にスキーマを定義し、それに合わせて実装を用意していくアプローチ、 
Code First は関数やリゾルバを実装し、そのシグネチャやメタ情報からスキーマを自動生成するようなアプローチです。

両アプローチの良し悪しは本記事の主題から逸れるので他サイトに譲ります。
- https://www.apollographql.com/blog/backend/architecture/schema-first-vs-code-only-graphql/
- https://blog.logrocket.com/code-first-vs-schema-first-development-graphql/


# Spring GraphQL の辛い点
[spring-graphql](https://github.com/spring-projects/spring-graphql) は Schema First のアプローチを採用しています。
開発に際しては、まず *.graphql(s) ファイルでスキーマを定義し、それに合わせて java でリゾルバを実装していくことになります。
Schema First の宿命 (?) として辛いのが「**リゾルバの実装漏れ**」です。

例えば、 ユーザ一覧を返す `Query.users` というフィールドを定義し、対応するリゾルバが未実装のままサーバを起動しても、そのリゾルバにアクセスするまで未実装であることに気付けません。

実行しなくてもコンパイラが色々な問題を教えてくれるというのが静的型付け言語の強みなのに、実行してみるまで問題に気付けないというのはいささか残念な感じです。

この問題に対して、私はこれまで [graphql-autotest](https://github.com/bitjourney/graphql-autotest) を使って全リゾルバを網羅したクエリを自動生成し、E2E テストの一環として実行することで問題を検出してきました。
ただこの方法、 not-null な項目はアクセスしたときにエラーになるので気付けるんですが、nullable な項目はサーバから null が返ってくるだけなので実装漏れに気付けず、問題を完全にカバーしきれていませんでした。

# Schema Mapping Inspection
SpringBoot 3.x へのアップグレードを行った際に spring-graphql のドキュメントを見返していて気付いたのですが、 v1.2 で Schema Mapping Inspection という機能が追加されました。 ([元になった Issue#386](https://github.com/spring-projects/spring-graphql/issues/386))

https://spring.pleiades.io/spring-graphql/docs/current/reference/html/#execution.graphqlsource.schema-mapping-inspection

サーバの起動時にスキーマ・実装間で乖離している部分をレポートとして受け取れる `GraphQlSource.Builder#inspectSchemaMappings` という API が追加されました。
ドキュメントによると union だったり、 `java.lang.Object` や `List<?>` のようにシグネチャの戻り値から GraphQL 側の型との対応を判断できないリゾルバは正しく判定できないようですが、テストでカバーしなくても漏れをレポートしてくれるというのは、ありがたい機能です。

# Schema Mapping Inspection を使ってみる
### 設定
この機能は `GraphQlSource.Builder` の `inspectSchemaMappings` にコールバックを設定することで動作します。

```java:Config.java
@Configuration(proxyBeanMethods = false)
@Slf4j
public class Config {
  @Bean
  public GraphQlSourceBuilderCustomizer customizer() {
    return builder -> {
      builder.inspectSchemaMappings(report -> {
        if (report.unmappedFields().isEmpty() && report.unmappedRegistrations().isEmpty()) {
          return;
        }
        report.unmappedFields().forEach(f -> {
          log.error("実装が足りません！！ {}.{}", f.getTypeName(), f.getFieldName());
        });
        report.unmappedRegistrations().forEach((f, d) -> {
          final String desc = switch (d) {
            case SelfDescribingDataFetcher<?> sdf -> sdf.getDescription();
            default -> null;
          };
          log.error("スキーマ定義が足りません!! {}.{} ({})", f.getTypeName(), f.getFieldName(), desc);
        });
        // 問題がある場合はサーバを異常終了させる
        throw new RuntimeException("マッピングが不完全です!!");
      });
    };
  }
}
```

### お試し API
設定は整ったので、お試しで適当な GraphQL API を実装してみます。

```graphql:user.graphql
type User {
  id: ID!
  name: String!
  address: String
  foo: String # 実験: この field に対応する resolver 実装を意図的に欠落させる
}

type Query {
  users: [User!]!
}
```

```java:User.java
public record User(String id, String name, String address) {
}
```

```java:UserController.java
@Controller
public class UserController {
  @QueryMapping
  public Flux<User> users() {
    return Flux.fromIterable(List.of(
        new User("u1", "taro", "tokyo"),
        new User("u2", "jiro", "osaka"),
        new User("u3", "saburo", null)
    ));
  }

  // 実験: Schema 側に存在しない resolver を生やしてみる
  @QueryMapping
  public Mono<User> userBy(@Argument String id) {
    return Mono.just(new User("u1", "taro", "tokyo"));
  }
}
```

## 実行結果 (抜粋)
```
2023-08-27T20:53:08.374+09:00 [ INFO] Loaded 1 resource(s) in the GraphQL schema.
2023-08-27T20:53:08.438+09:00 [ERROR] 実装が足りません！！ User.foo
2023-08-27T20:53:08.439+09:00 [ERROR] スキーマ定義が足りません!! Query.userBy (UserController#userBy[1 args])
2023-08-27T20:53:08.440+09:00 [ WARN] Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'graphQlSource' defined in class path resource [org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.class]: Failed to instantiate [org.springframework.graphql.execution.GraphQlSource]: Factory method 'graphQlSource' threw exception with message: マッピングが不完全です!!
```

実装が足りないリゾルバ、スキーマが未定義のリゾルバ、両方とも検出してくれました！

# まとめ
spring-graphql v1.2 で追加された Schema Mapping Inspection を使ってリゾルバの実装漏れを検出する方法を紹介しました。
今は検知できないと言われている一部のパターンもいずれはサポートされることを期待したいですね。
現状は graphql-autotest で作った E2E テストとあわせて相互に補完し合いながら運用しようと思います。