GraphQL stitching composes a single schema from multiple underlying GraphQL resources, then smartly proxies portions of incoming requests to their respective locations in dependency order and returns the merged results. This allows an entire location graph to be queried through one combined GraphQL surface area.
Supports:
- Merged object and abstract types.
- Multiple keys per merged type.
- Shared objects, fields, enums, and inputs across locations.
- Combining local and remote schemas.
- Type merging via arbitrary queries or federation
_entities
protocol.
NOT Supported:
- Computed fields (ie: federation-style
@requires
). - Subscriptions, defer/stream.
This Ruby implementation is a sibling to GraphQL Tools (JS) and Bramble (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to Apollo Federation, though more generic. While Ruby is not the fastest language for a purely high-throughput API gateway, the opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language.
Add to your Gemfile:
gem "graphql-stitching"
Run bundle install
, then require unless running an autoloading framework (Rails, etc):
require "graphql/stitching"
The quickest way to start is to use the provided Client
component that wraps a stitched graph in an executable workflow with caching hooks:
movies_schema = <<~GRAPHQL
type Movie { id: ID! name: String! }
type Query { movie(id: ID!): Movie }
GRAPHQL
showtimes_schema = <<~GRAPHQL
type Showtime { id: ID! time: String! }
type Query { showtime(id: ID!): Showtime }
GRAPHQL
client = GraphQL::Stitching::Client.new(locations: {
movies: {
schema: GraphQL::Schema.from_definition(movies_schema),
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
},
showtimes: {
schema: GraphQL::Schema.from_definition(showtimes_schema),
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
},
my_local: {
schema: MyLocal::GraphQL::Schema,
},
})
result = client.execute(
query: "query FetchFromAll($movieId:ID!, $showtimeId:ID!){
movie(id:$movieId) { name }
showtime(id:$showtimeId): { time }
myLocalField
}",
variables: { "movieId" => "1", "showtimeId" => "2" },
operation_name: "FetchFromAll"
)
Schemas provided in location settings may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using GraphQL::Schema.from_definition
) and mapped to remote locations. See composer docs for more information on how schemas get merged.
While the Client
constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
- Composer - merges and validates many schemas into one supergraph.
- Supergraph - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
- Request - prepares a requested GraphQL document and variables for stitching.
- Planner - builds a cacheable query plan for a request document.
- Executor - executes a query plan with given request variables.
Object
and Interface
types may exist with different fields in different graph locations, and will get merged together in the combined schema.
To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location. This can be done using arbitrary queries or federation entities.
Types can merge through arbitrary queries using the @stitch
directive:
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
This directive (or static configuration) is applied to root queries where a merged type may be accessed in each location, and a key
argument specifies a field needed from other locations to be used as a query argument.
products_schema = <<~GRAPHQL
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
type Product {
id: ID!
name: String!
}
type Query {
product(id: ID!): Product @stitch(key: "id")
}
GRAPHQL
shipping_schema = <<~GRAPHQL
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
type Product {
id: ID!
weight: Float!
}
type Query {
products(ids: [ID!]!): [Product]! @stitch(key: "id")
}
GRAPHQL
client = GraphQL::Stitching::Client.new(locations: {
products: {
schema: GraphQL::Schema.from_definition(products_schema),
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
},
shipping: {
schema: GraphQL::Schema.from_definition(shipping_schema),
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
},
})
Focusing on the @stitch
directive usage:
type Product {
id: ID!
name: String!
}
type Query {
product(id: ID!): Product @stitch(key: "id")
}
- 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. - 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).
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:
type Product {
id: ID!
}
The above representation of a Product
type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted. This pattern of providing key-only types is very common in stitching: it allows a foreign key to be represented as an object stub that may be enriched by data collected from other locations.
It's okay (even preferable 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.
type Query {
products(ids: [ID!]!): [Product]! @stitch(key: "id")
}
# input: ["1", "2", "3"]
# result: [{ id: "1" }, null, { id: "3" }]
See error handling tips for list queries.
It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types. For interfaces, the key selection should match a field within the interface. For unions, all possible types must implement the key selection individually.
interface Node {
id: ID!
}
type Product implements Node {
id: ID!
name: String!
}
type Query {
nodes(ids: [ID!]!): [Node]! @stitch(key: "id")
}
Stitching infers which argument to use for queries with a single argument. For queries that accept multiple arguments, the key must provide an argument mapping specified as "<arg>:<key>"
. Note the "id:id"
key:
type Query {
product(id: ID, upc: ID): Product @stitch(key: "id:id")
}
A type may exist in multiple locations across the graph using different keys, for example:
type Product { id:ID! } # storefronts location
type Product { id:ID! upc:ID! } # products location
type Product { upc:ID! } # catelog location
In the above graph, the storefronts
and catelog
locations have different keys that join through an intermediary. This pattern is perfectly valid and resolvable as long as the intermediary provides stitching queries for each possible key:
type Product {
id: ID!
upc: ID!
}
type Query {
productById(id: ID!): Product @stitch(key: "id")
productByUpc(upc: ID!): Product @stitch(key: "upc")
}
The @stitch
directive is also repeatable (requires graphql-ruby >= v2.0.15), allowing a single query to associate with multiple keys:
type Product {
id: ID!
upc: ID!
}
type Query {
product(id: ID, upc: ID): Product @stitch(key: "id:id") @stitch(key: "upc:upc")
}
The @stitch
directive can be added to class-based schemas with a directive class:
class StitchField < GraphQL::Schema::Directive
graphql_name "stitch"
locations FIELD_DEFINITION
repeatable true
argument :key, String, required: true
end
class Query < GraphQL::Schema::Object
field :product, Product, null: false do
directive StitchField, key: "id"
argument :id, ID, required: true
end
end
The @stitch
directive can be exported from a class-based schema to an SDL string by calling schema.to_definition
.
A clean SDL string may also have stitching directives applied via static configuration by passing a stitch
array in location settings:
sdl_string = <<~GRAPHQL
type Product {
id: ID!
upc: ID!
}
type Query {
productById(id: ID!): Product
productByUpc(upc: ID!): Product
}
GRAPHQL
supergraph = GraphQL::Stitching::Composer.new.perform({
products: {
schema: GraphQL::Schema.from_definition(sdl_string),
executable: ->() { ... },
stitch: [
{ field_name: "productById", key: "id" },
{ field_name: "productByUpc", key: "upc" },
]
},
# ...
})
The library is configured to use a @stitch
directive by default. You may customize this by setting a new name during initialization:
GraphQL::Stitching.stitch_directive = "merge"
The Apollo Federation specification defines a standard interface for accessing merged type variants across locations. Stitching can utilize a subset of this interface to facilitate basic type merging. The following spec is supported:
@key(fields: "id")
(repeatable) specifies a key field for an object type. The keyfields
argument may only contain one field selection._Entity
is a union type that must contain all types that implement a@key
._Any
is a scalar that recieves raw JSON objects; each object representation contains a__typename
and the type's key field._entities(representations: [_Any!]!): [_Entity]!
is a root query for local entity types.
The composer will automatcially detect and stitch schemas with an _entities
query, for example:
accounts_schema = <<~GRAPHQL
directive @key(fields: String!) repeatable on OBJECT
type User @key(fields: "id") {
id: ID!
name: String!
address: String!
}
union _Entity = User
scalar _Any
type Query {
user(id: ID!): User
_entities(representations: [_Any!]!): [_Entity]!
}
GRAPHQL
comments_schema = <<~GRAPHQL
directive @key(fields: String!) repeatable on OBJECT
type User @key(fields: "id") {
id: ID!
comments: [String!]!
}
union _Entity = User
scalar _Any
type Query {
_entities(representations: [_Any!]!): [_Entity]!
}
GRAPHQL
client = GraphQL::Stitching::Client.new(locations: {
accounts: {
schema: GraphQL::Schema.from_definition(accounts_schema),
executable: ...,
},
comments: {
schema: GraphQL::Schema.from_definition(comments_schema),
executable: ...,
},
})
It's perfectly fine to mix and match schemas that implement an _entities
query with schemas that implement @stitch
directives; the protocols achieve the same result. Note that stitching is much simpler than Apollo Federation by design, and that Federation's advanced routing features (such as the @requires
and @external
directives) will not work with stitching.
An executable resource performs location-specific GraphQL requests. Executables may be GraphQL::Schema
classes, or any object that responds to .call(location, source, variables, context)
and returns a raw GraphQL response:
class MyExecutable
def call(location, source, variables, context)
# process a GraphQL request...
return {
"data" => { ... },
"errors" => [ ... ],
}
end
end
A Supergraph is composed with executable resources provided for each location. Any location that omits the executable
option will use the provided schema
as its default executable:
supergraph = GraphQL::Stitching::Composer.new.perform({
first: {
schema: FirstSchema,
# executable:^^^^^^ delegates to FirstSchema,
},
second: {
schema: SecondSchema,
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001", headers: { ... }),
},
third: {
schema: ThirdSchema,
executable: MyExecutable.new,
},
fourth: {
schema: FourthSchema,
executable: ->(loc, query, vars, ctx) { ... },
},
})
The GraphQL::Stitching::HttpExecutable
class is provided as a simple executable wrapper around Net::HTTP.post
. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a Supergraph
when rehydrating it from cache (see docs).
The stitching executor automatically batches subgraph requests so that only one request is made per location per generation of data. This is done using batched queries that combine all data access for a given a location. For example:
query MyOperation_2 {
_0_result: widgets(ids:["a","b","c"]) { ... } # << 3 Widget
_1_0_result: sprocket(id:"x") { ... } # << 1 Sprocket
_1_1_result: sprocket(id:"y") { ... } # << 1 Sprocket
_1_2_result: sprocket(id:"z") { ... } # << 1 Sprocket
}
Tips:
- List queries (like the
widgets
selection above) are more compact for accessing multiple records, and are therefore preferable as stitching accessors. - Assure that root field resolvers across your subgraph implement batching to anticipate cases like the three
sprocket
selections above.
Otherwise, there's no developer intervention necessary (or generally possible) to improve upon data access. Note that multiple generations of data may still force the executor to return to a previous location for more data.
The Executor component builds atop the Ruby fiber-based implementation of GraphQL::Dataloader
. Non-blocking concurrency requires setting a fiber scheduler via Fiber.set_scheduler
, see graphql-ruby docs. You may also need to build your own remote clients using corresponding HTTP libraries.
This repo includes a working example of several stitched schemas running across small Rack servers. Try running it:
bundle install
foreman start
Then visit the gateway service at http://localhost:3000
and try this query:
query {
storefront(id: "1") {
id
products {
upc
name
price
manufacturer {
name
address
products { upc name }
}
}
}
}
The above query collects data from all locations, two of which are remote schemas and the third a local schema. The combined graph schema is also stitched in to provide introspection capabilities.
bundle install
bundle exec rake test [TEST=path/to/test.rb]