graphql-stitching 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +27 -0
  3. data/.gitignore +59 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +11 -0
  6. data/Gemfile.lock +49 -0
  7. data/LICENSE +21 -0
  8. data/Procfile +3 -0
  9. data/README.md +329 -0
  10. data/Rakefile +12 -0
  11. data/docs/README.md +14 -0
  12. data/docs/composer.md +69 -0
  13. data/docs/document.md +15 -0
  14. data/docs/executor.md +29 -0
  15. data/docs/gateway.md +106 -0
  16. data/docs/images/library.png +0 -0
  17. data/docs/images/merging.png +0 -0
  18. data/docs/images/stitching.png +0 -0
  19. data/docs/planner.md +43 -0
  20. data/docs/shaper.md +20 -0
  21. data/docs/supergraph.md +65 -0
  22. data/example/gateway.rb +50 -0
  23. data/example/graphiql.html +153 -0
  24. data/example/remote1.rb +26 -0
  25. data/example/remote2.rb +26 -0
  26. data/graphql-stitching.gemspec +34 -0
  27. data/lib/graphql/stitching/composer/base_validator.rb +11 -0
  28. data/lib/graphql/stitching/composer/validate_boundaries.rb +80 -0
  29. data/lib/graphql/stitching/composer/validate_interfaces.rb +24 -0
  30. data/lib/graphql/stitching/composer.rb +442 -0
  31. data/lib/graphql/stitching/document.rb +59 -0
  32. data/lib/graphql/stitching/executor.rb +254 -0
  33. data/lib/graphql/stitching/gateway.rb +120 -0
  34. data/lib/graphql/stitching/planner.rb +323 -0
  35. data/lib/graphql/stitching/planner_operation.rb +59 -0
  36. data/lib/graphql/stitching/remote_client.rb +25 -0
  37. data/lib/graphql/stitching/shaper.rb +92 -0
  38. data/lib/graphql/stitching/supergraph.rb +171 -0
  39. data/lib/graphql/stitching/util.rb +63 -0
  40. data/lib/graphql/stitching/version.rb +7 -0
  41. data/lib/graphql/stitching.rb +30 -0
  42. metadata +142 -0
data/docs/gateway.md ADDED
@@ -0,0 +1,106 @@
1
+ ## GraphQL::Stitching::Gateway
2
+
3
+ The `Gateway` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A gateway is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration.
4
+
5
+ ### Building
6
+
7
+ The Gateway constructor accepts configuration to build a [`Supergraph`](./supergraph.md) for you. Location names are root keys, and each location config provides a `schema` and an optional [executable](../README.md#executables).
8
+
9
+ ```ruby
10
+ movies_schema = "type Query { ..."
11
+ showtimes_schema = "type Query { ..."
12
+
13
+ gateway = GraphQL::Stitching::Gateway.new(locations: {
14
+ products: {
15
+ schema: GraphQL::Schema.from_definition(movies_schema),
16
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
17
+ },
18
+ showtimes: {
19
+ schema: GraphQL::Schema.from_definition(showtimes_schema),
20
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
21
+ },
22
+ my_local: {
23
+ schema: MyLocal::GraphQL::Schema,
24
+ },
25
+ })
26
+ ```
27
+
28
+ Locations provided with only a `schema` will assign the schema as the location executable (these are locally-executable schemas, and must have locally-implemented resolvers). Locations that provide an `executable` will perform requests using the executable.
29
+
30
+ #### From exported supergraph
31
+
32
+ It's possible to [export and rehydrate](./supergraph.md#export-and-caching) `Supergraph` instances, allowing a supergraph to be cached as static artifacts and then rehydrated quickly at runtime without going through composition. To setup a gateway with a prebuilt supergraph, you may pass it as a `supergraph` argument:
33
+
34
+ ```ruby
35
+ exported_schema = "..."
36
+ exported_mapping = JSON.parse("{ ... }")
37
+ supergraph = GraphQL::Stitching::Supergraph.from_export(exported_schema, exported_mapping)
38
+
39
+ gateway = GraphQL::Stitching::Gateway.new(supergraph: supergraph)
40
+ ```
41
+
42
+ ### Execution
43
+
44
+ A gateway provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests to a stitched gateway becomes mostly a drop-in replacement to executing a `GraphQL::Schema` instance:
45
+
46
+ ```ruby
47
+ result = gateway.execute(
48
+ query: "query MyProduct($id: ID!) { product(id: $id) { name } }",
49
+ variables: { "id" => "1" },
50
+ operation_name: "MyProduct",
51
+ )
52
+ ```
53
+
54
+ Arguments for the `execute` method include:
55
+
56
+ * `query`: a query (or mutation) as a string or parsed AST.
57
+ * `variables`: a hash of variables for the request.
58
+ * `operation_name`: the name of the operation to execute (when multiple are provided).
59
+ * `validate`: true if static validation should run on the supergraph schema before execution.
60
+ * `context`: an object that gets passed through to gateway caching and error hooks.
61
+
62
+ ### Cache hooks
63
+
64
+ The gateway provides cache hooks to enable caching query plans across requests. Without caching, every request made the the gateway will be planned individually. With caching, a query may be planned once, cached, and then executed from cache for subsequent requests. Cache keys are a normalized digest of each query string.
65
+
66
+ ```ruby
67
+ gateway.on_cache_read do |key, _context|
68
+ $redis.get(key) # << 3P code
69
+ end
70
+
71
+ gateway.on_cache_write do |key, payload, _context|
72
+ $redis.set(key, payload) # << 3P code
73
+ end
74
+ ```
75
+
76
+ Note that inlined input data works against caching, so you should _avoid_ this:
77
+
78
+ ```graphql
79
+ query {
80
+ product(id: "1") { name }
81
+ }
82
+ ```
83
+
84
+ Instead, always leverage variables in queries so that the document body remains consistent across requests:
85
+
86
+ ```graphql
87
+ query($id: ID!) {
88
+ product(id: $id) { name }
89
+ }
90
+
91
+ # variables: { "id" => "1" }
92
+ ```
93
+
94
+ ### Error hooks
95
+
96
+ The gateway also provides an error hook. Any program errors rescued during execution will be passed to the `on_error` handler, which can report on the error as needed and return a formatted error message for the gateway to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
97
+
98
+ ```ruby
99
+ gateway.on_error do |err, context|
100
+ # log the error
101
+ Bugsnag.notify(err)
102
+
103
+ # return a formatted message for the public response
104
+ "Whoops, please contact support abount request '#{context[:request_id]}'"
105
+ end
106
+ ```
Binary file
Binary file
Binary file
data/docs/planner.md ADDED
@@ -0,0 +1,43 @@
1
+ ## GraphQL::Stitching::Planner
2
+
3
+ A `Planner` generates a query plan for a given [`Supergraph`](./supergraph.md) and request [`Document`](./document.md). The generated plan breaks down all the discrete GraphQL operations that must be delegated across locations and their sequencing.
4
+
5
+ ```ruby
6
+ request = <<~GRAPHQL
7
+ query MyQuery($id: ID!) {
8
+ product(id:$id) {
9
+ title
10
+ brands { name }
11
+ }
12
+ }
13
+ GRAPHQL
14
+
15
+ document = GraphQL::Stitching::Document.new(request, operation_name: "MyQuery")
16
+
17
+ plan = GraphQL::Stitching::Planner.new(
18
+ supergraph: supergraph,
19
+ document: document,
20
+ ).perform
21
+ ```
22
+
23
+ ### Caching
24
+
25
+ Plans are designed to be cacheable. This is very useful for redundant GraphQL documents (commonly sent by frontend clients) where there's no sense in planning every request individually. It's far more efficient to generate a plan once and cache it, then simply retreive the plan and execute it for future requests.
26
+
27
+ ```ruby
28
+ cached_plan = $redis.get(document.digest)
29
+
30
+ plan = if cached_plan
31
+ JSON.parse(cached_plan)
32
+ else
33
+ plan_hash = GraphQL::Stitching::Planner.new(
34
+ supergraph: supergraph,
35
+ document: document,
36
+ ).perform.to_h
37
+
38
+ $redis.set(document.digest, JSON.generate(plan_hash))
39
+ plan_hash
40
+ end
41
+
42
+ # execute the plan...
43
+ ```
data/docs/shaper.md ADDED
@@ -0,0 +1,20 @@
1
+ ## GraphQL::Stitching::Shaper
2
+
3
+ The `Shaper` takes the raw output generated by the `GraphQL::Stitching::Executor` and does the final shaping and
4
+ cleaning. It removes data that was added while building the result, it also handles cleaning up violations that can
5
+ only occur at the end, such as bubbling up null violoations.
6
+
7
+ See the [Executor](./docs/executor.md)
8
+
9
+ ```ruby
10
+ raw_result = GraphQL::Stitching::Executor.new(
11
+ supergraph: supergraph,
12
+ plan: plan.to_h,
13
+ variables: variables,
14
+ ).perform(document)
15
+
16
+ final_result = GraphQL::Stitching::Shaper.new(
17
+ supergraph: supergraph,
18
+ document: document,
19
+ raw: raw_result).perform!
20
+ ```
@@ -0,0 +1,65 @@
1
+ ## GraphQL::Stitching::Supergraph
2
+
3
+ A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` is generated by a [`Composer`](./composer.md), and contains the combined schema and delegation map for location routing.
4
+
5
+ ```ruby
6
+ storefronts_sdl = "type Query { storefront(id: ID!): Storefront } ..."
7
+ products_sdl = "type Query { product(id: ID!): Product } ..."
8
+
9
+ supergraph = GraphQL::Stitching::Composer.new({
10
+ "storefronts" => GraphQL::Schema.from_definition(storefronts_sdl),
11
+ "products" => GraphQL::Schema.from_definition(products_sdl),
12
+ }).perform
13
+
14
+ combined_schema = supergraph.schema
15
+ ```
16
+
17
+ ### Assigning executables
18
+
19
+ A Supergraph also manages executable resources assigned for each location (ie: the objects that perform GraphQL requests for each location). An executable is a `GraphQL::Schema` class or any object that implements a `.call(location, query_string, variables)` method and returns a raw GraphQL response. Executables are assigned to a supergraph using `assign_executable`:
20
+
21
+ ```ruby
22
+ supergraph = GraphQL::Stitching::Composer.new(...)
23
+
24
+ supergraph.assign_executable("location1", MyExecutable.new)
25
+ supergraph.assign_executable("location2", ->(loc, query, vars) { ... })
26
+ supergraph.assign_executable("location3") do |loc, query vars|
27
+ # ...
28
+ end
29
+ ```
30
+
31
+ ### Export and caching
32
+
33
+ A Supergraph is designed to be composed, cached, and restored. Calling the `export` method will return an SDL (Schema Definition Language) print of the combined graph schema and a deletation mapping hash. These can be persisted in any raw format that suits your stack:
34
+
35
+ ```ruby
36
+ supergraph_sdl, delegation_map = supergraph.export
37
+
38
+ # stash these resources in Redis...
39
+ $redis.set("cached_supergraph_sdl", supergraph_sdl)
40
+ $redis.set("cached_delegation_map", JSON.generate(delegation_map))
41
+
42
+ # or, write the resources as files and commit them to your repo...
43
+ File.write("supergraph/schema.graphql", supergraph_sdl)
44
+ File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
45
+ ```
46
+
47
+ To restore a cached Supergraph, collect the cached SDL and delegation mapping then recreate the Supergraph using `from_export`:
48
+
49
+ ```ruby
50
+ supergraph_sdl = $redis.get("cached_supergraph_sdl")
51
+ delegation_map = JSON.parse($redis.get("cached_delegation_map"))
52
+
53
+ supergraph = GraphQL::Stitching::Supergraph.from_export(supergraph_sdl, delegation_map)
54
+ ```
55
+
56
+ Note that a supergraph built from cache will not have _any_ executables assigned to it, so you'll need to manually reassign executables for each location:
57
+
58
+ ```ruby
59
+ remote_client = GraphQL::Stitching::RemoteClient.new(
60
+ url: "http:localhost:3000",
61
+ headers: { "Authorization" => "Bearer 12345" }
62
+ )
63
+ supergraph.assign_executable("my_remote", remote_client)
64
+ supergraph.assign_executable("my_local", MyLocal::Schema)
65
+ ```
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'byebug'
6
+ require 'graphql'
7
+ require 'graphql/stitching'
8
+ require_relative '../test/schemas/example'
9
+
10
+ class StitchedApp
11
+ def initialize
12
+ file = File.open("#{__dir__}/graphiql.html")
13
+ @graphiql = file.read
14
+ file.close
15
+
16
+ @gateway = GraphQL::Stitching::Gateway.new(locations: {
17
+ products: {
18
+ schema: Schemas::Example::Products,
19
+ },
20
+ storefronts: {
21
+ schema: Schemas::Example::Storefronts,
22
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001/graphql"),
23
+ },
24
+ manufacturers: {
25
+ schema: Schemas::Example::Manufacturers,
26
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002/graphql"),
27
+ }
28
+ })
29
+ end
30
+
31
+ def call(env)
32
+ req = Rack::Request.new(env)
33
+ case req.path_info
34
+ when /graphql/
35
+ params = JSON.parse(req.body.read)
36
+
37
+ result = @gateway.execute(
38
+ query: params["query"],
39
+ variables: params["variables"],
40
+ operation_name: params["operationName"],
41
+ )
42
+
43
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
44
+ else
45
+ [200, {"content-type" => "text/html"}, [@graphiql]]
46
+ end
47
+ end
48
+ end
49
+
50
+ Rackup::Handler.default.run(StitchedApp.new, :Port => 3000)
@@ -0,0 +1,153 @@
1
+ <!--
2
+ * Copyright (c) Facebook, Inc.
3
+ * All rights reserved.
4
+ *
5
+ *
6
+ * This source code is licensed under the license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ -->
9
+
10
+ <!-- original source: https://github.com/graphql/graphiql/blob/master/example/index.html -->
11
+
12
+ <!DOCTYPE html>
13
+ <html>
14
+ <head>
15
+ <style>
16
+ body {
17
+ height: 100%;
18
+ margin: 0;
19
+ width: 100%;
20
+ overflow: hidden;
21
+ }
22
+ #graphiql {
23
+ height: 100vh;
24
+ }
25
+ </style>
26
+
27
+ <!--
28
+ This GraphiQL example depends on Promise and fetch, which are available in
29
+ modern browsers, but can be "polyfilled" for older browsers.
30
+ GraphiQL itself depends on React DOM.
31
+ If you do not want to rely on a CDN, you can host these files locally or
32
+ include them directly in your favored resource bunder.
33
+ -->
34
+ <script src="https://cdn.jsdelivr.net/es6-promise/4.0.5/es6-promise.auto.min.js"></script>
35
+ <script src="https://cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
36
+ <script src="https://cdn.jsdelivr.net/react/15.4.2/react.min.js"></script>
37
+ <script src="https://cdn.jsdelivr.net/react/15.4.2/react-dom.min.js"></script>
38
+
39
+ <!--
40
+ These two files can be found in the npm module, however you may wish to
41
+ copy them directly into your environment, or perhaps include them in your
42
+ favored resource bundler.
43
+ -->
44
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/graphiql/0.10.2/graphiql.css" />
45
+ <script src="https://cdn.jsdelivr.net/graphiql/0.10.2/graphiql.min.js"></script>
46
+
47
+ </head>
48
+ <body>
49
+ <div id="graphiql">Loading...</div>
50
+ <script>
51
+
52
+ /**
53
+ * This GraphiQL example illustrates how to use some of GraphiQL's props
54
+ * in order to enable reading and updating the URL parameters, making
55
+ * link sharing of queries a little bit easier.
56
+ *
57
+ * This is only one example of this kind of feature, GraphiQL exposes
58
+ * various React params to enable interesting integrations.
59
+ */
60
+
61
+ // Parse the search string to get url parameters.
62
+ var search = window.location.search;
63
+ var parameters = {};
64
+ search.substr(1).split('&').forEach(function (entry) {
65
+ var eq = entry.indexOf('=');
66
+ if (eq >= 0) {
67
+ parameters[decodeURIComponent(entry.slice(0, eq))] =
68
+ decodeURIComponent(entry.slice(eq + 1));
69
+ }
70
+ });
71
+
72
+ // if variables was provided, try to format it.
73
+ if (parameters.variables) {
74
+ try {
75
+ parameters.variables =
76
+ JSON.stringify(JSON.parse(parameters.variables), null, 2);
77
+ } catch (e) {
78
+ // Do nothing, we want to display the invalid JSON as a string, rather
79
+ // than present an error.
80
+ }
81
+ }
82
+
83
+ // When the query and variables string is edited, update the URL bar so
84
+ // that it can be easily shared
85
+ function onEditQuery(newQuery) {
86
+ parameters.query = newQuery;
87
+ updateURL();
88
+ }
89
+
90
+ function onEditVariables(newVariables) {
91
+ parameters.variables = newVariables;
92
+ updateURL();
93
+ }
94
+
95
+ function onEditOperationName(newOperationName) {
96
+ parameters.operationName = newOperationName;
97
+ updateURL();
98
+ }
99
+
100
+ function updateURL() {
101
+ var newSearch = '?' + Object.keys(parameters).filter(function (key) {
102
+ return Boolean(parameters[key]);
103
+ }).map(function (key) {
104
+ return encodeURIComponent(key) + '=' +
105
+ encodeURIComponent(parameters[key]);
106
+ }).join('&');
107
+ history.replaceState(null, null, newSearch);
108
+ }
109
+
110
+ // Defines a GraphQL fetcher using the fetch API. You're not required to
111
+ // use fetch, and could instead implement graphQLFetcher however you like,
112
+ // as long as it returns a Promise or Observable.
113
+ function graphQLFetcher(graphQLParams) {
114
+ // This example expects a GraphQL server at the path /graphql.
115
+ // Change this to point wherever you host your GraphQL server.
116
+ return fetch('/graphql', {
117
+ method: 'post',
118
+ headers: {
119
+ 'Accept': 'application/json',
120
+ 'Content-Type': 'application/json',
121
+ },
122
+ body: JSON.stringify(graphQLParams),
123
+ credentials: 'include',
124
+ }).then(function (response) {
125
+ return response.text();
126
+ }).then(function (responseBody) {
127
+ try {
128
+ return JSON.parse(responseBody);
129
+ } catch (error) {
130
+ return responseBody;
131
+ }
132
+ });
133
+ }
134
+
135
+ // Render <GraphiQL /> into the body.
136
+ // See the README in the top level of this module to learn more about
137
+ // how you can customize GraphiQL by providing different values or
138
+ // additional child elements.
139
+ ReactDOM.render(
140
+ React.createElement(GraphiQL, {
141
+ fetcher: graphQLFetcher,
142
+ query: parameters.query,
143
+ variables: parameters.variables,
144
+ operationName: parameters.operationName,
145
+ onEditQuery: onEditQuery,
146
+ onEditVariables: onEditVariables,
147
+ onEditOperationName: onEditOperationName
148
+ }),
149
+ document.getElementById('graphiql')
150
+ );
151
+ </script>
152
+ </body>
153
+ </html>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative '../test/schemas/example'
7
+
8
+ class FirstRemoteApp
9
+ def call(env)
10
+ req = Rack::Request.new(env)
11
+ case req.path_info
12
+ when /graphql/
13
+ params = JSON.parse(req.body.read)
14
+ result = Schemas::Example::Storefronts.execute(
15
+ query: params["query"],
16
+ variables: params["variables"],
17
+ operation_name: params["operationName"],
18
+ )
19
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
20
+ else
21
+ [404, {"content-type" => "text/html"}, ["not found"]]
22
+ end
23
+ end
24
+ end
25
+
26
+ Rackup::Handler.default.run(FirstRemoteApp.new, :Port => 3001)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative '../test/schemas/example'
7
+
8
+ class SecondRemoteApp
9
+ def call(env)
10
+ req = Rack::Request.new(env)
11
+ case req.path_info
12
+ when /graphql/
13
+ params = JSON.parse(req.body.read)
14
+ result = Schemas::Example::Manufacturers.execute(
15
+ query: params["query"],
16
+ variables: params["variables"],
17
+ operation_name: params["operationName"],
18
+ )
19
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
20
+ else
21
+ [404, {"content-type" => "text/html"}, ["not found"]]
22
+ end
23
+ end
24
+ end
25
+
26
+ Rackup::Handler.default.run(SecondRemoteApp.new, :Port => 3002)
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'graphql/stitching/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'graphql-stitching'
8
+ spec.version = GraphQL::Stitching::VERSION
9
+ spec.authors = ['Greg MacWilliam']
10
+ spec.summary = 'GraphQL schema stitching for Ruby'
11
+ spec.description = spec.summary
12
+ spec.homepage = 'https://github.com/gmac/graphql-stitching-ruby'
13
+ spec.license = 'MIT'
14
+
15
+ spec.required_ruby_version = '>= 3.1.1'
16
+
17
+ spec.metadata = {
18
+ 'homepage_uri' => 'https://github.com/gmac/graphql-stitching-ruby',
19
+ 'changelog_uri' => 'https://github.com/gmac/graphql-stitching-ruby/releases',
20
+ 'source_code_uri' => 'https://github.com/gmac/graphql-stitching-ruby',
21
+ 'bug_tracker_uri' => 'https://github.com/gmac/graphql-stitching-ruby/issues',
22
+ }
23
+
24
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
25
+ f.match(%r{^test/})
26
+ end
27
+ spec.require_paths = ['lib']
28
+
29
+ spec.add_runtime_dependency 'graphql', '~> 2.0.16'
30
+
31
+ spec.add_development_dependency 'bundler', '~> 2.0'
32
+ spec.add_development_dependency 'rake', '~> 12.0'
33
+ spec.add_development_dependency 'minitest', '~> 5.12'
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Composer::BaseValidator
6
+ def perform(ctx, composer)
7
+ raise "not implemented"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Composer::ValidateBoundaries < Composer::BaseValidator
6
+
7
+ def perform(ctx, composer)
8
+ ctx.schema.types.each do |type_name, type|
9
+ # objects and interfaces that are not the root operation types
10
+ next unless type.kind.object? || type.kind.interface?
11
+ next if ctx.schema.query == type || ctx.schema.mutation == type
12
+ next if type.graphql_name.start_with?("__")
13
+
14
+ # multiple subschemas implement the type
15
+ subschema_types_by_location = composer.subschema_types_by_name_and_location[type_name]
16
+ next unless subschema_types_by_location.length > 1
17
+
18
+ boundaries = ctx.boundaries[type_name]
19
+ if boundaries&.any?
20
+ validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
21
+ elsif type.kind.object?
22
+ validate_as_shared(ctx, type, subschema_types_by_location)
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
30
+ # abstract boundaries are expanded with their concrete implementations, which each get validated. Ignore the abstract itself.
31
+ return if type.kind.abstract?
32
+
33
+ # only one boundary allowed per type/location/key
34
+ boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
35
+ if memo.dig(boundary["location"], boundary["selection"])
36
+ raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` found in #{boundary["location"]}.
37
+ Limit one boundary query per type and key in each location. Abstract boundaries provide all possible types."
38
+ end
39
+ memo[boundary["location"]] ||= {}
40
+ memo[boundary["location"]][boundary["selection"]] = boundary
41
+ end
42
+
43
+ boundary_keys = boundaries.map { _1["selection"] }.uniq
44
+ key_only_types_by_location = subschema_types_by_location.select do |location, subschema_type|
45
+ subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
46
+ end
47
+
48
+ # all locations have a boundary, or else are key-only
49
+ subschema_types_by_location.each do |location, subschema_type|
50
+ unless boundaries_by_location_and_key[location] || key_only_types_by_location[location]
51
+ raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
52
+ end
53
+ end
54
+
55
+ outbound_access_locations = key_only_types_by_location.keys
56
+ bidirectional_access_locations = subschema_types_by_location.keys - outbound_access_locations
57
+
58
+ # verify that all outbound locations can access all inbound locations
59
+ (outbound_access_locations + bidirectional_access_locations).each do |location|
60
+ remote_locations = bidirectional_access_locations.reject { _1 == location }
61
+ paths = ctx.route_type_to_locations(type.graphql_name, location, remote_locations)
62
+ if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
63
+ raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations.
64
+ All locations must provide a boundary accessor that uses a conjoining key."
65
+ end
66
+ end
67
+ end
68
+
69
+ def validate_as_shared(ctx, type, subschema_types_by_location)
70
+ expected_fields = type.fields.keys.sort
71
+ subschema_types_by_location.each do |location, subschema_type|
72
+ if subschema_type.fields.keys.sort != expected_fields
73
+ raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
74
+ or else define boundary queries so that its unique fields may be accessed remotely."
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Composer::ValidateInterfaces < Composer::BaseValidator
6
+
7
+ def perform(supergraph, composer)
8
+ # @todo
9
+ # Validate all supergraph interface fields
10
+ # match possible types in all locations...
11
+ # - Traverse supergraph types (supergraph.types)
12
+ # - For each interface (.kind.interface?), get possible types (Util.get_possible_types)
13
+ # - For each possible type, traverse type candidates (composer.subschema_types_by_name_and_location)
14
+ # - For each type candidate, compare interface fields to type candidate fields
15
+ # - For each type candidate field that matches an interface field...
16
+ # - Named types must match
17
+ # - List structures must match
18
+ # - Nullabilities must be >= interface field
19
+ # - It's OKAY if a type candidate does not implement the full interface
20
+ end
21
+
22
+ end
23
+ end
24
+ end