Object and Interface types may exist with different fields in different graph locations, and will get merged together in the combined supergraph schema.
To facilitate this, schemas should be designed around merged type keys that stitching can cross-reference and fetch across locations using type resolver queries (discussed below). For those in an Apollo ecosystem, there's also limited support for merging types though federation _entities.
Foreign keys in a GraphQL schema frequently look like the Product.imageId field here:
# -- Products schema:
type Product {
id: ID!
imageId: ID!
}
# -- Images schema:
type Image {
id: ID!
url: String!
}However, this design does not lend itself to merging types across locations. A simple schema refactor makes this foreign key more expressive as an entity type, and turns the key into an object that will merge with analogous objects in other locations:
# -- Products schema:
type Product {
id: ID!
image: Image!
}
type Image {
id: ID!
}
# -- Images schema:
type Image {
id: ID!
url: String!
}Each location that provides a unique variant of a type must provide at least one resolver query for accessing it. Type resolvers are root queries identified by a @stitch directive:
directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITIONThis directive tells stitching how to cross-reference and fetch types from across locations, for example:
products_schema = <<~GRAPHQL
directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION
type Product {
id: ID!
name: String!
}
type Query {
product(id: ID!): Product @stitch(key: "id")
}
GRAPHQL
catalog_schema = <<~GRAPHQL
directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION
type Product {
id: ID!
price: 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"),
},
catalog: {
schema: GraphQL::Schema.from_definition(catalog_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
@stitchdirective marks 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 can be submitted as an argument to this query. The query argument used to send the key is inferred when possible (more on arguments later).
Merged types must have a resolver query in each of their possible locations. The one exception to this requirement are outbound-only types that contain no exclusive data; these may omit their resolver because they never require an inbound request to fetch them.
It's generally preferable to provide a list accessor as a resolver query for optimal batching. The only requirement is that both the field argument and the 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 resolver queries to be implemented through abstract types. An abstract query will provide access to all of its possible types by default, each of which must implement the key.
interface Node {
id: ID!
}
type Product implements Node {
id: ID!
name: String!
}
type Query {
nodes(ids: [ID!]!): [Node]! @stitch(key: "id")
}To customize which types an abstract query provides and their respective keys, add a typeName constraint. This can be repeated to select multiple types from an abstract.
type Product { sku: ID! }
type Order { id: ID! }
type Customer { id: ID! } # << not stitched
union Entity = Product | Order | Customer
type Query {
entity(key: ID!): Entity
@stitch(key: "sku", typeName: "Product")
@stitch(key: "id", typeName: "Order")
}Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For custom mappings, use the arguments option:
type Product {
id: ID!
}
union Entity = Product
type Query {
entity(key: ID!, type: String!): Entity @stitch(
key: "id",
arguments: "key: $.id, type: $.__typename",
typeName: "Product",
)
}The arguments option specifies a template of GraphQL arguments (or, GraphQL syntax that would normally be written into an arguments closure). This template may include key insertions prefixed by $ with dot-notation paths to any selections made by the resolver key. A __typename key selection is also always available. This arguments syntax allows sending multiple arguments that intermix stitching keys with complex input shapes and/or static values.
All argument patterns
List arguments
List arguments may specify input just like non-list arguments, and GraphQL list input coercion will assume the shape represents a list item:
type Query {
product(ids: [ID!]!, organization: ID!): [Product]!
@stitch(key: "id", arguments: "ids: $.id, organization: '1'")
}List resolvers (that return list types) may only insert keys into repeatable list arguments, while non-list arguments may only contain static values. Nested list inputs are neither common nor practical, so are not supported.
Scalar & Enum arguments
Built-in scalars are written as normal literal values. For convenience, string literals may be enclosed in single quotes rather than escaped double-quotes:
enum DataSource { CACHE }
type Query {
product(id: ID!, source: String!): Product
@stitch(key: "id", arguments: "id: $.id, source: 'cache'")
variant(id: ID!, source: DataSource!): Variant
@stitch(key: "id", arguments: "id: $.id, source: CACHE")
}InputObject arguments
Input objects may be provided anywhere in the input, even as nested structures. The stitching resolver will build the specified object shape:
input ComplexKey {
id: ID
nested: ComplexKey
}
type Query {
product(key: ComplexKey!): [Product]!
@stitch(key: "id", arguments: "key: { nested: { id: $.id } }")
}Custom scalar arguments
Custom scalar keys allow any input shape to be submitted, from primitive scalars to complex object structures. These values will be sent and recieved as untyped JSON input, which makes them flexible but quite lax with validation:
type Product {
id: ID!
}
union Entity = Product
scalar Key
type Query {
entities(representations: [Key!]!): [Entity]!
@stitch(key: "id", arguments: "representations: { id: $.id, __typename: $.__typename }")
}Resolver keys may make composite selections for multiple key fields and/or nested scopes, for example:
interface FieldOwner {
id: ID!
}
type CustomField {
owner: FieldOwner!
key: String!
value: String
}
input CustomFieldLookup {
ownerId: ID!
ownerType: String!
key: String!
}
type Query {
customFields(lookups: [CustomFieldLookup!]!): [CustomField]! @stitch(
key: "owner { id __typename } key",
arguments: "lookups: { ownerId: $.owner.id, ownerType: $.owner.__typename, key: $.key }"
)
}Note that composite key selections may not be distributed across locations. The complete selection criteria must be available in each location that provides the key.
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! sku:ID! } # products location
type Product { sku:ID! } # catelog locationIn 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 resolver queries for each possible key:
type Product {
id: ID!
sku: ID!
}
type Query {
productById(id: ID!): Product @stitch(key: "id")
productBySku(sku: ID!): Product @stitch(key: "sku")
}The @stitch directive is also repeatable, allowing a single query to associate with multiple keys:
type Product {
id: ID!
sku: ID!
}
type Query {
product(id: ID, sku: ID): Product @stitch(key: "id") @stitch(key: "sku")
}It's okay for a merged type resolver to return null for an object as long as all unique fields of the type allow null. For example, the following merge works:
# -- Request
query {
movieA(id: "23") {
id
title
rating
}
}
# -- Location A
type Movie {
id: String!
title: String!
}
type Query {
movieA(id: ID!): Movie @stitch(key: "id")
# (id: "23") -> { id: "23", title: "Jurassic Park" }
}
# -- Location B
type Movie {
id: String!
rating: Int
}
type Query {
movieB(id: ID!): Movie @stitch(key: "id")
# (id: "23") -> null
}And produces this result:
{
"data": {
"id": "23",
"title": "Jurassic Park",
"rating": null
}
}Location B is allowed to return null here because its one unique field (rating) is nullable. If rating were non-null, then null bubbling would invalidate the response object.
The @stitch directive can be added to class-based schemas using the provided definition:
class Query < GraphQL::Schema::Object
field :product, Product, null: false do
directive(GraphQL::Stitching::Directives::Stitch, key: "id")
argument(:id, ID, required: true)
end
end
class Schema < GraphQL::Schema
directive(GraphQL::Stitching::Directives::Stitch)
query(Query)
endAlternatively, a clean schema can have stitching directives applied from static configuration passed as a location's stitch option:
sdl_string = <<~GRAPHQL
type Product {
id: ID!
sku: ID!
}
type Query {
productById(id: ID!): Product
productBySku(sku: ID!): Product
}
GRAPHQL
client = GraphQL::Stitching::Client.new(locations: {
products: {
schema: GraphQL::Schema.from_definition(sdl_string),
executable: ->() { ... },
stitch: [
{ field_name: "productById", key: "id" },
{ field_name: "productBySku", key: "sku", arguments: "mySku: $.sku" },
]
},
# ...
})Merged types do not always require a resolver query. For example:
# -- Location A
type Widget {
id: ID!
name: String
price: Float
}
type Query {
widgetA(id: ID!): Widget @stitch(key: "id")
}
# -- Location B
type Widget {
id: ID!
size: Float
}
type Query {
widgetB(id: ID!): Widget @stitch(key: "id")
}
# -- Location C
type Widget {
id: ID!
name: String
size: Float
}
type Query {
featuredWidget: Widget
}In this graph, Widget is a merged type without a resolver query in location C. This works because all of its fields are resolvable in other locations; that means location C can provide outbound representations of this type without ever needing to resolve inbound requests for it. Outbound types do still require a shared key field (such as id above) that allow them to join with data in other resolver locations (such as price above).
