graphql-stitching 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +27 -0
- data/.gitignore +59 -0
- data/.ruby-version +1 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +49 -0
- data/LICENSE +21 -0
- data/Procfile +3 -0
- data/README.md +329 -0
- data/Rakefile +12 -0
- data/docs/README.md +14 -0
- data/docs/composer.md +69 -0
- data/docs/document.md +15 -0
- data/docs/executor.md +29 -0
- data/docs/gateway.md +106 -0
- data/docs/images/library.png +0 -0
- data/docs/images/merging.png +0 -0
- data/docs/images/stitching.png +0 -0
- data/docs/planner.md +43 -0
- data/docs/shaper.md +20 -0
- data/docs/supergraph.md +65 -0
- data/example/gateway.rb +50 -0
- data/example/graphiql.html +153 -0
- data/example/remote1.rb +26 -0
- data/example/remote2.rb +26 -0
- data/graphql-stitching.gemspec +34 -0
- data/lib/graphql/stitching/composer/base_validator.rb +11 -0
- data/lib/graphql/stitching/composer/validate_boundaries.rb +80 -0
- data/lib/graphql/stitching/composer/validate_interfaces.rb +24 -0
- data/lib/graphql/stitching/composer.rb +442 -0
- data/lib/graphql/stitching/document.rb +59 -0
- data/lib/graphql/stitching/executor.rb +254 -0
- data/lib/graphql/stitching/gateway.rb +120 -0
- data/lib/graphql/stitching/planner.rb +323 -0
- data/lib/graphql/stitching/planner_operation.rb +59 -0
- data/lib/graphql/stitching/remote_client.rb +25 -0
- data/lib/graphql/stitching/shaper.rb +92 -0
- data/lib/graphql/stitching/supergraph.rb +171 -0
- data/lib/graphql/stitching/util.rb +63 -0
- data/lib/graphql/stitching/version.rb +7 -0
- data/lib/graphql/stitching.rb +30 -0
- 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
|
+
```
|
data/docs/supergraph.md
ADDED
@@ -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
|
+
```
|
data/example/gateway.rb
ADDED
@@ -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>
|
data/example/remote1.rb
ADDED
@@ -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)
|
data/example/remote2.rb
ADDED
@@ -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,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
|