Skip to content

Commit 8698a19

Browse files
authored
Visibility controls (#173)
1 parent 5fefd60 commit 8698a19

File tree

19 files changed

+1283
-168
lines changed

19 files changed

+1283
-168
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
99
- Merged object and abstract types joining though multiple keys.
1010
- Shared objects, fields, enums, and inputs across locations.
1111
- Combining local and remote schemas.
12+
- [Visibility controls](./docs/visibility.md) for hiding schema elements.
1213
- [File uploads](./docs/http_executable.md) via multipart forms.
1314
- Tested with all minor versions of `graphql-ruby`.
1415

@@ -171,11 +172,11 @@ GRAPHQL
171172
client = GraphQL::Stitching::Client.new(locations: {
172173
products: {
173174
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"),
175176
},
176177
catalog: {
177178
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"),
179180
},
180181
})
181182
```
@@ -477,6 +478,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
477478

478479
- [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
479480
- [Schema composition merge patterns](./docs/composer.md#merge-patterns)
481+
- [Visibility controls](./docs/visibility.md)
480482
- [Subscriptions tutorial](./docs/subscriptions.md)
481483
- [Field selection routing](./docs/mechanics.md#field-selection-routing)
482484
- [Root selection routing](./docs/mechanics.md#root-selection-routing)

docs/visibility.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

lib/graphql/stitching.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ def initialize(element)
3232
end
3333

3434
class << self
35-
attr_writer :stitch_directive
36-
3735
# Proc used to compute digests; uses SHA2 by default.
3836
# @returns [Proc] proc used to compute digests.
3937
def digest(&block)
@@ -49,6 +47,26 @@ def digest(&block)
4947
def stitch_directive
5048
@stitch_directive ||= "stitch"
5149
end
50+
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)
69+
end
5270
end
5371
end
5472
end

lib/graphql/stitching/client.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ class Client
1313
# Builds a new client instance. Either `supergraph` or `locations` configuration is required.
1414
# @param supergraph [Supergraph] optional, a pre-composed supergraph that bypasses composer setup.
1515
# @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: {})
1818
@supergraph = if locations && supergraph
1919
raise ArgumentError, "Cannot provide both locations and a supergraph."
2020
elsif supergraph && !supergraph.is_a?(Supergraph)
2121
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."
2224
elsif supergraph
2325
supergraph
2426
else
25-
composer ||= Composer.new
27+
composer = Composer.new(**composer_options)
2628
composer.perform(locations)
2729
end
2830

0 commit comments

Comments
 (0)