graphql-stitching 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da2c14565c38d14e4e49f51ff2027f89a2ee4c0313102083c9ea3baadbcd02d4
4
- data.tar.gz: eec311845ed143af3f8d598c22f2b6026a46d34868fd7546abfb585a8fed6ebc
3
+ metadata.gz: 63b137317e57972e09c9926447c53ab26ac99a146557258e94b62abff305d096
4
+ data.tar.gz: e3a2217f5c04cc1a8a8412f15bb0ee5e6c4340869f945758735468c0903840f6
5
5
  SHA512:
6
- metadata.gz: 87608b9f5391ec190e0d0f534e1a58447f056b37e37c3f569d908b2c7043d40ef38732b3bff6e4975d81d34b04bb84cd88f809b364d73aeade68675f8c35f35f
7
- data.tar.gz: eb1e085ad36c36b52b0c871d9d8f3ff0ca5b6d29ff51c1ea6cd9564c0cdba8e554381615099a331426f4857b82049290287c79cc1338388a36027cb451aa3bb9
6
+ metadata.gz: 63ccfdcf28c02490946d2b7b9fef5dbe726cc47a214887010dc7dfa7f066d266bb7b2e5d899b13ad705060b8f7981904f47029a119c73c1aa582181d97ec6bec
7
+ data.tar.gz: d72baafd4419199658afbdba8a70a109a84da1e48e712344f568a5d4f75f5d0b2da3f3549a50370e528357fd003d9f8cbae825a490b2b0eed86c84528471855f
data/README.md CHANGED
@@ -9,6 +9,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
9
9
  - Merged object and abstract types joining though multiple keys.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
12
+ - [Visibility controls](./docs/visibility.md) for hiding schema elements.
12
13
  - [File uploads](./docs/http_executable.md) via multipart forms.
13
14
  - Tested with all minor versions of `graphql-ruby`.
14
15
 
@@ -171,11 +172,11 @@ GRAPHQL
171
172
  client = GraphQL::Stitching::Client.new(locations: {
172
173
  products: {
173
174
  schema: GraphQL::Schema.from_definition(products_schema),
174
- executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
175
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
175
176
  },
176
177
  catalog: {
177
178
  schema: GraphQL::Schema.from_definition(catalog_schema),
178
- executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
179
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
179
180
  },
180
181
  })
181
182
  ```
@@ -358,20 +359,12 @@ type Query {
358
359
 
359
360
  #### Class-based schemas
360
361
 
361
- The `@stitch` directive can be added to class-based schemas with a directive class:
362
+ The `@stitch` directive can be added to class-based schemas with a directive definition provided by the library:
362
363
 
363
364
  ```ruby
364
- class StitchingResolver < GraphQL::Schema::Directive
365
- graphql_name "stitch"
366
- locations FIELD_DEFINITION
367
- repeatable true
368
- argument :key, String, required: true
369
- argument :arguments, String, required: false
370
- end
371
-
372
365
  class Query < GraphQL::Schema::Object
373
366
  field :product, Product, null: false do
374
- directive StitchingResolver, key: "id"
367
+ directive GraphQL::Stitching::Directives::Stitch, key: "id"
375
368
  argument :id, ID, required: true
376
369
  end
377
370
  end
@@ -485,6 +478,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
485
478
 
486
479
  - [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
487
480
  - [Schema composition merge patterns](./docs/composer.md#merge-patterns)
481
+ - [Visibility controls](./docs/visibility.md)
488
482
  - [Subscriptions tutorial](./docs/subscriptions.md)
489
483
  - [Field selection routing](./docs/mechanics.md#field-selection-routing)
490
484
  - [Root selection routing](./docs/mechanics.md#root-selection-routing)
@@ -0,0 +1,168 @@
1
+ # Visibility
2
+
3
+ Visibility controls can hide parts of a supergraph from select audiences without compromising stitching operations. Restricted schema elements are hidden from introspection and validate as though they do not exist (which is different from traditional authorization where an element is acknowledged as restricted). Visibility is useful for managing multiple distributions of a schema for different audiences.
4
+
5
+ Under the hood, this system wraps `GraphQL::Schema::Visibility` (with nil profile support) and requires at least GraphQL Ruby v2.5.3.
6
+
7
+ ## Example
8
+
9
+ Schemas may include a `@visibility` directive that defines element _profiles_. A profile is just a label describing an API distribution (public, private, etc). When a request is assigned a visibility profile, it can only access elements belonging to that profile. Elements without an explicit `@visibility` constraint belong to all profiles. For example:
10
+
11
+ _schemas/product_info.graphql_
12
+ ```graphql
13
+ directive @stitch(key: String!) on FIELD_DEFINITION
14
+ directive @visibility(profiles: [String!]!) on OBJECT | INTERFACE | UNION | INPUT_OBJECT | ENUM | SCALAR | FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE
15
+
16
+ type Product {
17
+ id: ID!
18
+ title: String!
19
+ description: String!
20
+ }
21
+
22
+ type Query {
23
+ featuredProduct: Product
24
+ product(id: ID!): Product @stitch(key: "id") @visibility(profiles: ["private"])
25
+ }
26
+ ```
27
+
28
+ _schemas/product_prices.graphql_
29
+ ```graphql
30
+ directive @stitch(key: String!) on FIELD_DEFINITION
31
+ directive @visibility(profiles: [String!]!) on OBJECT | INTERFACE | UNION | INPUT_OBJECT | ENUM | SCALAR | FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE
32
+
33
+ type Product {
34
+ id: ID! @visibility(profiles: [])
35
+ msrp: Float! @visibility(profiles: ["private"])
36
+ price: Float!
37
+ }
38
+
39
+ type Query {
40
+ products(ids: [ID!]!): [Product]! @stitch(key: "id") @visibility(profiles: ["private"])
41
+ }
42
+ ```
43
+
44
+ When composing a stitching client, the names of all possible visibility profiles that the supergraph responds to should be specified in composer options:
45
+
46
+ ```ruby
47
+ client = GraphQL::Stitching::Client.new(
48
+ composer_options: {
49
+ visibility_profiles: ["public", "private"],
50
+ },
51
+ locations: {
52
+ info: {
53
+ schema: GraphQL::Schema.from_definition(File.read("schemas/product_info.graphql")),
54
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
55
+ },
56
+ prices: {
57
+ schema: GraphQL::Schema.from_definition(File.read("schemas/product_prices.graphql")),
58
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
59
+ },
60
+ }
61
+ )
62
+ ```
63
+
64
+ The client can then execute requests with a `visibility_profile` parameter in context that specifies the name of any profile the supergraph was composed with, or nil:
65
+
66
+ ```ruby
67
+ query = %|{
68
+ featuredProduct {
69
+ title # always visible
70
+ price # always visible
71
+ msrp # only visible to internal and nil profiles
72
+ id # only visible to nil profile
73
+ }
74
+ }|
75
+
76
+ result = client.execute(query, context: {
77
+ visibility_profile: "public", # << or private, or nil
78
+ })
79
+ ```
80
+
81
+ The `visibility_profile` parameter will select which visibility distribution to use while introspecting and validating the request. For example:
82
+
83
+ - Using `visibility_profile: "public"` will say the `msrp` field does not exist (because it is restricted to "private").
84
+ - Using `visibility_profile: "private"` will accesses the `msrp` field as usual.
85
+ - Using `visibility_profile: nil` will access the entire graph without any visibility constraints.
86
+
87
+ The full potential of visibility comes when hiding stitching implementation details, such as the `id` field (which is the stitching key for the Product type). While the `id` field is hidden from all named profiles, it remains operational for the stitching implementation.
88
+
89
+ ## Adding visibility directives
90
+
91
+ Add the `@visibility` directive into schemas using the library definition:
92
+
93
+ ```ruby
94
+ class QueryType < GraphQL::Schema::Object
95
+ field :my_field, String, null: true do |f|
96
+ f.directive(GraphQL::Stitching::Directives::Visibility, profiles: ["private"])
97
+ end
98
+ end
99
+
100
+ class MySchema < GraphQL::Schema
101
+ directive(GraphQL::Stitching::Directives::Visibility)
102
+ query(QueryType)
103
+ end
104
+ ```
105
+
106
+ ## Merging visibilities
107
+
108
+ Visibility directives merge across schemas into the narrowest constraint possible. Profile sets for an element will intersect into its supergraph constraint:
109
+
110
+ ```graphql
111
+ # location 1
112
+ myField: String @visibility(profiles: ["a", "c"])
113
+
114
+ # location 2
115
+ myField: String @visibility(profiles: ["b", "c"])
116
+
117
+ # merged supergraph
118
+ myField: String @visibility(profiles: ["c"])
119
+ ```
120
+
121
+ This may cause an element's profiles to intersect into an empty set, which means the element belongs to no profiles and will be hidden from all named distributions:
122
+
123
+ ```graphql
124
+ # location 1
125
+ myField: String @visibility(profiles: ["a"])
126
+
127
+ # location 2
128
+ myField: String @visibility(profiles: ["b"])
129
+
130
+ # merged supergraph
131
+ myField: String @visibility(profiles: [])
132
+ ```
133
+
134
+ Locations may omit visibility information to give other locations full control. Remember that elements without a `@visibility` constraint belong to all profiles, which also applies while merging:
135
+
136
+ ```graphql
137
+ # location 1
138
+ myField: String
139
+
140
+ # location 2
141
+ myField: String @visibility(profiles: ["b"])
142
+
143
+ # merged supergraph
144
+ myField: String @visibility(profiles: ["b"])
145
+ ```
146
+
147
+ ## Type controls
148
+
149
+ Visibility controls can be applied to almost all GraphQL schema elements, including:
150
+
151
+ - Types (Object, Interface, Union, Enum, Scalar, InputObject)
152
+ - Fields (of Object and Interface)
153
+ - Arguments (of Field and InputObject)
154
+ - Enum values
155
+
156
+ While the visibility of type members (fields, arguments, and enum values) are pretty intuitive, the visibility of parent types is far more nuanced as constraints start to cascade:
157
+
158
+ ```graphql
159
+ type Widget @visibility(profiles: ["private"]) {
160
+ title: String
161
+ }
162
+
163
+ type Query {
164
+ widget: Widget # << GETS HIDDEN
165
+ }
166
+ ```
167
+
168
+ In this example, hiding the `Widget` type will also hide the `Query.widget` field that returns it.
@@ -13,16 +13,18 @@ module GraphQL
13
13
  # Builds a new client instance. Either `supergraph` or `locations` configuration is required.
14
14
  # @param supergraph [Supergraph] optional, a pre-composed supergraph that bypasses composer setup.
15
15
  # @param locations [Hash<Symbol, Hash<Symbol, untyped>>] optional, composer configurations for each graph location.
16
- # @param composer [Composer] optional, a pre-configured composer instance for use with `locations` configuration.
17
- def initialize(locations: nil, supergraph: nil, composer: nil)
16
+ # @param composer_options [Hash] optional, composer options for configuring composition.
17
+ def initialize(locations: nil, supergraph: nil, composer_options: {})
18
18
  @supergraph = if locations && supergraph
19
19
  raise ArgumentError, "Cannot provide both locations and a supergraph."
20
20
  elsif supergraph && !supergraph.is_a?(Supergraph)
21
21
  raise ArgumentError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
22
+ elsif supergraph && composer_options.any?
23
+ raise ArgumentError, "Cannot provide composer options with a pre-built supergraph."
22
24
  elsif supergraph
23
25
  supergraph
24
26
  else
25
- composer ||= Composer.new
27
+ composer = Composer.new(**composer_options)
26
28
  composer.perform(locations)
27
29
  end
28
30
 
@@ -22,6 +22,9 @@ module GraphQL
22
22
 
23
23
  # @api private
24
24
  BASIC_VALUE_MERGER = ->(values_by_location, _info) { values_by_location.values.find { !_1.nil? } }
25
+
26
+ # @api private
27
+ VISIBILITY_PROFILES_MERGER = ->(values_by_location, _info) { values_by_location.values.reduce(:&) }
25
28
 
26
29
  # @api private
27
30
  BASIC_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
@@ -51,6 +54,7 @@ module GraphQL
51
54
  query_name: "Query",
52
55
  mutation_name: "Mutation",
53
56
  subscription_name: "Subscription",
57
+ visibility_profiles: [],
54
58
  description_merger: nil,
55
59
  deprecation_merger: nil,
56
60
  default_value_merger: nil,
@@ -70,6 +74,7 @@ module GraphQL
70
74
  @resolver_map = {}
71
75
  @resolver_configs = {}
72
76
  @mapped_type_names = {}
77
+ @visibility_profiles = Set.new(visibility_profiles)
73
78
  @subgraph_directives_by_name_and_location = nil
74
79
  @subgraph_types_by_name_and_location = nil
75
80
  @schema_directives = nil
@@ -82,9 +87,16 @@ module GraphQL
82
87
 
83
88
  schemas, executables = prepare_locations_input(locations_input)
84
89
 
90
+ directives_to_omit = [
91
+ GraphQL::Stitching.stitch_directive,
92
+ Directives::SupergraphKey.graphql_name,
93
+ Directives::SupergraphResolver.graphql_name,
94
+ Directives::SupergraphSource.graphql_name,
95
+ ]
96
+
85
97
  # "directive_name" => "location" => subgraph_directive
86
98
  @subgraph_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
87
- (schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
99
+ (schema.directives.keys - schema.default_directives.keys - directives_to_omit).each do |directive_name|
88
100
  memo[directive_name] ||= {}
89
101
  memo[directive_name][location] = schema.directives[directive_name]
90
102
  end
@@ -154,25 +166,30 @@ module GraphQL
154
166
 
155
167
  builder = self
156
168
  schema = Class.new(GraphQL::Schema) do
169
+ object_types = schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? }
157
170
  add_type_and_traverse(schema_types.values, root: false)
158
- orphan_types(schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? })
171
+ orphan_types(object_types)
159
172
  query schema_types[builder.query_name]
160
173
  mutation schema_types[builder.mutation_name]
161
174
  subscription schema_types[builder.subscription_name]
162
175
  directives builder.schema_directives.values
163
176
 
177
+ object_types.each do |t|
178
+ t.interfaces.each { _1.orphan_types(t) }
179
+ end
180
+
164
181
  own_orphan_types.clear
165
182
  end
166
183
 
167
184
  select_root_field_locations(schema)
168
185
  expand_abstract_resolvers(schema, schemas)
186
+ apply_supergraph_directives(schema, @resolver_map, @field_map)
169
187
 
170
- supergraph = Supergraph.new(
171
- schema: schema,
172
- fields: @field_map,
173
- resolvers: @resolver_map,
174
- executables: executables,
175
- )
188
+ if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive])
189
+ visibility_def.get_argument("profiles").default_value(@visibility_profiles.to_a.sort)
190
+ end
191
+
192
+ supergraph = Supergraph.from_definition(schema, executables: executables)
176
193
 
177
194
  COMPOSITION_VALIDATORS.each do |validator_class|
178
195
  validator_class.new.perform(supergraph, self)
@@ -228,7 +245,7 @@ module GraphQL
228
245
 
229
246
  builder = self
230
247
 
231
- Class.new(GraphQL::Schema::Scalar) do
248
+ Class.new(GraphQL::Stitching::Supergraph::ScalarType) do
232
249
  graphql_name(type_name)
233
250
  description(builder.merge_descriptions(type_name, types_by_location))
234
251
  builder.build_merged_directives(type_name, types_by_location, self)
@@ -255,7 +272,7 @@ module GraphQL
255
272
  end
256
273
  end
257
274
 
258
- Class.new(GraphQL::Schema::Enum) do
275
+ Class.new(GraphQL::Stitching::Supergraph::EnumType) do
259
276
  graphql_name(type_name)
260
277
  description(builder.merge_descriptions(type_name, types_by_location))
261
278
  builder.build_merged_directives(type_name, types_by_location, self)
@@ -277,7 +294,7 @@ module GraphQL
277
294
  def build_object_type(type_name, types_by_location)
278
295
  builder = self
279
296
 
280
- Class.new(GraphQL::Schema::Object) do
297
+ Class.new(GraphQL::Stitching::Supergraph::ObjectType) do
281
298
  graphql_name(type_name)
282
299
  description(builder.merge_descriptions(type_name, types_by_location))
283
300
 
@@ -297,7 +314,7 @@ module GraphQL
297
314
  builder = self
298
315
 
299
316
  Module.new do
300
- include GraphQL::Schema::Interface
317
+ include GraphQL::Stitching::Supergraph::InterfaceType
301
318
  graphql_name(type_name)
302
319
  description(builder.merge_descriptions(type_name, types_by_location))
303
320
 
@@ -316,7 +333,7 @@ module GraphQL
316
333
  def build_union_type(type_name, types_by_location)
317
334
  builder = self
318
335
 
319
- Class.new(GraphQL::Schema::Union) do
336
+ Class.new(GraphQL::Stitching::Supergraph::UnionType) do
320
337
  graphql_name(type_name)
321
338
  description(builder.merge_descriptions(type_name, types_by_location))
322
339
 
@@ -331,7 +348,7 @@ module GraphQL
331
348
  def build_input_object_type(type_name, types_by_location)
332
349
  builder = self
333
350
 
334
- Class.new(GraphQL::Schema::InputObject) do
351
+ Class.new(GraphQL::Stitching::Supergraph::InputObjectType) do
335
352
  graphql_name(type_name)
336
353
  description(builder.merge_descriptions(type_name, types_by_location))
337
354
  builder.build_merged_arguments(type_name, types_by_location, self)
@@ -442,6 +459,7 @@ module GraphQL
442
459
  end
443
460
 
444
461
  directives_by_name_location.each do |directive_name, directives_by_location|
462
+ kwarg_merger = @directive_kwarg_merger
445
463
  directive_class = @schema_directives[directive_name]
446
464
  next unless directive_class
447
465
 
@@ -458,8 +476,20 @@ module GraphQL
458
476
  end
459
477
  end
460
478
 
479
+ if directive_class.graphql_name == GraphQL::Stitching.visibility_directive
480
+ unless GraphQL::Stitching.supports_visibility?
481
+ raise CompositionError, "Using `@#{GraphQL::Stitching.visibility_directive}` directive " \
482
+ "for schema visibility controls requires GraphQL Ruby v#{GraphQL::Stitching::MIN_VISIBILITY_VERSION} or later."
483
+ end
484
+
485
+ if (profiles = kwarg_values_by_name_location["profiles"])
486
+ @visibility_profiles.merge(profiles.each_value.reduce(&:|))
487
+ kwarg_merger = VISIBILITY_PROFILES_MERGER
488
+ end
489
+ end
490
+
461
491
  kwargs = kwarg_values_by_name_location.each_with_object({}) do |(kwarg_name, kwarg_values_by_location), memo|
462
- memo[kwarg_name.to_sym] = @directive_kwarg_merger.call(kwarg_values_by_location, {
492
+ memo[kwarg_name.to_sym] = kwarg_merger.call(kwarg_values_by_location, {
463
493
  type_name: type_name,
464
494
  field_name: field_name,
465
495
  argument_name: argument_name,
@@ -670,6 +700,72 @@ module GraphQL
670
700
  memo[enum_name] << :write
671
701
  end
672
702
  end
703
+
704
+ def apply_supergraph_directives(schema, resolvers_by_type_name, locations_by_type_and_field)
705
+ schema_directives = {}
706
+ schema.types.each do |type_name, type|
707
+ if resolvers_for_type = resolvers_by_type_name.dig(type_name)
708
+ # Apply key directives for each unique type/key/location
709
+ # (this allows keys to be composite selections and/or omitted from the supergraph schema)
710
+ keys_for_type = resolvers_for_type.each_with_object({}) do |resolver, memo|
711
+ memo[resolver.key.to_definition] ||= Set.new
712
+ memo[resolver.key.to_definition].merge(resolver.key.locations)
713
+ end
714
+
715
+ keys_for_type.each do |key, locations|
716
+ locations.each do |location|
717
+ schema_directives[Directives::SupergraphKey.graphql_name] ||= Directives::SupergraphKey
718
+ type.directive(Directives::SupergraphKey, key: key, location: location)
719
+ end
720
+ end
721
+
722
+ # Apply resolver directives for each unique query resolver
723
+ resolvers_for_type.each do |resolver|
724
+ params = {
725
+ location: resolver.location,
726
+ field: resolver.field,
727
+ list: resolver.list? || nil,
728
+ key: resolver.key.to_definition,
729
+ arguments: resolver.arguments.map(&:to_definition).join(", "),
730
+ argument_types: resolver.arguments.map(&:to_type_definition).join(", "),
731
+ type_name: (resolver.type_name if resolver.type_name != type_name),
732
+ }
733
+
734
+ schema_directives[Directives::SupergraphResolver.graphql_name] ||= Directives::SupergraphResolver
735
+ type.directive(Directives::SupergraphResolver, **params.tap(&:compact!))
736
+ end
737
+ end
738
+
739
+ next unless type.kind.fields? && !type.introspection?
740
+
741
+ type.fields.each do |field_name, field|
742
+ if field.owner != type
743
+ # make a local copy of fields inherited from an interface
744
+ # to assure that source attributions reflect the object, not the interface.
745
+ field = type.field(
746
+ field.graphql_name,
747
+ description: field.description,
748
+ deprecation_reason: field.deprecation_reason,
749
+ type: Util.unwrap_non_null(field.type),
750
+ null: !field.type.non_null?,
751
+ connection: false,
752
+ camelize: false,
753
+ )
754
+ end
755
+
756
+ locations_for_field = locations_by_type_and_field.dig(type_name, field_name)
757
+ next if locations_for_field.nil?
758
+
759
+ # Apply source directives to annotate the possible locations of each field
760
+ locations_for_field.each do |location|
761
+ schema_directives[Directives::SupergraphSource.graphql_name] ||= Directives::SupergraphSource
762
+ field.directive(Directives::SupergraphSource, location: location)
763
+ end
764
+ end
765
+ end
766
+
767
+ schema_directives.each_value { |directive_class| schema.directive(directive_class) }
768
+ end
673
769
  end
674
770
  end
675
771
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ module Directives
5
+ class Stitch < GraphQL::Schema::Directive
6
+ graphql_name "stitch"
7
+ locations FIELD_DEFINITION
8
+ argument :key, String, required: true
9
+ argument :arguments, String, required: false
10
+ argument :type_name, String, required: false
11
+ repeatable true
12
+ end
13
+
14
+ class Visibility < GraphQL::Schema::Directive
15
+ graphql_name "visibility"
16
+ locations(
17
+ OBJECT, INTERFACE, UNION, INPUT_OBJECT, ENUM, SCALAR,
18
+ FIELD_DEFINITION, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITION, ENUM_VALUE
19
+ )
20
+ argument :profiles, [String, null: false], required: true
21
+ end
22
+
23
+ class SupergraphKey < GraphQL::Schema::Directive
24
+ graphql_name "key"
25
+ locations OBJECT, INTERFACE, UNION
26
+ argument :key, String, required: true
27
+ argument :location, String, required: true
28
+ repeatable true
29
+ end
30
+
31
+ class SupergraphResolver < GraphQL::Schema::Directive
32
+ graphql_name "resolver"
33
+ locations OBJECT, INTERFACE, UNION
34
+ argument :location, String, required: true
35
+ argument :list, Boolean, required: false
36
+ argument :key, String, required: true
37
+ argument :field, String, required: true
38
+ argument :arguments, String, required: true
39
+ argument :argument_types, String, required: true
40
+ argument :type_name, String, required: false
41
+ repeatable true
42
+ end
43
+
44
+ class SupergraphSource < GraphQL::Schema::Directive
45
+ graphql_name "source"
46
+ locations FIELD_DEFINITION
47
+ argument :location, String, required: true
48
+ repeatable true
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Supergraph
5
+ class << self
6
+ def validate_executable!(location, executable)
7
+ return true if executable.is_a?(Class) && executable <= GraphQL::Schema
8
+ return true if executable && executable.respond_to?(:call)
9
+ raise StitchingError, "Invalid executable provided for location `#{location}`."
10
+ end
11
+
12
+ def from_definition(schema, executables:)
13
+ if schema.is_a?(String)
14
+ schema = if GraphQL::Stitching.supports_visibility?
15
+ GraphQL::Schema.from_definition(schema, base_types: BASE_TYPES)
16
+ else
17
+ GraphQL::Schema.from_definition(schema)
18
+ end
19
+ end
20
+
21
+ field_map = {}
22
+ resolver_map = {}
23
+ possible_locations = {}
24
+ visibility_profiles = if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive])
25
+ visibility_def.get_argument("profiles").default_value
26
+ else
27
+ []
28
+ end
29
+
30
+ schema.types.each do |type_name, type|
31
+ next if type.introspection?
32
+
33
+ # Collect/build key definitions for each type
34
+ locations_by_key = type.directives.each_with_object({}) do |directive, memo|
35
+ next unless directive.graphql_name == Directives::SupergraphKey.graphql_name
36
+
37
+ kwargs = directive.arguments.keyword_arguments
38
+ memo[kwargs[:key]] ||= []
39
+ memo[kwargs[:key]] << kwargs[:location]
40
+ end
41
+
42
+ key_definitions = locations_by_key.each_with_object({}) do |(key, locations), memo|
43
+ memo[key] = TypeResolver.parse_key(key, locations)
44
+ end
45
+
46
+ # Collect/build resolver definitions for each type
47
+ type.directives.each do |d|
48
+ next unless d.graphql_name == Directives::SupergraphResolver.graphql_name
49
+
50
+ kwargs = d.arguments.keyword_arguments
51
+ resolver_map[type_name] ||= []
52
+ resolver_map[type_name] << TypeResolver.new(
53
+ location: kwargs[:location],
54
+ type_name: kwargs.fetch(:type_name, type_name),
55
+ field: kwargs[:field],
56
+ list: kwargs[:list] || false,
57
+ key: key_definitions[kwargs[:key]],
58
+ arguments: TypeResolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]),
59
+ )
60
+ end
61
+
62
+ next unless type.kind.fields?
63
+
64
+ type.fields.each do |field_name, field|
65
+ # Collection locations for each field definition
66
+ field.directives.each do |d|
67
+ next unless d.graphql_name == Directives::SupergraphSource.graphql_name
68
+
69
+ location = d.arguments.keyword_arguments[:location]
70
+ field_map[type_name] ||= {}
71
+ field_map[type_name][field_name] ||= []
72
+ field_map[type_name][field_name] << location
73
+ possible_locations[location] = true
74
+ end
75
+ end
76
+ end
77
+
78
+ executables = possible_locations.keys.each_with_object({}) do |location, memo|
79
+ executable = executables[location] || executables[location.to_sym]
80
+ if validate_executable!(location, executable)
81
+ memo[location] = executable
82
+ end
83
+ end
84
+
85
+ new(
86
+ schema: schema,
87
+ fields: field_map,
88
+ resolvers: resolver_map,
89
+ visibility_profiles: visibility_profiles,
90
+ executables: executables,
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Supergraph
5
+ module Visibility
6
+ def visible?(ctx)
7
+ profile = ctx[:visibility_profile]
8
+ return true if profile.nil?
9
+
10
+ directive = directives.find { _1.graphql_name == GraphQL::Stitching.visibility_directive }
11
+ return true if directive.nil?
12
+
13
+ profiles = directive.arguments.keyword_arguments[:profiles]
14
+ return true if profiles.nil?
15
+
16
+ profiles.include?(profile)
17
+ end
18
+ end
19
+
20
+ class ArgumentType < GraphQL::Schema::Argument
21
+ include Visibility
22
+ end
23
+
24
+ class FieldType < GraphQL::Schema::Field
25
+ include Visibility
26
+ argument_class(ArgumentType)
27
+ end
28
+
29
+ class InputObjectType < GraphQL::Schema::InputObject
30
+ extend Visibility
31
+ argument_class(ArgumentType)
32
+ end
33
+
34
+ module InterfaceType
35
+ include GraphQL::Schema::Interface
36
+ field_class(FieldType)
37
+
38
+ definition_methods do
39
+ include Visibility
40
+ end
41
+ end
42
+
43
+ class ObjectType < GraphQL::Schema::Object
44
+ extend Visibility
45
+ field_class(FieldType)
46
+ end
47
+
48
+ class EnumValueType < GraphQL::Schema::EnumValue
49
+ include Visibility
50
+ end
51
+
52
+ class EnumType < GraphQL::Schema::Enum
53
+ extend Visibility
54
+ enum_value_class(EnumValueType)
55
+ end
56
+
57
+ class ScalarType < GraphQL::Schema::Scalar
58
+ extend Visibility
59
+ end
60
+
61
+ class UnionType < GraphQL::Schema::Union
62
+ extend Visibility
63
+ end
64
+
65
+ BASE_TYPES = {
66
+ enum: EnumType,
67
+ input_object: InputObjectType,
68
+ interface: InterfaceType,
69
+ object: ObjectType,
70
+ scalar: ScalarType,
71
+ union: UnionType,
72
+ }.freeze
73
+ end
74
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "supergraph/to_definition"
3
+ require_relative "supergraph/types"
4
+ require_relative "supergraph/from_definition"
4
5
 
5
6
  module GraphQL
6
7
  module Stitching
@@ -16,23 +17,25 @@ module GraphQL
16
17
  # @return [Hash<String, Executable>] a map of executable resources by location.
17
18
  attr_reader :executables
18
19
 
19
- attr_reader :resolvers, :locations_by_type_and_field
20
+ attr_reader :resolvers
21
+ attr_reader :memoized_schema_types
22
+ attr_reader :memoized_introspection_types
23
+ attr_reader :locations_by_type_and_field
20
24
 
21
- def initialize(schema:, fields: {}, resolvers: {}, executables: {})
25
+ def initialize(schema:, fields: {}, resolvers: {}, visibility_profiles: [], executables: {})
22
26
  @schema = schema
23
27
  @resolvers = resolvers
24
28
  @resolvers_by_version = nil
25
29
  @fields_by_type_and_location = nil
26
30
  @locations_by_type = nil
27
- @memoized_introspection_types = nil
31
+ @memoized_introspection_types = @schema.introspection_system.types
32
+ @memoized_schema_types = @schema.types
28
33
  @memoized_schema_fields = {}
29
- @memoized_schema_types = nil
30
34
  @possible_keys_by_type = {}
31
35
  @possible_keys_by_type_and_location = {}
32
- @static_validator = nil
33
36
 
34
37
  # add introspection types into the fields mapping
35
- @locations_by_type_and_field = memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
38
+ @locations_by_type_and_field = @memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
36
39
  next unless type.kind.fields?
37
40
 
38
41
  memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
@@ -46,6 +49,17 @@ module GraphQL
46
49
  memo[location.to_s] = executable
47
50
  end
48
51
  end.freeze
52
+
53
+ if visibility_profiles.any?
54
+ profiles = visibility_profiles.each_with_object({ nil => {} }) { |p, m| m[p.to_s] = {} }
55
+ @schema.use(GraphQL::Schema::Visibility, profiles: profiles)
56
+ else
57
+ @schema.use(GraphQL::Schema::AlwaysVisible)
58
+ end
59
+ end
60
+
61
+ def to_definition
62
+ @schema.to_definition
49
63
  end
50
64
 
51
65
  def resolvers_by_version
@@ -62,17 +76,9 @@ module GraphQL
62
76
  @executables.keys.reject { _1 == SUPERGRAPH_LOCATION }
63
77
  end
64
78
 
65
- def memoized_introspection_types
66
- @memoized_introspection_types ||= schema.introspection_system.types
67
- end
68
-
69
- def memoized_schema_types
70
- @memoized_schema_types ||= @schema.types
71
- end
72
-
73
79
  def memoized_schema_fields(type_name)
74
80
  @memoized_schema_fields[type_name] ||= begin
75
- fields = memoized_schema_types[type_name].fields
81
+ fields = @memoized_schema_types[type_name].fields
76
82
  @schema.introspection_system.dynamic_fields.each do |field|
77
83
  fields[field.name] ||= field # adds __typename
78
84
  end
@@ -65,6 +65,10 @@ module GraphQL
65
65
  argument_types: arguments.map(&:to_type_definition).join(", "),
66
66
  }.tap(&:compact!)
67
67
  end
68
+
69
+ def inspect
70
+ as_json.to_json
71
+ end
68
72
  end
69
73
  end
70
74
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.6.1"
5
+ VERSION = "1.7.0"
6
6
  end
7
7
  end
@@ -32,8 +32,6 @@ module GraphQL
32
32
  end
33
33
 
34
34
  class << self
35
- attr_writer :stitch_directive
36
-
37
35
  # Proc used to compute digests; uses SHA2 by default.
38
36
  # @returns [Proc] proc used to compute digests.
39
37
  def digest(&block)
@@ -50,15 +48,30 @@ module GraphQL
50
48
  @stitch_directive ||= "stitch"
51
49
  end
52
50
 
53
- # Names of stitching directives to omit from the composed supergraph.
54
- # @returns [Array<String>] list of stitching directive names.
55
- def stitching_directive_names
56
- [stitch_directive]
51
+ attr_writer :stitch_directive
52
+
53
+ # Name of the directive used to denote member visibilities.
54
+ # @returns [String] name of the visibility directive.
55
+ def visibility_directive
56
+ @visibility_directive ||= "visibility"
57
+ end
58
+
59
+ attr_writer :visibility_directive
60
+
61
+ MIN_VISIBILITY_VERSION = "2.5.3"
62
+
63
+ # @returns Boolean true if GraphQL::Schema::Visibility is fully supported
64
+ def supports_visibility?
65
+ return @supports_visibility if defined?(@supports_visibility)
66
+
67
+ # Requires `Visibility` (v2.4) with nil profile support (v2.5.3)
68
+ @supports_visibility = Gem::Version.new(GraphQL::VERSION) >= Gem::Version.new(MIN_VISIBILITY_VERSION)
57
69
  end
58
70
  end
59
71
  end
60
72
  end
61
73
 
74
+ require_relative "stitching/directives"
62
75
  require_relative "stitching/supergraph"
63
76
  require_relative "stitching/client"
64
77
  require_relative "stitching/composer"
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.6.1
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-22 00:00:00.000000000 Z
11
+ date: 2025-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -92,6 +92,7 @@ files:
92
92
  - docs/subscriptions.md
93
93
  - docs/supergraph.md
94
94
  - docs/type_resolver.md
95
+ - docs/visibility.md
95
96
  - examples/file_uploads/Gemfile
96
97
  - examples/file_uploads/Procfile
97
98
  - examples/file_uploads/README.md
@@ -164,6 +165,7 @@ files:
164
165
  - lib/graphql/stitching/composer/type_resolver_config.rb
165
166
  - lib/graphql/stitching/composer/validate_interfaces.rb
166
167
  - lib/graphql/stitching/composer/validate_type_resolvers.rb
168
+ - lib/graphql/stitching/directives.rb
167
169
  - lib/graphql/stitching/executor.rb
168
170
  - lib/graphql/stitching/executor/root_source.rb
169
171
  - lib/graphql/stitching/executor/shaper.rb
@@ -175,10 +177,8 @@ files:
175
177
  - lib/graphql/stitching/request.rb
176
178
  - lib/graphql/stitching/request/skip_include.rb
177
179
  - lib/graphql/stitching/supergraph.rb
178
- - lib/graphql/stitching/supergraph/key_directive.rb
179
- - lib/graphql/stitching/supergraph/resolver_directive.rb
180
- - lib/graphql/stitching/supergraph/source_directive.rb
181
- - lib/graphql/stitching/supergraph/to_definition.rb
180
+ - lib/graphql/stitching/supergraph/from_definition.rb
181
+ - lib/graphql/stitching/supergraph/types.rb
182
182
  - lib/graphql/stitching/type_resolver.rb
183
183
  - lib/graphql/stitching/type_resolver/arguments.rb
184
184
  - lib/graphql/stitching/type_resolver/keys.rb
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GraphQL::Stitching
4
- class Supergraph
5
- class KeyDirective < GraphQL::Schema::Directive
6
- graphql_name "key"
7
- locations OBJECT, INTERFACE, UNION
8
- argument :key, String, required: true
9
- argument :location, String, required: true
10
- repeatable true
11
- end
12
- end
13
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GraphQL::Stitching
4
- class Supergraph
5
- class ResolverDirective < GraphQL::Schema::Directive
6
- graphql_name "resolver"
7
- locations OBJECT, INTERFACE, UNION
8
- argument :location, String, required: true
9
- argument :list, Boolean, required: false
10
- argument :key, String, required: true
11
- argument :field, String, required: true
12
- argument :arguments, String, required: true
13
- argument :argument_types, String, required: true
14
- argument :type_name, String, required: false
15
- repeatable true
16
- end
17
- end
18
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GraphQL::Stitching
4
- class Supergraph
5
- class SourceDirective < GraphQL::Schema::Directive
6
- graphql_name "source"
7
- locations FIELD_DEFINITION
8
- argument :location, String, required: true
9
- repeatable true
10
- end
11
- end
12
- end
@@ -1,164 +0,0 @@
1
- # frozen_string_literal: true
2
- require_relative "./key_directive"
3
- require_relative "./resolver_directive"
4
- require_relative "./source_directive"
5
-
6
- module GraphQL::Stitching
7
- class Supergraph
8
- class << self
9
- def validate_executable!(location, executable)
10
- return true if executable.is_a?(Class) && executable <= GraphQL::Schema
11
- return true if executable && executable.respond_to?(:call)
12
- raise StitchingError, "Invalid executable provided for location `#{location}`."
13
- end
14
-
15
- def from_definition(schema, executables:)
16
- schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
17
- field_map = {}
18
- resolver_map = {}
19
- possible_locations = {}
20
-
21
- schema.types.each do |type_name, type|
22
- next if type.introspection?
23
-
24
- # Collect/build key definitions for each type
25
- locations_by_key = type.directives.each_with_object({}) do |directive, memo|
26
- next unless directive.graphql_name == KeyDirective.graphql_name
27
-
28
- kwargs = directive.arguments.keyword_arguments
29
- memo[kwargs[:key]] ||= []
30
- memo[kwargs[:key]] << kwargs[:location]
31
- end
32
-
33
- key_definitions = locations_by_key.each_with_object({}) do |(key, locations), memo|
34
- memo[key] = TypeResolver.parse_key(key, locations)
35
- end
36
-
37
- # Collect/build resolver definitions for each type
38
- type.directives.each do |directive|
39
- next unless directive.graphql_name == ResolverDirective.graphql_name
40
-
41
- kwargs = directive.arguments.keyword_arguments
42
- resolver_map[type_name] ||= []
43
- resolver_map[type_name] << TypeResolver.new(
44
- location: kwargs[:location],
45
- type_name: kwargs.fetch(:type_name, type_name),
46
- field: kwargs[:field],
47
- list: kwargs[:list] || false,
48
- key: key_definitions[kwargs[:key]],
49
- arguments: TypeResolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]),
50
- )
51
- end
52
-
53
- next unless type.kind.fields?
54
-
55
- type.fields.each do |field_name, field|
56
- # Collection locations for each field definition
57
- field.directives.each do |d|
58
- next unless d.graphql_name == SourceDirective.graphql_name
59
-
60
- location = d.arguments.keyword_arguments[:location]
61
- field_map[type_name] ||= {}
62
- field_map[type_name][field_name] ||= []
63
- field_map[type_name][field_name] << location
64
- possible_locations[location] = true
65
- end
66
- end
67
- end
68
-
69
- executables = possible_locations.keys.each_with_object({}) do |location, memo|
70
- executable = executables[location] || executables[location.to_sym]
71
- if validate_executable!(location, executable)
72
- memo[location] = executable
73
- end
74
- end
75
-
76
- new(
77
- schema: schema,
78
- fields: field_map,
79
- resolvers: resolver_map,
80
- executables: executables,
81
- )
82
- end
83
- end
84
-
85
- def to_definition
86
- if @schema.directives[KeyDirective.graphql_name].nil?
87
- @schema.directive(KeyDirective)
88
- end
89
- if @schema.directives[ResolverDirective.graphql_name].nil?
90
- @schema.directive(ResolverDirective)
91
- end
92
- if @schema.directives[SourceDirective.graphql_name].nil?
93
- @schema.directive(SourceDirective)
94
- end
95
-
96
- @schema.types.each do |type_name, type|
97
- if resolvers_for_type = @resolvers.dig(type_name)
98
- # Apply key directives for each unique type/key/location
99
- # (this allows keys to be composite selections and/or omitted from the supergraph schema)
100
- keys_for_type = resolvers_for_type.each_with_object({}) do |resolver, memo|
101
- memo[resolver.key.to_definition] ||= Set.new
102
- memo[resolver.key.to_definition].merge(resolver.key.locations)
103
- end
104
-
105
- keys_for_type.each do |key, locations|
106
- locations.each do |location|
107
- params = { key: key, location: location }
108
-
109
- unless has_directive?(type, KeyDirective.graphql_name, params)
110
- type.directive(KeyDirective, **params)
111
- end
112
- end
113
- end
114
-
115
- # Apply resolver directives for each unique query resolver
116
- resolvers_for_type.each do |resolver|
117
- params = {
118
- location: resolver.location,
119
- field: resolver.field,
120
- list: resolver.list? || nil,
121
- key: resolver.key.to_definition,
122
- arguments: resolver.arguments.map(&:to_definition).join(", "),
123
- argument_types: resolver.arguments.map(&:to_type_definition).join(", "),
124
- type_name: (resolver.type_name if resolver.type_name != type_name),
125
- }
126
-
127
- unless has_directive?(type, ResolverDirective.graphql_name, params)
128
- type.directive(ResolverDirective, **params.tap(&:compact!))
129
- end
130
- end
131
- end
132
-
133
- next unless type.kind.fields?
134
-
135
- type.fields.each do |field_name, field|
136
- locations_for_field = @locations_by_type_and_field.dig(type_name, field_name)
137
- next if locations_for_field.nil?
138
-
139
- # Apply source directives to annotate the possible locations of each field
140
- locations_for_field.each do |location|
141
- params = { location: location }
142
-
143
- unless has_directive?(field, SourceDirective.graphql_name, params)
144
- field.directive(SourceDirective, **params)
145
- end
146
- end
147
- end
148
- end
149
-
150
- @schema.to_definition
151
- end
152
-
153
- private
154
-
155
- def has_directive?(element, directive_name, params)
156
- existing = element.directives.find do |d|
157
- kwargs = d.arguments.keyword_arguments
158
- d.graphql_name == directive_name && params.all? { |k, v| kwargs[k] == v }
159
- end
160
-
161
- !existing.nil?
162
- end
163
- end
164
- end