graphql-stitching 1.0.6 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbeabcd8c2327504cc5ccc613942d163bddddb2087e7eb90c966861777d67b9d
4
- data.tar.gz: cf03589e91ef96fb8dc9b74ca2703c20095e586bab45f91a89b3579376edbdac
3
+ metadata.gz: 9cdcb3361e391ba9b6dd78aa38c308c08ddb6fde2720d15208e8dfb96c3006ea
4
+ data.tar.gz: 9f64b024bda3987d1c523cf52b49f0ca2dfd688ac4c89bbf18de30c88c098156
5
5
  SHA512:
6
- metadata.gz: 78b5d6bbf4abccb99795c6225e1a61807ed74f94ad9ce5bdde0be534133e101c7d51f677d3c89aebaad7ebe2b125ff11a192a3d1a29cdacb3d3b51049801212f
7
- data.tar.gz: ade024c4ace1474d9c7a7cd80fb8820fc71e456e566d89fe656d07bf4b64e60db83561546a908b5d2fcbec9d88ac740a7080946e981a74f7bd904b3da0aeebfb
6
+ metadata.gz: b51bcfeaa20e9cdea6c2d4b4e472e8c23e13ec8af36e1b23852603d0329045af064e3ae738467a0823be0c9f545105acba001b708c207eb7f08d51ebf0791e1f
7
+ data.tar.gz: c529e74c88fc7193d7823d8c92a488b3e459b5ba9f88e8d62355ae2bd5e9eb0ed2b0d6f7fd14b6b8ed13ab1bb53a93bc5da49dcecd5deb92ee180fa70604c80b
@@ -16,7 +16,7 @@ jobs:
16
16
  ruby: 3.2
17
17
  - gemfile: Gemfile
18
18
  ruby: 3.1
19
- - gemfile: gemfiles/graphql_1.13.gemfile
19
+ - gemfile: gemfiles/graphql_1.13.9.gemfile
20
20
  ruby: 3.1
21
21
  - gemfile: Gemfile
22
22
  ruby: 2.7
@@ -30,6 +30,6 @@ jobs:
30
30
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
31
31
  - name: Run tests
32
32
  run: |
33
- gem install bundler
33
+ gem install bundler -v 2.4.22
34
34
  bundle install --jobs 4 --retry 3
35
35
  bundle exec rake test
data/README.md CHANGED
@@ -113,12 +113,12 @@ products_schema = <<~GRAPHQL
113
113
  }
114
114
  GRAPHQL
115
115
 
116
- shipping_schema = <<~GRAPHQL
116
+ catalog_schema = <<~GRAPHQL
117
117
  directive @stitch(key: String!) repeatable on FIELD_DEFINITION
118
118
 
119
119
  type Product {
120
120
  id: ID!
121
- weight: Float!
121
+ price: Float!
122
122
  }
123
123
 
124
124
  type Query {
@@ -131,7 +131,7 @@ client = GraphQL::Stitching::Client.new(locations: {
131
131
  schema: GraphQL::Schema.from_definition(products_schema),
132
132
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
133
133
  },
134
- shipping: {
134
+ catalog: {
135
135
  schema: GraphQL::Schema.from_definition(shipping_schema),
136
136
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
137
137
  },
@@ -151,9 +151,9 @@ type Query {
151
151
  ```
152
152
 
153
153
  * The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
154
- * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible (more on arguments later).
154
+ * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#multiple-query-arguments) later).
155
155
 
156
- Each location that provides a unique variant of a type must provide one stitching query per key. The exception to this requirement are types that contain only a single key field:
156
+ Each location that provides a unique variant of a type must provide at least one stitching query. The exception to this requirement are types that contain only a single key field:
157
157
 
158
158
  ```graphql
159
159
  type Product {
@@ -165,7 +165,7 @@ The above representation of a `Product` type provides no unique data beyond a ke
165
165
 
166
166
  #### List queries
167
167
 
168
- It's okay ([even preferable](https://www.youtube.com/watch?v=VmK0KBHTcWs) in many circumstances) to provide a list accessor as a stitching query. The only requirement is that both the field argument and return type must be lists, and the query results are expected to be a mapped set with `null` holding the position of missing results.
168
+ It's okay ([even preferable](#batching) in most circumstances) to provide a list accessor as a stitching query. The only requirement is that both the field argument and return type must be lists, and the query results are expected to be a mapped set with `null` holding the position of missing results.
169
169
 
170
170
  ```graphql
171
171
  type Query {
@@ -228,7 +228,7 @@ type Query {
228
228
  }
229
229
  ```
230
230
 
231
- The `@stitch` directive is also repeatable (_requires graphql-ruby >= v2.0.15_), allowing a single query to associate with multiple keys:
231
+ The `@stitch` directive is also repeatable, allowing a single query to associate with multiple keys:
232
232
 
233
233
  ```graphql
234
234
  type Product {
@@ -311,16 +311,15 @@ The [Apollo Federation specification](https://www.apollographql.com/docs/federat
311
311
  The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
312
312
 
313
313
  ```ruby
314
- accounts_schema = <<~GRAPHQL
314
+ products_schema = <<~GRAPHQL
315
315
  directive @key(fields: String!) repeatable on OBJECT
316
316
 
317
- type User @key(fields: "id") {
317
+ type Product @key(fields: "id") {
318
318
  id: ID!
319
319
  name: String!
320
- address: String!
321
320
  }
322
321
 
323
- union _Entity = User
322
+ union _Entity = Product
324
323
  scalar _Any
325
324
 
326
325
  type Query {
@@ -329,15 +328,15 @@ accounts_schema = <<~GRAPHQL
329
328
  }
330
329
  GRAPHQL
331
330
 
332
- comments_schema = <<~GRAPHQL
331
+ catalog_schema = <<~GRAPHQL
333
332
  directive @key(fields: String!) repeatable on OBJECT
334
333
 
335
- type User @key(fields: "id") {
334
+ type Product @key(fields: "id") {
336
335
  id: ID!
337
- comments: [String!]!
336
+ price: Float!
338
337
  }
339
338
 
340
- union _Entity = User
339
+ union _Entity = Product
341
340
  scalar _Any
342
341
 
343
342
  type Query {
@@ -346,12 +345,12 @@ comments_schema = <<~GRAPHQL
346
345
  GRAPHQL
347
346
 
348
347
  client = GraphQL::Stitching::Client.new(locations: {
349
- accounts: {
350
- schema: GraphQL::Schema.from_definition(accounts_schema),
348
+ products: {
349
+ schema: GraphQL::Schema.from_definition(products_schema),
351
350
  executable: ...,
352
351
  },
353
- comments: {
354
- schema: GraphQL::Schema.from_definition(comments_schema),
352
+ catalog: {
353
+ schema: GraphQL::Schema.from_definition(catalog_schema),
355
354
  executable: ...,
356
355
  },
357
356
  })
@@ -426,6 +425,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
426
425
 
427
426
  ## Additional topics
428
427
 
428
+ - [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
429
429
  - [Field selection routing](./docs/mechanics.md#field-selection-routing)
430
430
  - [Root selection routing](./docs/mechanics.md#root-selection-routing)
431
431
  - [Stitched errors](./docs/mechanics.md#stitched-errors)
data/docs/client.md CHANGED
@@ -25,11 +25,9 @@ client = GraphQL::Stitching::Client.new(locations: {
25
25
  Alternatively, you may pass a prebuilt `Supergraph` instance to the `Client` constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
26
26
 
27
27
  ```ruby
28
- exported_schema = "type Query { ..."
29
- exported_mapping = JSON.parse("{ ... }")
30
- supergraph = GraphQL::Stitching::Supergraph.from_export(
31
- schema: exported_schema,
32
- delegation_map: exported_mapping,
28
+ supergraph_sdl = File.read("precomposed_schema.graphql")
29
+ supergraph = GraphQL::Stitching::Supergraph.from_definition(
30
+ supergraph_sdl,
33
31
  executables: { ... },
34
32
  )
35
33
 
@@ -61,16 +59,16 @@ Arguments for the `execute` method include:
61
59
  The client provides cache hooks to enable caching query plans across requests. Without caching, every request made to the client 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.
62
60
 
63
61
  ```ruby
64
- client.on_cache_read do |key, _context, _request|
65
- $redis.get(key) # << 3P code
62
+ client.on_cache_read do |request|
63
+ $redis.get(request.digest) # << 3P code
66
64
  end
67
65
 
68
- client.on_cache_write do |key, payload, _context, _request|
69
- $redis.set(key, payload) # << 3P code
66
+ client.on_cache_write do |request, payload|
67
+ $redis.set(request.digest, payload) # << 3P code
70
68
  end
71
69
  ```
72
70
 
73
- Note that inlined input data works against caching, so you should _avoid_ this when possible:
71
+ Note that inlined input data works against caching, so you should _avoid_ these input literals when possible:
74
72
 
75
73
  ```graphql
76
74
  query {
@@ -93,11 +91,11 @@ query($id: ID!) {
93
91
  The client 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 client to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
94
92
 
95
93
  ```ruby
96
- client.on_error do |err, context|
94
+ client.on_error do |request, err|
97
95
  # log the error
98
96
  Bugsnag.notify(err)
99
97
 
100
98
  # return a formatted message for the public response
101
- "Whoops, please contact support abount request '#{context[:request_id]}'"
99
+ "Whoops, please contact support abount request '#{request.context[:request_id]}'"
102
100
  end
103
101
  ```
data/docs/executor.md CHANGED
@@ -16,6 +16,7 @@ request = GraphQL::Stitching::Request.new(
16
16
  query,
17
17
  variables: { "id" => "123" },
18
18
  operation_name: "MyQuery",
19
+ context: { ... },
19
20
  )
20
21
 
21
22
  plan = GraphQL::Stitching::Planner.new(
@@ -26,7 +27,7 @@ plan = GraphQL::Stitching::Planner.new(
26
27
  result = GraphQL::Stitching::Executor.new(
27
28
  supergraph: supergraph,
28
29
  request: request,
29
- plan: plan.to_h,
30
+ plan: plan,
30
31
  ).perform
31
32
  ```
32
33
 
@@ -39,7 +40,7 @@ By default, execution results are always returned with document shaping (stitchi
39
40
  raw_result = GraphQL::Stitching::Executor.new(
40
41
  supergraph: supergraph,
41
42
  request: request,
42
- plan: plan.to_h,
43
+ plan: plan,
43
44
  ).perform(raw: true)
44
45
  ```
45
46
 
data/docs/mechanics.md CHANGED
@@ -1,5 +1,59 @@
1
1
  ## Schema Stitching, mechanics
2
2
 
3
+ ### Deploying a stitched schema
4
+
5
+ Among the simplest and most effective ways to manage a stitched schema is to compose it locally, write the composed SDL as a `.graphql` file in your repo, and then load the composed schema into a stitching client at runtime. For example, setup a `rake` task that loads/fetches subgraph schemas, composes them, and then writes the composed schema definition as a file committed to the repo:
6
+
7
+ ```ruby
8
+ task :compose_graphql do
9
+ schema1_sdl = ... # load schema 1
10
+ schema2_sdl = ... # load schema 2
11
+
12
+ supergraph = GraphQL::Stitching::Composer.new.perform({
13
+ schema1: {
14
+ schema: GraphQL::Schema.from_definition(schema1_sdl)
15
+ },
16
+ schema2: {
17
+ schema: GraphQL::Schema.from_definition(schema2_sdl)
18
+ }
19
+ })
20
+
21
+ File.write("schema/supergraph.graphql", supergraph.to_definition)
22
+ puts "Schema composition was successful."
23
+ end
24
+
25
+ # bundle exec rake compose-graphql
26
+ ```
27
+
28
+ Then at runtime, load the composed schema into a stitching client:
29
+
30
+ ```ruby
31
+ class GraphQlController
32
+ class < self
33
+ def client
34
+ @client ||= begin
35
+ supergraph_sdl = File.read("schema/supergraph.graphql")
36
+ supergraph = GraphQL::Stitching::Supergraph.from_definition(supergraph_sdl, executables: {
37
+ schema1: GraphQL::Stitching::HttpExecutable.new("http://localhost:3001/graphql"),
38
+ schema2: GraphQL::Stitching::HttpExecutable.new("http://localhost:3002/graphql"),
39
+ })
40
+ GraphQL::Stitching::Client.new(supergraph: supergraph)
41
+ end
42
+ end
43
+ end
44
+
45
+ def exec
46
+ self.class.client.execute(
47
+ params[:query],
48
+ variables: params[:variables],
49
+ operation_name: params[:operation_name]
50
+ )
51
+ end
52
+ end
53
+ ```
54
+
55
+ This process assures that composition always happens before deployment where failures can be detected. Hot reloading of the supergraph can also be accommodated by uploading the composed schema to a sync location (cloud storage, etc) that is polled by the application runtime. When the schema changes, load it into a new stitching client and swap that into the application.
56
+
3
57
  ### Field selection routing
4
58
 
5
59
  Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
data/docs/planner.md CHANGED
@@ -32,15 +32,15 @@ Plans are designed to be cacheable. This is very useful for redundant GraphQL do
32
32
  cached_plan = $redis.get(request.digest)
33
33
 
34
34
  plan = if cached_plan
35
- JSON.parse(cached_plan)
35
+ GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
36
36
  else
37
- plan_hash = GraphQL::Stitching::Planner.new(
37
+ plan = GraphQL::Stitching::Planner.new(
38
38
  supergraph: supergraph,
39
39
  request: request,
40
- ).perform.to_h
40
+ ).perform
41
41
 
42
- $redis.set(request.digest, JSON.generate(plan_hash))
43
- plan_hash
42
+ $redis.set(request.digest, JSON.generate(plan.as_json))
43
+ plan
44
44
  end
45
45
 
46
46
  # execute the plan...
data/docs/request.md CHANGED
@@ -3,18 +3,27 @@
3
3
  A `Request` contains a parsed GraphQL document and variables, and handles the logistics of extracting the appropriate operation, variable definitions, and fragments. A `Request` should be built once per server request and passed through to other stitching components that utilize request information.
4
4
 
5
5
  ```ruby
6
- document = "query FetchMovie($id: ID!) { movie(id:$id) { id genre } }"
7
- request = GraphQL::Stitching::Request.new(document, variables: { "id" => "1" }, operation_name: "FetchMovie")
8
-
9
- request.document # parsed AST via GraphQL.parse
10
- request.variables # user-submitted variables
11
- request.string # normalized printed document string
12
- request.digest # SHA digest of the normalized document string
13
-
14
- request.variable_definitions # a mapping of variable names to their type definitions
15
- request.fragment_definitions # a mapping of fragment names to their fragment definitions
6
+ source = "query FetchMovie($id: ID!) { movie(id:$id) { id genre } }"
7
+ request = GraphQL::Stitching::Request.new(
8
+ source,
9
+ variables: { "id" => "1" },
10
+ operation_name: "FetchMovie",
11
+ context: { ... },
12
+ )
16
13
  ```
17
14
 
15
+ A `Request` provides the following information:
16
+
17
+ - `req.document`: parsed AST of the GraphQL source
18
+ - `req.variables`: a hash of user-submitted variables
19
+ - `req.string`: the original GraphQL source string, or printed document
20
+ - `req.digest`: a SHA2 of the request string
21
+ - `req.normalized_string`: printed document string with consistent whitespace
22
+ - `req.normalized_digest`: a SHA2 of the normalized string
23
+ - `req.operation`: the operation definition selected for the request
24
+ - `req.variable_definitions`: a mapping of variable names to their type definitions
25
+ - `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
26
+
18
27
  ### Preparing requests
19
28
 
20
29
  A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
data/docs/supergraph.md CHANGED
@@ -4,29 +4,25 @@ A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` i
4
4
 
5
5
  ### Export and caching
6
6
 
7
- 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 delegation mapping hash. These can be persisted in any raw format that suits your stack:
7
+ A Supergraph is designed to be composed, cached, and restored. Calling `to_definition` will return an SDL (Schema Definition Language) print of the combined graph schema with delegation mapping directives. This pre-composed schema can be persisted in any raw format that suits your stack:
8
8
 
9
9
  ```ruby
10
- supergraph_sdl, delegation_map = supergraph.export
10
+ supergraph_sdl = supergraph.to_definition
11
11
 
12
- # stash these resources in Redis...
12
+ # stash this composed schema in a cache...
13
13
  $redis.set("cached_supergraph_sdl", supergraph_sdl)
14
- $redis.set("cached_delegation_map", JSON.generate(delegation_map))
15
14
 
16
- # or, write the resources as files and commit them to your repo...
15
+ # or, write the composed schema as a file into your repo...
17
16
  File.write("supergraph/schema.graphql", supergraph_sdl)
18
- File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
19
17
  ```
20
18
 
21
- To restore a Supergraph, call `from_export` proving the cached SDL string, the parsed JSON delegation mapping, and a hash of executables keyed by their location names:
19
+ To restore a Supergraph, call `from_definition` providing the cached SDL string and a hash of executables keyed by their location names:
22
20
 
23
21
  ```ruby
24
22
  supergraph_sdl = $redis.get("cached_supergraph_sdl")
25
- delegation_map = JSON.parse($redis.get("cached_delegation_map"))
26
23
 
27
- supergraph = GraphQL::Stitching::Supergraph.from_export(
28
- schema: supergraph_sdl,
29
- delegation_map: delegation_map,
24
+ supergraph = GraphQL::Stitching::Supergraph.from_definition(
25
+ supergraph_sdl,
30
26
  executables: {
31
27
  my_remote: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
32
28
  my_local: MyLocalSchema,
@@ -20,6 +20,10 @@ module GraphQL
20
20
  composer ||= GraphQL::Stitching::Composer.new
21
21
  composer.perform(locations)
22
22
  end
23
+
24
+ @on_cache_read = nil
25
+ @on_cache_write = nil
26
+ @on_error = nil
23
27
  end
24
28
 
25
29
  def execute(query:, variables: nil, operation_name: nil, context: nil, validate: true)
@@ -52,7 +56,7 @@ module GraphQL
52
56
  rescue GraphQL::ParseError, GraphQL::ExecutionError => e
53
57
  error_result([e])
54
58
  rescue StandardError => e
55
- custom_message = @on_error.call(e, request.context) if @on_error
59
+ custom_message = @on_error.call(request, e) if @on_error
56
60
  error_result([{ "message" => custom_message || "An unexpected error occured." }])
57
61
  end
58
62
 
@@ -75,14 +79,14 @@ module GraphQL
75
79
 
76
80
  def fetch_plan(request)
77
81
  if @on_cache_read
78
- cached_plan = @on_cache_read.call(request.digest, request.context, request)
82
+ cached_plan = @on_cache_read.call(request)
79
83
  return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
80
84
  end
81
85
 
82
86
  plan = yield
83
87
 
84
88
  if @on_cache_write
85
- @on_cache_write.call(request.digest, JSON.generate(plan.as_json), request.context, request)
89
+ @on_cache_write.call(request, JSON.generate(plan.as_json))
86
90
  end
87
91
 
88
92
  plan
@@ -39,6 +39,12 @@ module GraphQL
39
39
  @directive_kwarg_merger = directive_kwarg_merger || BASIC_VALUE_MERGER
40
40
  @root_field_location_selector = root_field_location_selector || BASIC_ROOT_FIELD_LOCATION_SELECTOR
41
41
  @stitch_directives = {}
42
+
43
+ @field_map = nil
44
+ @boundary_map = nil
45
+ @mapped_type_names = nil
46
+ @candidate_directives_by_name_and_location = nil
47
+ @schema_directives = nil
42
48
  end
43
49
 
44
50
  def perform(locations_input)
@@ -541,7 +547,7 @@ module GraphQL
541
547
  field: field_candidate.name,
542
548
  arg: argument_name,
543
549
  list: boundary_structure.first.list?,
544
- federation: kwargs[:federation],
550
+ federation: kwargs[:federation] || false,
545
551
  )
546
552
  end
547
553
  end
@@ -8,6 +8,8 @@ module GraphQL
8
8
  EXPORT_PREFIX = "_export_"
9
9
 
10
10
  class << self
11
+ @typename_node = nil
12
+
11
13
  def key?(name)
12
14
  return false unless name
13
15
 
@@ -19,7 +21,11 @@ module GraphQL
19
21
  end
20
22
 
21
23
  def key_node(field_name)
22
- GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
24
+ if Util.graphql_version?(2, 2)
25
+ GraphQL::Language::Nodes::Field.new(field_alias: key(field_name), name: field_name)
26
+ else
27
+ GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
28
+ end
23
29
  end
24
30
 
25
31
  def typename_node
@@ -3,7 +3,7 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class Planner
6
- SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
6
+ SUPERGRAPH_LOCATIONS = [Supergraph::SUPERGRAPH_LOCATION].freeze
7
7
  TYPENAME = "__typename"
8
8
  QUERY_OP = "query"
9
9
  MUTATION_OP = "mutation"
@@ -9,6 +9,15 @@ module GraphQL
9
9
  attr_reader :document, :variables, :operation_name, :context
10
10
 
11
11
  def initialize(document, operation_name: nil, variables: nil, context: nil)
12
+ @string = nil
13
+ @digest = nil
14
+ @normalized_string = nil
15
+ @normalized_digest = nil
16
+ @operation = nil
17
+ @operation_directives = nil
18
+ @variable_definitions = nil
19
+ @fragment_definitions = nil
20
+
12
21
  @document = if document.is_a?(String)
13
22
  @string = document
14
23
  GraphQL.parse(document)
@@ -9,6 +9,7 @@ module GraphQL
9
9
  def initialize(supergraph:, request:)
10
10
  @supergraph = supergraph
11
11
  @request = request
12
+ @root_type = nil
12
13
  end
13
14
 
14
15
  def perform!(raw)
@@ -3,34 +3,89 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class Supergraph
6
- LOCATION = "__super"
6
+ SUPERGRAPH_LOCATION = "__super"
7
+
8
+ class ResolverDirective < GraphQL::Schema::Directive
9
+ graphql_name "resolver"
10
+ locations OBJECT, INTERFACE, UNION
11
+ argument :location, String, required: true
12
+ argument :key, String, required: true
13
+ argument :field, String, required: true
14
+ argument :arg, String, required: true
15
+ argument :list, Boolean, required: false
16
+ argument :federation, Boolean, required: false
17
+ repeatable true
18
+ end
7
19
 
8
- def self.validate_executable!(location, executable)
9
- return true if executable.is_a?(Class) && executable <= GraphQL::Schema
10
- return true if executable && executable.respond_to?(:call)
11
- raise StitchingError, "Invalid executable provided for location `#{location}`."
20
+ class SourceDirective < GraphQL::Schema::Directive
21
+ graphql_name "source"
22
+ locations FIELD_DEFINITION
23
+ argument :location, String, required: true
24
+ repeatable true
12
25
  end
13
26
 
14
- def self.from_export(schema:, delegation_map:, executables:)
15
- schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
27
+ class << self
28
+ def validate_executable!(location, executable)
29
+ return true if executable.is_a?(Class) && executable <= GraphQL::Schema
30
+ return true if executable && executable.respond_to?(:call)
31
+ raise StitchingError, "Invalid executable provided for location `#{location}`."
32
+ end
33
+
34
+ def from_definition(schema, executables:)
35
+ schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
36
+ field_map = {}
37
+ boundary_map = {}
38
+ possible_locations = {}
39
+ introspection_types = schema.introspection_system.types.keys
40
+
41
+ schema.types.each do |type_name, type|
42
+ next if introspection_types.include?(type_name)
43
+
44
+ type.directives.each do |directive|
45
+ next unless directive.graphql_name == ResolverDirective.graphql_name
46
+
47
+ kwargs = directive.arguments.keyword_arguments
48
+ boundary_map[type_name] ||= []
49
+ boundary_map[type_name] << Boundary.new(
50
+ type_name: type_name,
51
+ location: kwargs[:location],
52
+ key: kwargs[:key],
53
+ field: kwargs[:field],
54
+ arg: kwargs[:arg],
55
+ list: kwargs[:list] || false,
56
+ federation: kwargs[:federation] || false,
57
+ )
58
+ end
59
+
60
+ next unless type.kind.fields?
16
61
 
17
- executables = delegation_map["locations"].each_with_object({}) do |location, memo|
18
- executable = executables[location] || executables[location.to_sym]
19
- if validate_executable!(location, executable)
20
- memo[location] = executable
62
+ type.fields.each do |field_name, field|
63
+ field.directives.each do |d|
64
+ next unless d.graphql_name == SourceDirective.graphql_name
65
+
66
+ location = d.arguments.keyword_arguments[:location]
67
+ field_map[type_name] ||= {}
68
+ field_map[type_name][field_name] ||= []
69
+ field_map[type_name][field_name] << location
70
+ possible_locations[location] = true
71
+ end
72
+ end
21
73
  end
22
- end
23
74
 
24
- boundaries = delegation_map["boundaries"].map do |k, b|
25
- [k, b.map { Boundary.new(**_1) }]
26
- end
75
+ executables = possible_locations.keys.each_with_object({}) do |location, memo|
76
+ executable = executables[location] || executables[location.to_sym]
77
+ if validate_executable!(location, executable)
78
+ memo[location] = executable
79
+ end
80
+ end
27
81
 
28
- new(
29
- schema: schema,
30
- fields: delegation_map["fields"],
31
- boundaries: boundaries.to_h,
32
- executables: executables,
33
- )
82
+ new(
83
+ schema: schema,
84
+ fields: field_map,
85
+ boundaries: boundary_map,
86
+ executables: executables,
87
+ )
88
+ end
34
89
  end
35
90
 
36
91
  attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
@@ -42,38 +97,87 @@ module GraphQL
42
97
  @possible_keys_by_type_and_location = {}
43
98
  @memoized_schema_possible_types = {}
44
99
  @memoized_schema_fields = {}
100
+ @memoized_introspection_types = nil
101
+ @memoized_schema_types = nil
102
+ @fields_by_type_and_location = nil
103
+ @locations_by_type = nil
45
104
 
46
105
  # add introspection types into the fields mapping
47
106
  @locations_by_type_and_field = memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
48
107
  next unless type.kind.fields?
49
108
 
50
109
  memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
51
- m[field_name] = [LOCATION]
110
+ m[field_name] = [SUPERGRAPH_LOCATION]
52
111
  end
53
112
  end.freeze
54
113
 
55
114
  # validate and normalize executable references
56
- @executables = executables.each_with_object({ LOCATION => @schema }) do |(location, executable), memo|
115
+ @executables = executables.each_with_object({ SUPERGRAPH_LOCATION => @schema }) do |(location, executable), memo|
57
116
  if self.class.validate_executable!(location, executable)
58
117
  memo[location.to_s] = executable
59
118
  end
60
119
  end.freeze
61
120
  end
62
121
 
122
+ def to_definition
123
+ if @schema.directives[ResolverDirective.graphql_name].nil?
124
+ @schema.directive(ResolverDirective)
125
+ end
126
+ if @schema.directives[SourceDirective.graphql_name].nil?
127
+ @schema.directive(SourceDirective)
128
+ end
129
+
130
+ @schema.types.each do |type_name, type|
131
+ if boundaries_for_type = @boundaries.dig(type_name)
132
+ boundaries_for_type.each do |boundary|
133
+ existing = type.directives.find do |d|
134
+ kwargs = d.arguments.keyword_arguments
135
+ d.graphql_name == ResolverDirective.graphql_name &&
136
+ kwargs[:location] == boundary.location &&
137
+ kwargs[:key] == boundary.key &&
138
+ kwargs[:field] == boundary.field &&
139
+ kwargs[:arg] == boundary.arg &&
140
+ kwargs.fetch(:list, false) == boundary.list &&
141
+ kwargs.fetch(:federation, false) == boundary.federation
142
+ end
143
+
144
+ type.directive(ResolverDirective, **{
145
+ location: boundary.location,
146
+ key: boundary.key,
147
+ field: boundary.field,
148
+ arg: boundary.arg,
149
+ list: boundary.list || nil,
150
+ federation: boundary.federation || nil,
151
+ }.tap(&:compact!)) if existing.nil?
152
+ end
153
+ end
154
+
155
+ next unless type.kind.fields?
156
+
157
+ type.fields.each do |field_name, field|
158
+ locations_for_field = @locations_by_type_and_field.dig(type_name, field_name)
159
+ next if locations_for_field.nil?
160
+
161
+ locations_for_field.each do |location|
162
+ existing = field.directives.find do |d|
163
+ d.graphql_name == SourceDirective.graphql_name &&
164
+ d.arguments.keyword_arguments[:location] == location
165
+ end
166
+
167
+ field.directive(SourceDirective, location: location) if existing.nil?
168
+ end
169
+ end
170
+ end
171
+
172
+ @schema.to_definition
173
+ end
174
+
63
175
  def fields
64
176
  @locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
65
177
  end
66
178
 
67
179
  def locations
68
- @executables.keys.reject { _1 == LOCATION }
69
- end
70
-
71
- def export
72
- return GraphQL::Schema::Printer.print_schema(@schema), {
73
- "locations" => locations,
74
- "fields" => fields,
75
- "boundaries" => @boundaries.map { |k, b| [k, b.map(&:as_json)] }.to_h,
76
- }
180
+ @executables.keys.reject { _1 == SUPERGRAPH_LOCATION }
77
181
  end
78
182
 
79
183
  def memoized_introspection_types
@@ -12,7 +12,16 @@ module GraphQL
12
12
  end
13
13
  end
14
14
 
15
+ GRAPHQL_VERSION = GraphQL::VERSION.split(".").map(&:to_i).freeze
16
+
15
17
  class << self
18
+ def graphql_version?(major, minor = nil, patch = nil)
19
+ result = GRAPHQL_VERSION[0] >= major
20
+ result &&= GRAPHQL_VERSION[1] >= minor if minor
21
+ result &&= GRAPHQL_VERSION[2] >= patch if patch
22
+ result
23
+ end
24
+
16
25
  # specifies if a type is a primitive leaf value
17
26
  def is_leaf_type?(type)
18
27
  type.kind.scalar? || type.kind.enum?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.0.6"
5
+ VERSION = "1.1.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.6
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-11-29 00:00:00.000000000 Z
11
+ date: 2023-12-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql