Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Spring for GraphQL の実践 #jjug_ccc #jjug_ccc_c / Spring for GraphQL in Practice

Spring for GraphQL の実践 #jjug_ccc #jjug_ccc_c / Spring for GraphQL in Practice

2024年6月16日開催の JJUG CCC 2024 Spring での @soranakkによる発表の資料です。

イベントページ:https://ccc2024spring.java-users.jp/

SMS tech

June 15, 2024
Tweet

More Decks by SMS tech

Other Decks in Technology

Transcript

  1. 自己紹介 © SMS Co.,Ltd. 空中 清高 そらなか きよたか @soranakk •

    株式会社エス・エム・エス カイポケ開発部 プロダクトプラットフォームチームEM • 2021年12月 カイポケのリニューアルプロジェクトにエンジニアとして入社 • Androidアプリ開発者から フロントエンドやバックエンド、インフラなどを開発するフルスタック エンジニア兼エンジニアマネージャーにジョブチェンジ
  2. GraphQL Gateway • Backend毎にSubgraphを提供する • GraphQL Gatewayで一つのGraph Schema(Supergraph)に統合 ◦ Frontendからは単一の

    GraphQL サーバーに見える • Subgraph をさらに分割するのもやりやすいのがポイント Apollo のドキュメント(Introduction to Apollo Federation) より https://www.apollographql.com/docs/federation/ © SMS Co.,Ltd.
  3. マルチレポで課題が見つかってきた • Frontendチーム ◦ ローカル開発時にサーバー側と繋ぎたい場合、複数のバックエンドを立てない といけない ◦ それぞれ別のリポジトリにあるので、複数のリポジトリを落としてきて環境構築 する必要がある •

    Product Platformチーム(アプリサーバー、Gateway、フロントエンドを開発) ◦ Product Platformチームのサーバーと全体共通の Gatewayサーバーを開発す るとき、それぞれ別のリポジトリだったので複数のリポジトリ管理が必要だった (GitHub Issue等諸々) ◦ フロントエンドも開発するチームだったので、フロントエンドチームと同じ問題が 発生(常に全リポジトリをチェックアウトしてきて、それぞれにコミットする、とか PRもそれぞれ見て回る必要アリとか ) © SMS Co.,Ltd.
  4. モノレポ化の工夫 • モノレポ移行はチーム毎に順番に ◦ 既にマルチレポで開発が始まっていたので順次移行の形を取りました • ブランチ名やPRのラベル、codeownersを使って、整理できるように ◦ codeownersでPRのreviewersを自動設定 ◦

    labelerを利用してコードの修正箇所によって自動的にラベル付け • デプロイはそれぞれのサービス単位で行えるように ◦ 新カイポケではデプロイは個別のサービス毎に行えるようにしています • GitHub Actions で sparse checkout の設定を行う ◦ CI時に全体をcheckoutしていると時間がかかるので特定のディレクトリだけ checkoutしてCIを高速化 © SMS Co.,Ltd.
  5. バックエンド構成 • Backend はドメイン毎に分割 • 各サーバーは Spring Framework で構成されている •

    各サーバーは Subgraph を Gateway に公開している • サーバー間通信用の GraphQL Schema も公開している © SMS Co.,Ltd.
  6. フロントエンド構成 • Frontend は UseCase 毎に分割 • 一つの Next.js で構成されていて、共通の

    UI ライブラリを使っている • Supergraph の GraphQL Schema からコードを生成している © SMS Co.,Ltd.
  7. スキーマ管理 • Backend毎にSubgraphを提供している • GraphQL Gatewayで一つのGraph Schema(Supergraph)に統合している • Frontend は

    Supergraph からコード生成している • Backend, Frontend のコードはモノレポで管理されている Apollo のドキュメント(Introduction to Apollo Federation) より https://www.apollographql.com/docs/federation/ © SMS Co.,Ltd.
  8. Supergraph の置き場 • モノレポ直下の schema フォルダに配置 • Backend の Subgraph

    や 合成した Supergraph を配置している • Supergraph の生成は rover supergraph compose コマンドで実行 ◦ https://www.apollographql.com/docs/rover/commands/supergraphs/ © SMS Co.,Ltd.
  9. rover supergraph compose • supergraph.yaml に Subgraph 情報を記載する • Subgraph

    の GraphQL Schema は一つのファイルになっている制約があるのに注意 ◦ それぞれの Subgraph は複数ファイルになっているので、まとめる前処理を別途行なっ ている © SMS Co.,Ltd.
  10. query { office(officeId: "xxx") { name # 事業所名だけ欲しい } }

    { "data": { "office": { "name": "〇〇オフィス" # 事業所名だけ返される } } } GraphQL の良い点 type Query { office(officeId: ID!): Office } type Office { id: ID! name: String! address: String! phoneNumber: String! } スキーマ定義 取得クエリ • チームをまたいだ共通言語として活用できる ◦ フロントエンドチームや PdM, QA とのコミュニケーションに使える • オーバーフェッチ問題をあまり気にしないで良い ◦ クライアントは必要とするデータのみを取得できる © SMS Co.,Ltd.
  11. オーバーフェッチ問題の解決 type Query { corporation(corporationId: ID!): Corporation } type Corporation

    { id: ID! name: String! address: String! phoneNumber: String! offices: [Office!] } type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … • Corporation のみ欲しい場合と、紐づいているOfficeも欲しい場合がある • どのくらい深い階層まで欲しいかはUIの事情によって変化する • GraphQLであればBackend側はそれをあまり意識せずに定義できる スキーマ定義 © SMS Co.,Ltd.
  12. Resolver の置き場 • Layered Architecture を採用していて Presentation 層に配置 • 認可アノテーション(後述)と

    Resolver の置き場を分けている • @Controller のメソッドがGraphQL Schemaにマッピングされる ◦ いくつかのアノテーションで GraphQL Schema とマッピングする © SMS Co.,Ltd.
  13. @QueryMapping • GraphQL の Query とマッピングするのが @QueryMapping ◦ 引数は @Argument

    で明示する • GraphQL Schema の名前とメソッド名、引数が一致していれば自動的にマッピング ◦ 名前が違う場合は QueryMapping や Argument の引数で指定する type Query { corporation(corporationId: ID!): Corporation } type Corporation { id: ID! name: String! address: String! phoneNumber: String! } スキーマ定義 @Controller class CorporationController () { @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A”, address = “東京都~~~”, phoneNumber = “03****++++” ) } コード © SMS Co.,Ltd.
  14. @MutationMapping • GraphQL の Mutation とマッピングするのが @MutationMapping ◦ 引数は @Argument

    で明示する • GraphQL Schema の名前とメソッド名、引数が一致していれば自動的にマッピング ◦ 名前が違う場合は MutationMapping や Argument の引数で指定する type Mutation { addCorporation(input: CorporationInput!): Corporation } input CorporationInput { name: String! address: String! phoneNumber: String! } スキーマ定義 @Controller class CorporationController () { @MutationMapping fun addCorporation(@Argument input: CorporationInput): Corporation? = Corporation( id = “1”, name = input.name, address = input.address, phoneNumber = input.phoneNumber ) } コード © SMS Co.,Ltd.
  15. オーバーフェッチ問題の解決 type Query { corporation(corporationId: ID!): Corporation } type Corporation

    { id: ID! name: String! address: String! phoneNumber: String! offices: [Office!] } type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … • Corporation のみ欲しい場合と、紐づいているOfficeも欲しい場合がある • どのくらい深い階層まで欲しいかはUIの事情によって変化する • GraphQLであればBackend側はそれをあまり意識せずに定義できる スキーマ定義 © SMS Co.,Ltd.
  16. @SchemaMapping • corporation の Query で offices を null で返しておく

    • @SchemaMapping で offices を部分取得できるようにマッピングする • GraphQL Schema で定義した名前とメソッド名が一致していれば自動的にマッピング ◦ 違う場合は SchemaMapping の引数で指定する コード @SchemaMapping fun offices(corporation: Corporation): List<Office> = map[corporation.id] val map = mapOf( “1” to listOf(Office(id = “1”), Office(id = “2”)), “2” to listOf(Office(id = “3”)), “3” to listOf(Office(id = “4”), Office(id = “5”)), … ) } @Controller class CorporationController () { @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A”, address = “東京都~~~”, phoneNumber = “03****++++”, offices = null ) © SMS Co.,Ltd.
  17. N + 1問題 • @SchemaMapping は Corporation 一つ一つを解決する時に呼び出される • GraphQL

    に 複数の Corporation を返すAPIがあると何度も呼び出されてしまう ◦ DBアクセスや他サービスの呼び出しがN回実行されてしまう コード @Controller class CorporationController () { @QueryMapping fun corporations(): List<Corporation>? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) @SchemaMapping fun offices(corporation: Corporation): List<Office> = map[corporation.id] val map = mapOf( “1” to listOf(Office(id = “1”), Office(id = “2”)), “2” to listOf(Office(id = “3”)), “3” to listOf(Office(id = “4”), Office(id = “5”)), … ) } © SMS Co.,Ltd.
  18. @BatchMapping • BatchMapping を使うと複数のCorporationの時も一度のロードで済む ◦ @SchemaMapping で DataLoader を使う書き方のショートカット版 •

    メソッド名と引数、戻り値でマッピングされる コード @Controller class CorporationController () { @QueryMapping fun corporations(): List<Corporation>? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) @BatchMapping fun offices(corporations: List<Corporation>): Map<Corporation, List<Office>?> = corporations.associateWith { corporation -> map[corporation.id] } val map = mapOf( “1” to listOf(Office(id = “1”), Office(id = “2”)), “2” to listOf(Office(id = “3”)), “3” to listOf(Office(id = “4”), Office(id = “5”)), … ) © SMS Co.,Ltd.
  19. オーバーフェッチ問題の解決 type Query { corporation(corporationId: ID!): Corporation } type Corporation

    { id: ID! name: String! address: String! phoneNumber: String! offices: [Office!] } type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … • Corporation のみ欲しい場合と、紐づいているOfficeも欲しい場合がある • どのくらい深い階層まで欲しいかはUIの事情によって変化する • GraphQLであればBackend側はそれをあまり意識せずに定義できる スキーマ定義 © SMS Co.,Ltd.
  20. 遅延ロードを使うかどうか • Corporationの取得時に必ず offices もリクエストされる場合は直接返した方がいい ◦ 一度のDBアクセスで全て手に入るので • 最適化したいならGraphQLリクエストを解析して使われ方を調べる必要あり @Controller

    class CorporationController () { @QueryMapping fun corporations(): List<Corporation>? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) コード @BatchMapping fun offices(corporations: List<Corporation>): Map<Corporation, List<Office>?> = corporations.associateWith { corporation -> map[corporation.id] } val map = mapOf( “1” to listOf(Office(id = “1”), Office(id = “2”)), “2” to listOf(Office(id = “3”)), “3” to listOf(Office(id = “4”), Office(id = “5”)), … ) © SMS Co.,Ltd.
  21. オーバーフェッチ問題の解決 type Query { corporation(corporationId: ID!): Corporation } type Corporation

    { id: ID! name: String! address: String! phoneNumber: String! offices: [Office!] } type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … • Corporation のみ欲しい場合と、紐づいているOfficeも欲しい場合がある • どのくらい深い階層まで欲しいかはUIの事情によって変化する • GraphQLであればBackend側はそれをあまり意識せずに定義できる スキーマ定義 © SMS Co.,Ltd.
  22. Federation • サーバーA の GraphQL Schema で定義した Corporation を サーバーB

    で拡張できる ◦ @key でオブジェクトを特定するプロパティを指定する type Query { corporation(corporationId: ID!): Corporation } type Corporation @key(fields: "id") { id: ID! name: String! address: String! phoneNumber: String! } type Corporation @key(fields: "id") { id: ID! offices: [Office!] } type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … サーバーAのスキーマ定義 サーバーBのスキーマ定義 © SMS Co.,Ltd.
  23. Federation • サーバーA側は拡張のことは何も知らないので、普通に実装する @Controller class CorporationController () { @QueryMapping fun

    corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A”, address = “東京都~~~”, phoneNumber = “03****++++” ) } サーバーAのコード © SMS Co.,Ltd.
  24. @EntityMapping • サーバーBでCorporationを拡張するため @EntityMapping を使う ◦ @Argument で @key で指定した

    id とマップさせる • offices は部分ロードの時と同じく SchemaMapping や BatchMapping が使える ◦ N + 1問題を回避するため BatchMapping がオススメ @Controller class OfficeController () { @EntityMapping fun corporation(@Argument id: String): Corporation = Corporation(id = id, offices = null) サーバーBのコード @BatchMapping fun offices(corporations: List<Corporation>): Map<Corporation, List<Office>?> = corporations.associateWith { corporation -> map[corporation.id] } val map = mapOf( Corporation(id = “1”) to listOf(Office(id = “1”), Office(id = “2”)), Corporation(id = “2”) to listOf(Office(id = “3”)), Corporation(id = “3”) to listOf(Office(id = “4”), Office(id = “5”)), … ) © SMS Co.,Ltd.
  25. @EntityMapping の利用について • EntityMapping は SpringBoot 3.3.0 からの機能なのに注意 ◦ Spring

    for GraphQL 1.3.0 で導入された • EntityMapping を利用するために拡張するサーバー側で設定が必要 ◦ FederationSchemaFactory を利用するようにしておけばOK @Configuration class GraphQLConfiguration { @Bean fun federationTransform(factory: FederationSchemaFactory): GraphQlSourceBuilderCustomizer { return GraphQlSourceBuilderCustomizer { builder -> builder.schemaFactory(factory::createGraphQLSchema) } } @Bean fun federationSchemaFactory(): FederationSchemaFactory { return FederationSchemaFactory() } } サーバーBのコード © SMS Co.,Ltd.
  26. DGS codegen • DGS (Domain Graph Service)は Netflix 社製の Spring

    と統合されたGraphQLライブラリ ◦ https://netflix.github.io/dgs/ • DGSのコード生成プラグイン部分を利用している ◦ https://netflix.github.io/dgs/generating-code-from-schema/ © SMS Co.,Ltd.
  27. DGS codegen利用方法 • GraphQLで定義した type などの class が生成される • クライアントコードの生成もできるが利用していない

    ◦ 過去はSpring for GraphQL と DGS が同居すると graphql-java の依存の競合が発生し ていた → 今はもう大丈夫かも? tasks.withType<GenerateJavaTask> { generateClient = false generateClientv2 = false packageName = "com.sms.generated.graphql" schemaPaths = mutableListOf("$rootDir/src/main/resources/graphql/") generatedSourcesDir = "$rootDir/build" language = "kotlin" } build.gradle © SMS Co.,Ltd.
  28. Apollo codegen • apollo 製のコード生成プラグイン • 内部通信で利用するGraphQL Clientコードの生成に利用している • GraphQL

    Documents の指定ができ、指定されたクエリーだけコード生成される apollo { service("hcs") { srcDir("${project.rootDir}/external/hcs") packageName.set("com.sms.generated.external.graphql.hcs") } } build.gradle © SMS Co.,Ltd.
  29. graphql-java-extended-validation • GraphQL Schema に validation のための Directive が書ける •

    制限に違反していたら例外が投げられる • GraphQL Schema に書いてあるので、クライアント側への共有もできる "郵便番号" postalCode: String! @Pattern(regexp: "^[0-9]+$") @Size(min: 7, max: 7) "都道府県" prefecture: String! @NotBlank @Size(min: 3, max: 4) スキーマ定義 © SMS Co.,Ltd.
  30. 導入方法 • gradle に依存を追加する • GraphQLConfiguration を追加する @Bean fun runtimeWiringConfigurer():

    RuntimeWiringConfigurer? { val validationRules = ValidationRules.newValidationRules() .build() val schemaWiring = ValidationSchemaWiring(validationRules) return RuntimeWiringConfigurer { wiringBuilder: Builder -> wiringBuilder.directiveWiring(schemaWiring) } } コード © SMS Co.,Ltd.
  31. 利用方法 • GraphQL Schema に直接、制限を書く • 他にも 数値用の Min, Max

    など、色々ある ◦ https://github.com/graphql-java/graphql-java-extended-validation "郵便番号" postalCode: String! @Pattern(regexp: "^[0-9]+$") @Size(min: 7, max: 7) "都道府県" prefecture: String! @NotBlank @Size(min: 3, max: 4) スキーマ定義 © SMS Co.,Ltd.
  32. エラーレスポンスの形式 • エラーが発生した時のレスポンス形式が揃っていないとクライアント側が辛い • Spring for GraphQL ではいくつかの例外発生場所があって、それぞれでハンドリングの仕方 が異なる ◦

    エラーのデータ形式を自分達で決めている場合は適切にやる必要がある { “data”: null, “errors”: [ { “message”: “適切なエラーメッセージ ” “extensions”: { “errorCode”: “BAD_REQUEST” “errorDetailCode”: “INPUT_VALIDATION_ERROR” } ] } © SMS Co.,Ltd.
  33. GraphQL Schema 解決前のエラーの種類 • graphql.ErrorType.InvalidSyntax, • graphql.ErrorType.ValidationError, • graphql.ErrorType.OperationNotSupported •

    graphql.ErrorType.DataFetchingException, • graphql.ErrorType.NullValueInNonNullableField, • graphql.ErrorType.ExecutionAborted BAD_REQUEST INTERNAL_ERROR © SMS Co.,Ltd.
  34. graphql-java-extended-validation のエラー • ResourceBundleMessageInterpolator を継承したクラスで処理する • RuntimeWiringConfigurer で graphql-java-extended-validation と一緒にセットする

    @Bean fun runtimeWiringConfigurer(messageInterpolator: MessageInterpolator): RuntimeWiringConfigurer? { val validationRules = ValidationRules.newValidationRules() .messageInterpolator(messageInterpolator) .build() val schemaWiring = ValidationSchemaWiring(validationRules) return RuntimeWiringConfigurer { wiringBuilder: Builder -> wiringBuilder.directiveWiring(schemaWiring) } } コード @Component class ValidationMessageInterpolator : ResourceBundleMessageInterpolator() { override fun interpolate( messageTemplate: String?, messageParams: MutableMap<String, Any>?, validationEnvironment: ValidationEnvironment? ): GraphQLError { return GraphqlErrorBuilder.newError() …(略) .build() } © SMS Co.,Ltd.
  35. Resolver にたどり着いた後のエラー • DataFetcherExceptionResolverAdapter を継承したクラスで処理する • resolveToSingleError で処理しなかったら(null を返したら)後続の DataFetcherExceptionResolver

    にチェインされる • 適用順を @Order で指定できるので優先順位を最低にして共通ライブラリ化している @Component @Order(Int.MAX_VALUE) class DefaultDataFetcherExceptionResolver : DataFetcherExceptionResolverAdapter() { override fun resolveToSingleError(ex: Throwable, env: DataFetchingEnvironment): GraphQLError? = when (ex) { is IllegalArgumentException -> … is AuthenticationException -> … is AccessDeniedException -> … else -> … } コード © SMS Co.,Ltd.
  36. ControllerAdvice と GraphQlExceptionHandler • DataFetcherExceptionResolverAdapter より優先して処理される • 従来の ControllerAdviceと同じように処理できる ◦

    @Controller の @GraphQlExceptionHandler は @ControllerAdvice より優先される @ControllerAdvice class GlobalExceptionHandler { @GraphQlExceptionHandler fun handle(ex: IllegalArgumentException, env: DataFetchingEnvironment): GraphQLError { return … } } @Controller class CorporationController () { @GraphQlExceptionHandler fun handle(ex: IllegalArgumentException, env: DataFetchingEnvironment): GraphQLError { return … } } コード © SMS Co.,Ltd.
  37. Method Security • GraphQL のエンドポイントは一つなのでPathベースの Web Security は利用できない • GraphQL

    のクエリーはメソッドにマッピングされるためMethod Security を利用する © SMS Co.,Ltd.
  38. PreAuthorize • Method Security で利用できるアノテーションの一つ ◦ 他にも @PreFilter や @PostFilter

    があるけどまだ使っていない • 処理の実行前にチェックして、違反していれば例外が投げられる • 引数で SpEL(Spring Expression Language) 式が使える @Controller class CorporationController () { @PreAuthorize(“isFullyAuthenticated()”) @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A” ) } コード © SMS Co.,Ltd.
  39. PreAuthorize での Bean の利用 • PreAuthorize では Bean が利用できる •

    SpEL 式で詳細に記述するのは辛いため、認可処理用の Bean を実装して利用している @Controller class CorporationController () { @PreAuthorize(“isFullyAuthenticated() && @corporationAuthorizer.canReadCorporation(principal, #corporationId)”) @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A” ) } コード @Component class CorporationAuthorizer { fun canReadCorporation(principal: User, corporationId: String): Boolean { return 認可結果を返す } } © SMS Co.,Ltd.
  40. PreAuthorize の SpEL 式 • Bean を利用していても、表記揺れなどが怖い • isFullyAuthenticated() を確認する

    or しない等 ◦ 先に確認するとSpEL式での principal へのアクセスでNLP発生が防げる ◦ principalが無い場合はNLPではなくてセキュリティ系の例外が発生して欲しい @PreAuthorize(“@corporationAuthorizer.canReadCorporation( principal, #corporationId)”) @PreAuthorize(“@corporationAuthorizer.canReadCorporation( principal, #corporationId) && isFullyAuthenticated()”) @PreAuthorize(“isFullyAuthenticated() && @corporationAuthorizer.canReadCorporation(principal, #corporationId)”) コード © SMS Co.,Ltd.
  41. Meta-Annotation • PreAuthorize の SpEL 式も含めて MetaAnnotation にして利用している • SpEL式の表記揺れを防止できる

    • 適切な名前を付けると可読性も増す コード @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @PreAuthorize(“isFullyAuthenticated() && @corporationAuthorizer.canReadCorporation(principal, #corporationId)”) annotation class PreAuthorizeCanReadCorporation @Controller class CorporationController () { @PreAuthorizeCanReadCorporation @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A” ) } © SMS Co.,Ltd.
  42. GraphQL のテスト • GraphQL でテストするために HttpGraphQlTester が用意されている ◦ WebSocket用のWebSocketGraphQlTester ◦

    乗っかるプロトコルを指定しないExecutionGraphQlServiceTester @Autowired private lateinit var tester: HttpGraphQlTester @Autowired private lateinit var tester: WebSocketGraphQlTester @Autowired private lateinit var tester: ExecutionGraphQlServiceTester val queryString = … tester .document(queryString) .variable("corporationId", “1”) .execute() .errors() .verify() .path("corporation") .entity(Corporation::class.java) .isEqualTo(expectedPayload) コード © SMS Co.,Ltd.
  43. GraphQL Document • テスト用のGraphQL Document の置き場はデフォルトで “classpath:/graphql-test/” tester .documentName(“corporationDocument”) .operationName(“CorporationOperation”)

    .variable("corporationId", “1”) .execute() .errors() .verify() .path("corporation") .entity(Corporation::class.java) .isEqualTo(expectedCorporation) query CorporationOperation($corporationId: ID!) { corporation(corporationId: $corporationId) { id name address phoneNumber } } コード corporationDocument.graphql © SMS Co.,Ltd.
  44. Test用のAnnotation • @GraphQlTest ◦ 通常はこれを使うのでいい ◦ ただし HttpGraphQlTester は使えない ◦

    ExecutionGraphQlServiceTesterを使うことになる • @SpringBootTest & @AutoConfigureHttpGraphQlTester ◦ HttpGraphQlTesterを使いたい場合はこちら ◦ カスタムした WebGraphQlInterceptor のテストをしたい場合 ◦ E2Eテストがしたい場合 © SMS Co.,Ltd.
  45. 認可アノテーションがついているかのテスト • リフレクションを使った静的解析を行ってテストする fun <T> assertMethodsWithAnnotation(controllerClass: Class<T>, testCases: Map<String, Class<out

    Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード © SMS Co.,Ltd.
  46. 認可アノテーションがついているかのテスト • テスト対象クラスとメソッド名と認可アノテーションの組み合わせのtestCaseを貰う © SMS Co.,Ltd. fun <T> assertMethodsWithAnnotation(controllerClass: Class<T>,

    testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード
  47. 認可アノテーションがついているかのテスト • テスト対象クラスのメソッド一覧をリフレクションで取得する © SMS Co.,Ltd. fun <T> assertMethodsWithAnnotation(controllerClass: Class<T>,

    testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード
  48. 認可アノテーションがついているかのテスト • GraphQLのAPIのみを対象にする © SMS Co.,Ltd. コード fun <T> assertMethodsWithAnnotation(controllerClass:

    Class<T>, testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } }
  49. 認可アノテーションがついているかのテスト • private でも static でも無いことを確認する © SMS Co.,Ltd. fun

    <T> assertMethodsWithAnnotation(controllerClass: Class<T>, testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード
  50. 認可アノテーションがついているかのテスト • 検出したメソッドが testCase に含まれていることを確認 © SMS Co.,Ltd. fun <T>

    assertMethodsWithAnnotation(controllerClass: Class<T>, testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード
  51. 認可アノテーションがついているかのテスト • 指定されているアノテーションがついていることを確認 © SMS Co.,Ltd. fun <T> assertMethodsWithAnnotation(controllerClass: Class<T>,

    testCases: Map<String, Class<out Annotation>>) { controllerClass.declaredMethods .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(QueryMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(MutationMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(SchemaMapping::class.java) || MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(BatchMapping::class.java) } .forEach { method -> val methodName = "${controllerClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val annotationClass = testCases[method.name] Assert.assertNotNull(annotationClass) { "$methodName must be added to the testCases. testCases:$testCases" } val annotation = AnnotationUtils.findAnnotation(method, annotationClass) Assert.assertNotNull(annotation) { "$methodName must be annotated with ${annotationClass?.simpleName}" } } } コード
  52. 認可アノテーションがついているかのテスト • 認可アノテーションのつけ忘れ • 認可アノテーションの付け間違い @Test fun `publicメソッドに適切な認可アノテーションがある `() {

    val testCases: Map<String, Class<out Annotation>> = mapOf( "corporation" to PreAuthorizeCanReadCorporation::class.java, ) assertMethodsWithAnnotation(CorporationController::class.java, testCases) } コード © SMS Co.,Ltd.
  53. 認可アノテーションのテスト • テスト用のComponentを用意して認可アノテーションをつける • 様々な権限を持った状態で呼び出して、意図通りの挙動になっていることを確認する コード @Component class AuthorizeTestCompornent {

    @PreAuthorizeCanReadCorporation fun preAuthorizeCanReadCorporation(cId: String) { println("called preAuthorizeCanReadCorporation:$cId") } } @Test @WithGrantedAuthority(CAN_READ_CORPORATION_USER_ID) fun `権限を持っているので例外は発生しない `() { assertDoesNotThrow { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } @Test @WithGrantedAuthority(NOTHING_USER_ID) fun `権限が無いので AccessDeniedException が投げられる`() { assertThrows<AccessDeniedException> { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } © SMS Co.,Ltd.
  54. 様々な権限を持った状態でテストを実行する方法 • @WithGrantedAuthority は実行するときのUserIDを指定できる自作アノテーション • 事前に様々な権限を持ったユーザーを登録しておき、そのIDを指定する @Component class AuthorizeTestCompornent {

    @PreAuthorizeCanReadCorporation fun preAuthorizeCanReadCorporation(cId: String) { println("called preAuthorizeCanReadCorporation:$cId") } } @Test @WithGrantedAuthority(CAN_READ_CORPORATION_USER_ID) fun `権限を持っているので例外は発生しない `() { assertDoesNotThrow { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } @Test @WithGrantedAuthority(NOTHING_USER_ID) fun `権限が無いので AccessDeniedException が投げられる`() { assertThrows<AccessDeniedException> { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } コード © SMS Co.,Ltd.
  55. 様々な権限を持った状態でテストを実行する方法 • @WithUserDetails の userDetailsServiceBeanName を指定する ◦ このサービスを使ってUserIDからUserを取得する • @get:AliasFor

    で継承したアノテーションの引数を指定できるようにしている @WithUserDetails(userDetailsServiceBeanName = "AuthorityUserDetailsService", setupBefore = TEST_EXECUTION) annotation class WithGrantedAuthority( @get:AliasFor(annotation = WithUserDetails::class, attribute = "value") val value: String ) コード © SMS Co.,Ltd.
  56. 事前に様々な権限を持ったユーザーを登録する • テスト用なので単純に Map に ID と権限情報を保存しておくだけ • loadUserByUsername で

    username から適切な User を作って返せばいい コード @Component("AuthorityUserDetailsService") class AuthorityUserDetailsService() : UserDetailsService { private val userMap = mutableMapOf<String, 権限情報>() fun add(userId: String, 権限情報) = userMap.put(userId, 権限情報) fun clean() = userMap.clear() override fun loadUserByUsername(username: String?): UserDetails = username ?.let { username と userMap から User を作って返す } ?: throw AccessDeniedException("username is not fount. $username") } © SMS Co.,Ltd.
  57. 事前に様々な権限を持ったユーザーを登録する • BeforeEach でテストしたい権限を持ったユーザーを追加する • AfterEach で追加したユーザーを削除しておく コード @BeforeEach fun

    setUp(@Autowired authorityUserDetailsService: AuthorityUserDetailsService) { authorityUserDetailsService.add(userId = CAN_READ_CORPORATION_USER_ID, …権限情報) authorityUserDetailsService.add(userId = NOTHING_USER_ID, …権限情報) } @AfterEach fun clean(@Autowired authorityUserDetailsService: AuthorityUserDetailsService) { authorityUserDetailsService.clean() } © SMS Co.,Ltd.
  58. 認可アノテーションのテスト • テスト用のComponentを用意して認可アノテーションをつける • 様々な権限を持った状態で呼び出して、意図通りの挙動になっていることを確認する コード @Component class AuthorizeTestCompornent {

    @PreAuthorizeCanReadCorporation fun preAuthorizeCanReadCorporation(cId: String) { println("called preAuthorizeCanReadCorporation:$cId") } } @Test @WithGrantedAuthority(CAN_READ_CORPORATION_USER_ID) fun `権限を持っているので例外は発生しない `() { assertDoesNotThrow { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } @Test @WithGrantedAuthority(NOTHING_USER_ID) fun `権限が無いので AccessDeniedException が投げられる`() { assertThrows<AccessDeniedException> { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } © SMS Co.,Ltd.
  59. 認可アノテーションのテスト(SpEL式) • SpEL式でメソッドの引数を使っている場合がある • メソッドが必要な引数を持たないケースでもコンパイルエラーにならない • リフレクションを使った静的解析で、これをチェックする コード @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME)

    @PreAuthorize(“isFullyAuthenticated() && @corporationAuthorizer.canReadCorporation(principal, #corporationId)”) annotation class PreAuthorizeCanReadCorporation @Controller class CorporationController () { @PreAuthorizeCanReadCorporation @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A” ) } © SMS Co.,Ltd.
  60. 認可アノテーションのテスト(SpEL式) • リフレクションを使った静的解析で、これをチェックする コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  61. 認可アノテーションのテスト(SpEL式) • テスト対象のアノテーションとメソッドに付いていることを確認したい引数名を貰う コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  62. 認可アノテーションのテスト(SpEL式) • Spring管理のコンポーネントを取得する ◦ ただしパッケージを指定して自分たちで追加したコンポーネントに絞る コード fun <A : Annotation>

    assertMethodsWithParameter(annotationType: Class<A>, parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  63. 認可アノテーションのテスト(SpEL式) • クラス名からクラスオブジェクトにmapする コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  64. 認可アノテーションのテスト(SpEL式) • クラスのリフレクションを使ってメソッド一覧にする コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  65. 認可アノテーションのテスト(SpEL式) • 指定されたアノテーションが付与されたメソッドに絞る コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  66. 認可アノテーションのテスト(SpEL式) • private でも static でも無いことを確認する コード fun <A :

    Annotation> assertMethodsWithParameter(annotationType: Class<A>, parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  67. 認可アノテーションのテスト(SpEL式) • 指定された名前の引数があることを確認する コード fun <A : Annotation> assertMethodsWithParameter(annotationType: Class<A>,

    parameterName: String) { ClassPathScanningCandidateComponentProvider(true) .findCandidateComponents("com.sms.platform") .asSequence() .map { Class.forName(it.beanClassName) } .flatMap { it.declaredMethods.toList() } .filter { MergedAnnotations.from(it, SearchStrategy.TYPE_HIERARCHY).isPresent(annotationType) } .forEach { method -> val methodName = "${method.declaringClass.canonicalName}#${method.name}" val isPublic = Modifier.isPublic(method.modifiers) val isStatic = Modifier.isStatic(method.modifiers) assert(isPublic) { "$methodName must be public method." } assert(!isStatic) { "$methodName must not be static method." } val hasParameter = method.parameters.any { it.name == parameterName } assert(hasParameter) { "$methodName must have a parameter named '$parameterName'" } } } © SMS Co.,Ltd.
  68. まとめ • GraphQL の良いところ ◦ バックエンドもフロントエンドも GraphQL Schema からコード生成できる ◦

    Validation ルールがGraphQL Schema で共有できる ◦ オーバーフェッチをあまり気にせず実装できる • Spring for GraphQL で工夫しているところ ◦ validationと認可 ◦ エラーレスポンス処理 ◦ 認可のテスト © SMS Co.,Ltd.
  69. まだ導入してないけど、今後やりたいこと • Persisted Query • depth 制限や complex 制限 •

    ClientMutationID と Cache によるレスポンスの冪等性 © SMS Co.,Ltd.
  70. Spring for GraphQL の GraphQL Client • HttpGraphQlClient がある ◦

    でもこれは内部で WebClient を使っているため web-flux が必要になる © SMS Co.,Ltd.
  71. HttpSyncGraphQlClient © SMS Co.,Ltd. • HttpSyncGraphQlClient が Spring boot 3.3.0

    で追加された ◦ 内部で RestClient を使っているので web への依存だけで良い • ただし TestRestTemplate のようなテスト用の RestClient が無いことに起因して、 HttpSyncGraphQlClient のテスト版もないので、テスト時は web-flux が必要 ◦ https://github.com/spring-projects/spring-framework/issues/31275