Directives SDL
Stitching directives (@graphql-tools/stitching-directives
) may be used to configure a stitched gateway directly through the Schema Definition Language (SDL) of its subservices. The advantage of this approach is that all schema and type merging configuration is represented in a single document managed by each subservice, and can be reloaded by the gateway on the fly without a formal deploy or server restart.
Overview
Using SDL directives, a subservice may express its complete schema and type merging configuration in a single document. See related handbook example for a working demonstration.
type User {
id: ID!
username: String!
email: String!
}
type Query {
users(ids: [ID!]!): [User]! @merge(keyField: "id") @canonical
}
type Post {
id: ID!
message: String!
author: User
}
type User {
id: ID!
posts: [Post]
}
type Query {
post(id: ID!): Post
users(ids: [ID!]!): [User]! @merge(keyField: "id")
}
In the above example, the Users and Posts schemas will be combined in the stitched gateway and provide all of their own merged type configuration. All SDL directives will translate directly into the static configurations discussed in type merging docs. See the recipes section below for some common patterns.
Directives Glossary
By default, stitching directives use the following definitions (though the names of these directives may be customized):
directive @merge(
keyField: String
keyArg: String
additionalArgs: String
key: [String!]
argsExpr: String
) on FIELD_DEFINITION
directive @key(selectionSet: String!) on OBJECT
directive @computed(selectionSet: String!) on FIELD_DEFINITION
directive @canonical on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION
The function of these directives are:
-
@merge
: denotes a root field used to query a merged type across services. The marked field's name is analogous to thefieldName
setting in merged type configuration, while the field's arguments and return type are used to infer merge configuration. Directive arguments tune the merge behavior (see example recipes):keyField
: specifies the name of a field to pick off origin objects as the key value. When omitted, a@key
directive must be included on the return type's definition to be built into an object key.keyArg
: specifies which field argument receives the merge key. This may be omitted for fields with only one argument where the recipient can be inferred.additionalArgs
: specifies a string of additional keys and values to apply to other arguments, formatted as""" arg1: "value", arg2: "value" """
.key
: advanced use only; Allows building a custom key just for the argument from theselectionSet
included by the@key
directive.argsExpr
: advanced use only; This argument specifies a string expression that allows more customization of the input arguments. Rules for evaluation of this argument are as follows:- basic object parsing of the input key:
"arg1: $key.arg1, arg2: $key.arg2"
- any expression enclosed by double brackets will be evaluated once for each of the requested
keys, and then sent as a list:
"input: { keys: [[$key]] }"
- selections from the key can be referenced by using the $ sign and dot notation:
"upcs: [[$key.upc]]"
, so that$key.upc
refers to theupc
field of the key.
-
@key
: specifies a base selection set needed to merge the annotated type across subschemas. Analogous to theselectionSet
setting specified in merged type configuration. -
@computed
: specifies a selection of fields required from other services to compute the value of this field. These additional fields are only selected when the computed field is requested. Analogous to computed field in merged type configuration. Computed field dependencies must be sent into the subservice using an object key. -
@canonical
: specifies types and fields that provide a canonical definition to be built into the gateway schema. Useful for selecting preferred characteristics among types and fields that overlap across subschemas. Root fields marked as canonical specify which subschema the field proxies for new queries entering the graph.
Customizing Directive Names
You may use the stitchingDirectives
helper to build your own type definitions and validator with custom names. For example, the configuration below creates the resources for @myKey
, @myMerge
, and @myComputed
directives:
import { stitchingDirectives } from '@graphql-tools/stitching-directives'
const { allStitchingDirectivesTypeDefs, stitchingDirectivesValidator } = stitchingDirectives({
keyDirectiveName: 'myKey',
mergeDirectiveName: 'myMerge',
computedDirectiveName: 'myComputed'
})
Schema Setup
To setup stitching directives, you'll need to install their definitions into each subschema, and then add a transformer to the stitched gateway that reads them. See related handbook example for a complete demonstration.
Subservice Setup
When setting up a subservice, you'll need to do three things:
import { makeExecutableSchema } from '@graphql-tools/schema'
import { stitchingDirectives } from '@graphql-tools/stitching-directives'
const { allStitchingDirectivesTypeDefs, stitchingDirectivesValidator } = stitchingDirectives()
// 1. Include directive type definitions...
const typeDefs = /* GraphQL */ `
${allStitchingDirectivesTypeDefs}
# schema here ...
type Query {
# schema here ...
_sdl: String!
}
`
const schema = makeExecutableSchema({
typeDefs,
resolvers: {
Query: {
// 2. Setup a query that exposes the raw SDL...
_sdl: () => typeDefs
}
}
})
// 3. Include the stitching directives validator...
module.exports = stitchingDirectivesValidator(schema)
- Include
allStitchingDirectivesTypeDefs
in your schema's type definitions string (these define the schema of the directives themselves). - Include a
stitchingDirectivesValidator
in your executable schema (highly recommended). - Setup a query field that returns the schema's raw type definitions string (see the
_sdl
field example above). This field is extremely important for exposing the annotated SDL to your stitched gateway. Unfortunately, custom directives cannot be obtained through schema introspection.
Gateway Setup
When setting up the stitched gateway, you'll need to do two things:
import { stitchSchemas } from '@graphql-tools/stitch';
import { stitchingDirectivesTransformer } '@graphql-tools/stitching-directives';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import { parse } from 'graphql';
async function createGatewaySchema() {
const usersExec = buildHTTPExecutor({ endpoint: 'http://localhost:4001/graphql' })
const postsExec = buildHTTPExecutor({ endpoint: 'http://localhost:4002/graphql' })
return stitchSchemas({
// 1. Include directives transformer...
subschemaConfigTransforms: [stitchingDirectivesTransformer],
subschemas: [
{
schema: await fetchRemoteSchema(usersExec),
executor: usersExec
},
{
schema: await fetchRemoteSchema(postsExec),
executor: postsExec
}
]
})
}
async function fetchRemoteSchema(executor) {
// 2. Fetch schemas from their raw SDL queries...
const result = await executor({ document: parse('{ _sdl }') })
return buildSchema(result.data._sdl)
}
- Include the
stitchingDirectivesTransformer
in your stitched gateway's config transforms. This will read SDL directives into the stitched schema's static configuration. - Fetch subschemas through their
_sdl
query. You cannot introspect custom directives, so you must use a custom query that provides the complete annotated type definitions string.
Recipes
Picked Keys
The simplest merge pattern picks a key field from origin objects:
type User {
id: ID!
}
type Product {
upc: ID!
}
type Query {
user(id: ID!): User @merge(keyField: "id")
products(upcs: [ID!]!): [Product]! @merge(keyField: "upc")
}
Here, the @merge
directive marks each type's merge query, and its keyField
argument specifies a field to be picked from each original object as the query argument value. The above SDL translates into the following merge config:
merge: {
User: {
// single-record query:
selectionSet: '{ id }',
fieldName: 'user',
args: ({ id }) => ({ id })
},
Product: {
// array query:
selectionSet: '{ upc }',
fieldName: 'products',
key: ({ upc }) => upc,
argsFromKeys: upcs => ({ upcs })
}
}
Multiple Arguments
This pattern configures a merge query that receives multiple arguments:
type User {
id: ID!
}
type Query {
users(ids: [ID!]!, scope: String): [User]!
@merge(
keyField: "id"
keyArg: "ids"
additionalArgs: """
scope: "all"
"""
)
}
Because the merger field receives multiple arguments, the keyArg
parameter is required to specify which argument receives the key(s). The additionalArgs
parameter may also be used to provide static values for other arguments. The above SDL translates into the following merge config:
merge: {
User: {
selectionSet: '{ id }',
fieldName: 'users',
key: ({ id }) => id,
argsFromKeys: ids => ({ ids, scope: 'all' })
}
}
Object Keys
In the absence of a keyField
for the merge directive to pick, keys will assume the shape of an object with a __typename
and all fields collected for utilized selectionSets on the type:
type Product @key(selectionSet: "{ upc }") {
upc: ID!
shippingEstimate: Int @computed(selectionSet: "{ price weight }")
}
scalar _Key
type Query {
products(keys: [_Key!]!): [Product]! @merge
}
The above SDL specifies a type-level selectionSet using the @key
directive, and a field-level selectionSet using the @computed
directive. The @merge
directive takes no arguments here and will build object keys with fields collected from all utilized selectionSets. These object keys are passed to the merger field as a custom scalar (here called _Key
), or as an input object. This SDL translates into the following merge config:
// assume "pick" works like the lodash method...
merge: {
Product: {
selectionSet: '{ upc }',
computedFields: {
shippingEstimate: { selectionSet: '{ price weight }' }
},
fieldName: 'products',
key: obj => ({ __typename: 'Product', ...pick(obj, ['upc', 'price', 'weight']) }),
argsFromKeys: keys => ({ keys })
}
}
Each generated object key will have a __typename
and all utilized selectionSet fields on the type. For example, when the shippingEstimate
field is requested, the resulting object keys will look like this:
[
{ "__typename": "Product", "upc": "1", "price": 899, "weight": 100 },
{ "__typename": "Product", "upc": "2", "price": 1299, "weight": 1000 }
]
However, when shippingEstimate
is NOT requested, the generated object keys will only contain fields from the base selectionSet:
[
{ "__typename": "Product", "upc": "1" },
{ "__typename": "Product", "upc": "2" }
]
Typed Inputs
Similar to the object keys discussed above, an input object type may be used in place of a generic scalar to cast object keys with a specific schema:
type Product @key(selectionSet: "{ upc }") {
upc: ID!
shippingEstimate: Int @computed(selectionSet: "{ price weight }")
}
input ProductKey {
upc: ID!
price: Int
weight: Int
}
type Query {
products(keys: [ProductKey!]!): [Product]! @merge
}
This SDL translates into the following merge config:
// assume "pick" works like the lodash method...
merge: {
Product: {
selectionSet: '{ upc }',
computedFields: {
shippingEstimate: { selectionSet: '{ price weight }' }
},
fieldName: 'products',
key: obj => pick(obj, ['upc', 'price', 'weight']),
argsFromKeys: keys => ({ keys })
}
}
These typed inputs follow the same behavior as object keys with regards to only including fields from utilized selectionSets. The resulting objects will only ever include fields whitelisted by their input schema, and are subject to nullability mismatch errors:
[
{ "upc": "1", "price": 899, "weight": 100 },
{ "upc": "2", "price": 1299, "weight": 1000 }
]
Nested Inputs
More advanced cases may need to interface with complex inputs. In these cases, the keyArg
may specify a namespaced path at which to send the merge key:
type Product @key(selectionSet: "{ upc }") {
upc: ID!
}
input ProductKey {
upc: ID!
}
input ProductInput {
keys: [ProductKey!]!
}
type Query {
products(input: ProductInput): [Product]! @merge(keyArg: "input.keys")
}
Versioning & Release
Once subschemas and their merge configurations are defined as annotated SDLs, new versions of these documents can be pushed to the gateway to trigger a "hot" reloadβor, a reload of the gateway schema without restarting its server.
However, pushing untested SDLs directly to the gateway is risky due to the potential for incompatible subschema versions to be mixed. Therefore, a formal versioning, testing, and release strategy is necessary for long-term stability. See the handbook's versioning example that demonstrates turning a basic Git repo into a schema registry that manages versioning and release.
The general process for zero-downtime rollouts is:
- Compose and test all subschema head versions together to verify their combined stability prior to release.
- Deploy all updated subservice applications while keeping their existing subschema features operational.
- Push all updated subschema SDLs to the gateway as a single cutover.
- Decommission old subservices, and/or outdated subservice features.