HandbookFoundationStitching directives SDL

Stitching directives SDL

This example demonstrates the use of stitching directives to configure type merging via subschema SDLs. Shifting this configuration out of the gateway makes subschemas autonomous, and allows them to push their own configuration up to the gateway—enabling more sophisticated schema releases.

The @graphql-tools/stitching-directives package provides importable directives that can be used to annotate types and fields within subschemas, a validator to ensure the directives are used appropriately, and a configuration transformer that can be used on the gateway to convert the subschema directives into explicit configuration setting.

Note: the service setup in this example is based on the official demonstration repository for Apollo Federation.

This example demonstrates:

  • @key directive for type-level selection sets.
  • @merge directive for type merging services.
  • @computed directive for computed fields.
  • @canonical directive for preferred element definitions.

Sandbox

⬇️ Click ☰ to see the files

You can also see the project on GitHub here.

The following services are available for interactive queries:

  • Stitched gateway: listening on 4000/graphql
  • Accounts subservice: listening on 4001/graphql
  • Inventory subservice: listening on 4002/graphql
  • Products subservice: listening on 4003/graphql
  • Reviews subservice: listening on 4004/graphql

Summary

First, try a query that includes data from all services:

query {
  products(upcs: [1, 2]) {
    name
    price
    weight
    inStock
    shippingEstimate
    reviews {
      id
      body
      author {
        name
        username
        totalReviews
      }
      product {
        name
        price
      }
    }
  }
}

Neat, it works! All those merges were configured through schema SDL annotations. Let’s review the patterns used in this example and compare them to their static configuration counterparts. Keep in mind: SDL directives are always just annotations for the static configuration that’s been discussed throughout previous chapters.

Single picked key

Open the Accounts schema and see the expression of a single-record merge query:

type User {
  id: ID!
  name: String!
  username: String!
}
 
type Query {
  user(id: ID!): User @merge(keyField: "id")
}

This translates into the following configuration:

merge: {
  User: {
    selectionSet: '{ id }'
    fieldName: 'user',
    args: (id) => ({ id }),
  }
}

Here the @merge(keyField: "id") directive marks a merger query, and specifies that the id field acts as a selectionSet to be fetched with the original object and picked as the query argument.

Picked keys with additional arguments

Next, open the Products schema and see the expression of an array-batched merge query:

type Product {
  upc: ID!
}
 
type Query {
  products(upcs: [ID!]!, order: String): [Product]!
    @merge(
      keyField: "upc"
      keyArg: "upcs"
      additionalArgs: """
      order: "price"
      """
    )
}

This translates into the following configuration:

merge: {
  Product: {
    selectionSet: '{ upc }'
    fieldName: 'products',
    key: ({ upc }) => upc,
    argsFromKeys: (upcs) => ({ upcs, order: 'price' }),
  }
}

Again, the @merge(keyField: "upc") directive marks a merger array query, and specifies that the upc field acts as a selectionSet to be fetched with each original object and picked into the query argument array. The keyArg argument specifies which query argument receives the array of keys, and additionalArgs specifies static values to provide for other arguments.

Object keys

Now open the Inventory schema and see the expression of an object key, denoted by a generic scalar (here called _Key). In the absence of keyField, a generated key assumes the shape of a typed object like those sent to federation services:

type Product @key(selectionSet: "{ upc }") {
  upc: ID!
  shippingEstimate: Int @computed(selectionSet: "{ price weight }")
}
 
scalar _Key
 
type Query {
  _products(keys: [_Key!]!): [Product]! @merge
}

This translates into the following configuration:

// assume "pick" works like the lodash method...
merge: {
  Product: {
    selectionSet: '{ upc }',
    fields: {
      shippingEstimate: { selectionSet: '{ price weight }', computed: true },
    },
    fieldName: '_products',
    key: (obj) => ({ __typename: 'Product', ...pick(obj, ['upc', 'price', 'weight']) }),
    argsFromKeys: (keys) => ({ keys }),
  }
}

The _Key scalar is an object with a __typename and all utilized selectionSet fields on the type. For example, when the computed shippingEstimate field is requested, the resulting object keys look like:

;[
  { upc: '1', price: 899, weight: 100, __typename: 'Product' },
  { upc: '2', price: 1299, weight: 1000, __typename: 'Product' }
]

However, when shippingEstimate is NOT requested, the generated object keys will only contain fields from the base selectionSet:

;[
  { upc: '1', __typename: 'Product' },
  { upc: '2', __typename: 'Product' }
]

Typed inputs

Similar to the object keys discussed above, an input object type may be used to constrain the specific fields included on a object key:

type User @key(selectionSet: "{ id }") {
  id: ID!
}
 
input UserKey {
  id: ID!
}
 
type Query {
  _users(keys: [UserKey!]!): [User]! @merge
}

This translates into the following configuration:

// assume "pick" works like the lodash method...
merge: {
  User: {
    selectionSet: '{ id }',
    fieldName: '_users',
    key: (obj) => pick(obj, ['id']),
    argsFromKeys: (keys) => ({ keys }),
  }
}

In this case, the generated object keys will contain nothing but utilized selectionSet fields that are whitelisted by the input type:

[{ "id": "1" }, { "id": "2" }]

Nested inputs

A more advanced form of typed input keys, this pattern uses the keyArg parameter to specify an input path at which to format the stitching query arguments:

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")
}

Canonical definitions

Open the documentation sidebar of GraphiQL for the gateway schema, and have a look at the Product type definition. While this type is defined in three different ways by three different services, you’ll see that element descriptions from the Products subschema are favored in the combined gateway schema because it is marked as @canonical:

"Represents a Product available for resale."
type Product @canonical {
  # ...
}

Types marked as canonical will provide a preferred definition of the type and its fields to the combined gateway schema. That means we can write clean descriptions for a type and its fields in one service, and expect those definitions to be used in the combined gateway schema. Specific fields may also be marked as canonical to override a canonical type definition. Fields that are unique to a given subservice have no competing definitions, and are therefore canonical by default.

For demonstration only!

This example uses several needlessly complex key patterns for the sake of demonstration. For simplicity, it’s generally best to use the basic picked key patterns whenever possible. Sending object keys is generally only necessary when implementing computed fields or communicating with federation services.