Slide 1

Slide 1 text

Spring for GraphQL の 実践 © SMS Co.,Ltd.

Slide 2

Slide 2 text

自己紹介 © SMS Co.,Ltd.

Slide 3

Slide 3 text

自己紹介 © SMS Co.,Ltd. 空中 清高 そらなか きよたか @soranakk ● 株式会社エス・エム・エス カイポケ開発部 プロダクトプラットフォームチームEM ● 2021年12月 カイポケのリニューアルプロジェクトにエンジニアとして入社 ● Androidアプリ開発者から フロントエンドやバックエンド、インフラなどを開発するフルスタック エンジニア兼エンジニアマネージャーにジョブチェンジ

Slide 4

Slide 4 text

前提の共有 © SMS Co.,Ltd.

Slide 5

Slide 5 text

介護業界について 出典:厚生労働省 老健局「公的介護保険制度の現状と今後の役割」 https://www.mhlw.go.jp/file/06-Seisakujouhou-12300000-Roukenkyoku/0000213177.pdf ● 高齢化が進行し、2040年に3人に一人は高齢者になるという人口推計 ● この変化に適応させるため、3年ごとに介護保険制度が改正されていく ● 現時点でも結果制度が多様・複雑になり50を超えるサービス種類に細分化されている ● これをいかに解決していくかが持続性のある社会にとって非常に重要 © SMS Co.,Ltd.

Slide 6

Slide 6 text

カイポケについて ● 介護事業者向けの『経営支援プラットフォーム』 ● リリースから10年を超えている SMB(中小企業)向けのBtoB SaaS ● モノリスなシステムで、様々な機能がソースコードレベルで密結合している © SMS Co.,Ltd.

Slide 7

Slide 7 text

カイポケのリニューアル ● 介護業界からのニーズは高まっており、新たなサービス種類への対応が急務 ● システムの安定性や拡張性、開発効率の改善も必要 ● 「拡張性」・「スケーラビリティ」・「開発並列性」をキーワードにリニューアル © SMS Co.,Ltd.

Slide 8

Slide 8 text

新カイポケのアーキテクチャ ● ドメイン分析の結果からBackendはドメイン毎に分割、FrontendはUseCase毎に分割 ● 通信はGraphQLを採用し、Gatewayで一つのGraph Schemaに統合 ● Backendのサービスの数は少なくとも3つ、これから増えることもある想定 ○ 増やす利点もあるが複雑度が増すのでうまくやる必要がある © SMS Co.,Ltd.

Slide 9

Slide 9 text

前提のまとめ ● 既にリリースしてあるソフトウェアのリニューアル ● リニューアルなので既存のREST APIが存在するわけではない ● 最終的にどのくらいの規模、複雑さを持つようになるかをある程度知っている ● 小さくリリースを重ねて大きくしていくので、最初はそこまで複雑ではない © SMS Co.,Ltd.

Slide 10

Slide 10 text

プロダクトの全体構成 © SMS Co.,Ltd.

Slide 11

Slide 11 text

プロダクトの全体構成 ● Backendはドメイン毎に分割、FrontendはUseCase毎に分割 ● 通信はGraphQLを採用し、Gatewayで一つのGraph Schemaに統合 ○ サービス間の通信にもGraphQLを採用して通信方法を統一 © SMS Co.,Ltd.

Slide 12

Slide 12 text

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.

Slide 13

Slide 13 text

モノレポの構成 © SMS Co.,Ltd.

Slide 14

Slide 14 text

最初はマルチレポでした ● Backend毎にコードを管理する構成 ● 初期はフロントエンドはドメイン毎に作る、マイクロフロントエンドの構想があった © SMS Co.,Ltd.

Slide 15

Slide 15 text

マルチレポで課題が見つかってきた ● Frontendチーム ○ ローカル開発時にサーバー側と繋ぎたい場合、複数のバックエンドを立てない といけない ○ それぞれ別のリポジトリにあるので、複数のリポジトリを落としてきて環境構築 する必要がある ● Product Platformチーム(アプリサーバー、Gateway、フロントエンドを開発) ○ Product Platformチームのサーバーと全体共通の Gatewayサーバーを開発す るとき、それぞれ別のリポジトリだったので複数のリポジトリ管理が必要だった (GitHub Issue等諸々) ○ フロントエンドも開発するチームだったので、フロントエンドチームと同じ問題が 発生(常に全リポジトリをチェックアウトしてきて、それぞれにコミットする、とか PRもそれぞれ見て回る必要アリとか ) © SMS Co.,Ltd.

Slide 16

Slide 16 text

モノレポにしたい機運も高まってきた ● Renovate導入 ○ 依存関係の自動アップデートツール ○ リポジトリ毎に導入の設定が必要で、マルチレポの複数チームで導入する場 合、リポジトリの設定変更の申請からする必要がある ● その他、他チームのコードや知見共有 ○ 他チームで導入されたツールの検討に少し壁がある状態 ○ 「ちょっとコピってくるか」のために別リポジトリを落としてこないといけない © SMS Co.,Ltd.

Slide 17

Slide 17 text

モノレポ化の工夫 ● モノレポ移行はチーム毎に順番に ○ 既にマルチレポで開発が始まっていたので順次移行の形を取りました ● ブランチ名やPRのラベル、codeownersを使って、整理できるように ○ codeownersでPRのreviewersを自動設定 ○ labelerを利用してコードの修正箇所によって自動的にラベル付け ● デプロイはそれぞれのサービス単位で行えるように ○ 新カイポケではデプロイは個別のサービス毎に行えるようにしています ● GitHub Actions で sparse checkout の設定を行う ○ CI時に全体をcheckoutしていると時間がかかるので特定のディレクトリだけ checkoutしてCIを高速化 © SMS Co.,Ltd.

Slide 18

Slide 18 text

モノレポ化で嬉しかったこと ● 他チームのコードを参考にするのが、より簡単に! ○ GitHub Actionsなどのコードが読みやすくなって、良かった! ● Renovateなどのプロジェクト設定的なやり方の共有が簡単に! ○ とりあえず動いている設定がそこにあるので、導入が楽 ● GraphQL Schema 管理が楽 ○ 全てのSubgraphが一つのリポジトリにあるのでSupergraph作成も容易 ○ FrontendでSupergraphからコード生成するときも楽 © SMS Co.,Ltd.

Slide 19

Slide 19 text

バックエンド構成 © SMS Co.,Ltd.

Slide 20

Slide 20 text

バックエンド構成 ● Backend はドメイン毎に分割 ● 各サーバーは Spring Framework で構成されている ● 各サーバーは Subgraph を Gateway に公開している ● サーバー間通信用の GraphQL Schema も公開している © SMS Co.,Ltd.

Slide 21

Slide 21 text

フロントエンド構成 © SMS Co.,Ltd.

Slide 22

Slide 22 text

フロントエンド構成 ● Frontend は UseCase 毎に分割 ● 一つの Next.js で構成されていて、共通の UI ライブラリを使っている ● Supergraph の GraphQL Schema からコードを生成している © SMS Co.,Ltd.

Slide 23

Slide 23 text

スキーマ管理 © SMS Co.,Ltd.

Slide 24

Slide 24 text

スキーマ管理 ● 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.

Slide 25

Slide 25 text

Supergraph の置き場 ● モノレポ直下の schema フォルダに配置 ● Backend の Subgraph や 合成した Supergraph を配置している ● Supergraph の生成は rover supergraph compose コマンドで実行 ○ https://www.apollographql.com/docs/rover/commands/supergraphs/ © SMS Co.,Ltd.

Slide 26

Slide 26 text

rover supergraph compose ● supergraph.yaml に Subgraph 情報を記載する ● Subgraph の GraphQL Schema は一つのファイルになっている制約があるのに注意 ○ それぞれの Subgraph は複数ファイルになっているので、まとめる前処理を別途行なっ ている © SMS Co.,Ltd.

Slide 27

Slide 27 text

Spring for GraphQLの構 成方法 © SMS Co.,Ltd.

Slide 28

Slide 28 text

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.

Slide 29

Slide 29 text

オーバーフェッチ問題の解決 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.

Slide 30

Slide 30 text

GraphQL Schemaファイルの置き場 ● デフォルトでは classpath:graphql/**/ になっている ○ spring.graphql.schema.locations の設定値を変えれば変更できる ● graphqls or gqls の拡張子が使える ● 複数ファイルに分割されていてもOK © SMS Co.,Ltd.

Slide 31

Slide 31 text

Resolver の置き場 ● Layered Architecture を採用していて Presentation 層に配置 ● 認可アノテーション(後述)と Resolver の置き場を分けている ● @Controller のメソッドがGraphQL Schemaにマッピングされる ○ いくつかのアノテーションで GraphQL Schema とマッピングする © SMS Co.,Ltd.

Slide 32

Slide 32 text

@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.

Slide 33

Slide 33 text

@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.

Slide 34

Slide 34 text

オーバーフェッチ問題の解決 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.

Slide 35

Slide 35 text

@SchemaMapping ● corporation の Query で offices を null で返しておく ● @SchemaMapping で offices を部分取得できるようにマッピングする ● GraphQL Schema で定義した名前とメソッド名が一致していれば自動的にマッピング ○ 違う場合は SchemaMapping の引数で指定する コード @SchemaMapping fun offices(corporation: Corporation): List = 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.

Slide 36

Slide 36 text

N + 1問題 ● @SchemaMapping は Corporation 一つ一つを解決する時に呼び出される ● GraphQL に 複数の Corporation を返すAPIがあると何度も呼び出されてしまう ○ DBアクセスや他サービスの呼び出しがN回実行されてしまう コード @Controller class CorporationController () { @QueryMapping fun corporations(): List? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) @SchemaMapping fun offices(corporation: Corporation): List = 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.

Slide 37

Slide 37 text

@BatchMapping ● BatchMapping を使うと複数のCorporationの時も一度のロードで済む ○ @SchemaMapping で DataLoader を使う書き方のショートカット版 ● メソッド名と引数、戻り値でマッピングされる コード @Controller class CorporationController () { @QueryMapping fun corporations(): List? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) @BatchMapping fun offices(corporations: List): Map?> = 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.

Slide 38

Slide 38 text

オーバーフェッチ問題の解決 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.

Slide 39

Slide 39 text

遅延ロードを使うかどうか ● Corporationの取得時に必ず offices もリクエストされる場合は直接返した方がいい ○ 一度のDBアクセスで全て手に入るので ● 最適化したいならGraphQLリクエストを解析して使われ方を調べる必要あり @Controller class CorporationController () { @QueryMapping fun corporations(): List? = listOf( Corporation(id = “1”, … , offices = null), Corporation(id = “2”, … , offices = null), Corporation(id = “3”, … , offices = null), … )) コード @BatchMapping fun offices(corporations: List): Map?> = 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.

Slide 40

Slide 40 text

オーバーフェッチ問題の解決 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.

Slide 41

Slide 41 text

Office が違うSubgraphにある場合 ● Corporationと Office が違うサーバーで管理されている場合 ● クライアント側から意識せずにGraphQL リクエストしたい type Office { id: ID! name: String! staffMembers: [StaffMember!] } type StaffMember { … スキーマ定義 © SMS Co.,Ltd.

Slide 42

Slide 42 text

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.

Slide 43

Slide 43 text

Federation ● サーバーA側は拡張のことは何も知らないので、普通に実装する @Controller class CorporationController () { @QueryMapping fun corporation(@Argument corporationId: String): Corporation? = Corporation( id = corporationId, name = “株式会社A”, address = “東京都~~~”, phoneNumber = “03****++++” ) } サーバーAのコード © SMS Co.,Ltd.

Slide 44

Slide 44 text

@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): Map?> = 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.

Slide 45

Slide 45 text

@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.

Slide 46

Slide 46 text

コード生成 © SMS Co.,Ltd.

Slide 47

Slide 47 text

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.

Slide 48

Slide 48 text

DGS codegen利用方法 ● GraphQLで定義した type などの class が生成される ● クライアントコードの生成もできるが利用していない ○ 過去はSpring for GraphQL と DGS が同居すると graphql-java の依存の競合が発生し ていた → 今はもう大丈夫かも? tasks.withType { 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.

Slide 49

Slide 49 text

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.

Slide 50

Slide 50 text

GraphQL Document ● GraphQL リクエストのクエリーを書ける ● 同じエンドポイントでも取得するプロパティが違うとかの使い分けができる query Corporation($corporationId: ID!) { corporation(corporationId: $corporationId) { id name address phoneNumber } } corporation.graphql © SMS Co.,Ltd.

Slide 51

Slide 51 text

Schema validation © SMS Co.,Ltd.

Slide 52

Slide 52 text

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.

Slide 53

Slide 53 text

導入方法 ● 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.

Slide 54

Slide 54 text

利用方法 ● 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.

Slide 55

Slide 55 text

Error レスポンス管理 © SMS Co.,Ltd.

Slide 56

Slide 56 text

エラーレスポンスの形式 ● エラーが発生した時のレスポンス形式が揃っていないとクライアント側が辛い ● Spring for GraphQL ではいくつかの例外発生場所があって、それぞれでハンドリングの仕方 が異なる ○ エラーのデータ形式を自分達で決めている場合は適切にやる必要がある { “data”: null, “errors”: [ { “message”: “適切なエラーメッセージ ” “extensions”: { “errorCode”: “BAD_REQUEST” “errorDetailCode”: “INPUT_VALIDATION_ERROR” } ] } © SMS Co.,Ltd.

Slide 57

Slide 57 text

エラーレスポンスのハンドリング箇所 ● 以下の三つの箇所がある ○ GraphQL Schema 解決前のエラー ○ graphql-java-extended-validation のエラー ○ Resolver にたどり着いた後のエラー © SMS Co.,Ltd.

Slide 58

Slide 58 text

GraphQL Schema 解決前のエラー ● GraphQL Request が間違えていて Resolver に辿り着けない時など(文法間違いとか) ● WebGraphQlInterceptor を継承したクラスで対応する © SMS Co.,Ltd.

Slide 59

Slide 59 text

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.

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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?, validationEnvironment: ValidationEnvironment? ): GraphQLError { return GraphqlErrorBuilder.newError() …(略) .build() } © SMS Co.,Ltd.

Slide 62

Slide 62 text

Resolver にたどり着いた後のエラー ● DataFetcherExceptionResolverAdapter を継承したクラスで処理する ● resolveToSingleError で処理しなかったら(null を返したら)後続の DataFetcherExceptionResolver にチェインされる ● 適用順を @Order で指定できるので優先順位を最低にして共通ライブラリ化している © SMS Co.,Ltd.

Slide 63

Slide 63 text

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.

Slide 64

Slide 64 text

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.

Slide 65

Slide 65 text

認可処理 © SMS Co.,Ltd.

Slide 66

Slide 66 text

Method Security ● GraphQL のエンドポイントは一つなのでPathベースの Web Security は利用できない ● GraphQL のクエリーはメソッドにマッピングされるためMethod Security を利用する © SMS Co.,Ltd.

Slide 67

Slide 67 text

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.

Slide 68

Slide 68 text

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.

Slide 69

Slide 69 text

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.

Slide 70

Slide 70 text

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.

Slide 71

Slide 71 text

テスト © SMS Co.,Ltd.

Slide 72

Slide 72 text

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.

Slide 73

Slide 73 text

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.

Slide 74

Slide 74 text

Test用のAnnotation ● @GraphQlTest ○ 通常はこれを使うのでいい ○ ただし HttpGraphQlTester は使えない ○ ExecutionGraphQlServiceTesterを使うことになる ● @SpringBootTest & @AutoConfigureHttpGraphQlTester ○ HttpGraphQlTesterを使いたい場合はこちら ○ カスタムした WebGraphQlInterceptor のテストをしたい場合 ○ E2Eテストがしたい場合 © SMS Co.,Ltd.

Slide 75

Slide 75 text

認可のテスト ● APIエンドポイント毎に様々な権限を持つユーザーでテストする必要がある ○ CanRead, CanEdit, etc… ● 同じ種類の認可に対してのテストは違うAPIでも似たようなテストになる ● テストケース数 = エンドポイントの数 × 権限の種類 © SMS Co.,Ltd.

Slide 76

Slide 76 text

認可のテストを分解する ● APIエンドポイント毎に適切な認可アノテーションがついているかのテスト ● 認可アノテーションの権限毎のテスト ● テストケース数 = APIエンドポイントの数+認可の種類 × 権限の種類 ● APIエンドポイントの数 >> 認可アノテーションの種類 なのでこっちの方が少なくなる © SMS Co.,Ltd.

Slide 77

Slide 77 text

認可アノテーションがついているかのテスト ● リフレクションを使った静的解析を行ってテストする fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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.

Slide 78

Slide 78 text

認可アノテーションがついているかのテスト ● テスト対象クラスとメソッド名と認可アノテーションの組み合わせのtestCaseを貰う © SMS Co.,Ltd. fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } } コード

Slide 79

Slide 79 text

認可アノテーションがついているかのテスト ● テスト対象クラスのメソッド一覧をリフレクションで取得する © SMS Co.,Ltd. fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } } コード

Slide 80

Slide 80 text

認可アノテーションがついているかのテスト ● GraphQLのAPIのみを対象にする © SMS Co.,Ltd. コード fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } }

Slide 81

Slide 81 text

認可アノテーションがついているかのテスト ● private でも static でも無いことを確認する © SMS Co.,Ltd. fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } } コード

Slide 82

Slide 82 text

認可アノテーションがついているかのテスト ● 検出したメソッドが testCase に含まれていることを確認 © SMS Co.,Ltd. fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } } コード

Slide 83

Slide 83 text

認可アノテーションがついているかのテスト ● 指定されているアノテーションがついていることを確認 © SMS Co.,Ltd. fun assertMethodsWithAnnotation(controllerClass: Class, testCases: Map>) { 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}" } } } コード

Slide 84

Slide 84 text

認可アノテーションがついているかのテスト ● 認可アノテーションのつけ忘れ ● 認可アノテーションの付け間違い @Test fun `publicメソッドに適切な認可アノテーションがある `() { val testCases: Map> = mapOf( "corporation" to PreAuthorizeCanReadCorporation::class.java, ) assertMethodsWithAnnotation(CorporationController::class.java, testCases) } コード © SMS Co.,Ltd.

Slide 85

Slide 85 text

認可アノテーションのテスト ● テスト用の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 { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } © SMS Co.,Ltd.

Slide 86

Slide 86 text

様々な権限を持った状態でテストを実行する方法 ● @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 { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } コード © SMS Co.,Ltd.

Slide 87

Slide 87 text

様々な権限を持った状態でテストを実行する方法 ● @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.

Slide 88

Slide 88 text

事前に様々な権限を持ったユーザーを登録する ● テスト用なので単純に Map に ID と権限情報を保存しておくだけ ● loadUserByUsername で username から適切な User を作って返せばいい コード @Component("AuthorityUserDetailsService") class AuthorityUserDetailsService() : UserDetailsService { private val userMap = mutableMapOf() 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.

Slide 89

Slide 89 text

事前に様々な権限を持ったユーザーを登録する ● 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.

Slide 90

Slide 90 text

認可アノテーションのテスト ● テスト用の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 { c.preAuthorizeCanReadCorporation(CORPORATION_ID) } } © SMS Co.,Ltd.

Slide 91

Slide 91 text

認可アノテーションのテスト(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.

Slide 100

Slide 100 text

まとめ ● GraphQL の良いところ ○ バックエンドもフロントエンドも GraphQL Schema からコード生成できる ○ Validation ルールがGraphQL Schema で共有できる ○ オーバーフェッチをあまり気にせず実装できる ● Spring for GraphQL で工夫しているところ ○ validationと認可 ○ エラーレスポンス処理 ○ 認可のテスト © SMS Co.,Ltd.

Slide 101

Slide 101 text

ご清聴ありがとう ございました © SMS Co.,Ltd.

Slide 102

Slide 102 text

Appendix © SMS Co.,Ltd.

Slide 103

Slide 103 text

まだ導入してないけど、今後やりたいこと ● Persisted Query ● depth 制限や complex 制限 ● ClientMutationID と Cache によるレスポンスの冪等性 © SMS Co.,Ltd.

Slide 104

Slide 104 text

補足:SecurityDataFetcherExceptionResolver ● Spring Security 用のエラーハンドリングがデフォルトで導入されている ○ おそらく認可系の例外は詳細情報が載っている可能性があるため ○ 優先順位は @Order で指定できるものより低くなっている © SMS Co.,Ltd.

Slide 105

Slide 105 text

Spring for GraphQL の GraphQL Client ● HttpGraphQlClient がある ○ でもこれは内部で WebClient を使っているため web-flux が必要になる © SMS Co.,Ltd.

Slide 106

Slide 106 text

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