Schema Extensions
Schema extensions add gateway-level type definitions and resolvers into a combined API, which is useful for establishing connections between types that exist in separate subgraphs.
When considering these capabilities, be sure to compare them with the newer automated features available through type merging. While type merging frequently eliminates the need for schema extensions, it does not preclude their use.
Schema delegation is a way to automatically forward a query (or a part of a query) from the unified graph to an underlying subgraph that can execute the query.
Schema extensions are usually used to apply this approach to combine independent, uncontrolled sources.
Motivational Example
Let’s say we have a subgraph A, and the supergraph like below;
type Repository {
id: ID!
url: String
issues: [Issue]
userId: ID!
}
type Issue {
id: ID!
text: String!
repository: Repository!
}
type Query {
repositoryById(id: ID!): Repository
repositoriesByUserId(id: ID!): [Repository]
}And together with other composed subgraphs or extensions, we have the following supergraph;
type Repository {
id: ID!
url: String
issues: [Issue]
userId: ID!
user: User
}
type Issue {
id: ID!
text: String!
repository: Repository!
}
type User {
id: ID!
username: String
repositories: [Repository]
@resolveTo(
sourceName: "A"
sourceTypeName: "Query"
sourceFieldName: "repositoriesByUserId"
requiredSelectionSet: "{ id }"
sourceArgs: { id: "{root.id}" }
)
}
type Query {
userById(id: ID!): User
}Suppose we want the supergraph to delegate retrieval of repositories to the subgraph A, in order
to execute queries such as this one:
query {
userById(id: "1") {
id
username
repositories {
id
url
user {
username
id
}
issues {
text
}
}
}
}The resolver function for the repositories field of the User type would be responsible for the
delegation, in this case. While it’s possible to call a remote GraphQL endpoint or resolve the data
manually, this would require us to transform the query manually or always fetch all possible fields,
which could lead to overfetching. Delegation automatically extracts the appropriate query to send to
the subgraph A:
# To Subgraph A
query ($id: ID!) {
repositoriesByUserId(id: $id) {
id
url
issues {
text
}
}
}The delegation also removes the fields that don’t exist on the subschema, such as user. This field
would be retrieved from the parent schema using normal GraphQL resolvers.
Each field on the Repository and Issue types use a special resolver to properly extract data
from the delegated response. The special resolver resolves aliases, converts custom scalars and
enums to their internal representations, and maps errors. So it is more than just a simple fetch
call, and return that.
Basic Example
Let’s say we have two subgraphs;
type Post {
id: ID!
text: String
userId: ID!
}
type Query {
postById(id: ID!): Post
postsByUserId(userId: ID!): [Post!]!
}type User {
id: ID!
email: String
}
type Query {
userById(id: ID!): User
}We may want to navigate from a particular user to their posts, or from a post to its user. This is possible within our service architecture by connecting an existing key of each object to a corresponding root query:
Post.userId -> userById(id)gets a Post’s user.User.id -> postsByUserId(userId)gets a User’s posts.
To formalize this navigation within our supergraph, we can extend each type with a new field that will translate its respective key into an actual object association:
import { defineConfig } from '@graphql-mesh/compose-cli'
import { loadGraphQLHTTPSubgraph } from '@graphql-mesh/graphql'
export const composeConfig = defineConfig({
subgraphs: [
{
sourceHandler: loadGraphQLHTTPSubgraph('Posts', {
endpoint: 'http://localhost:4001/posts'
})
},
{
sourceHandler: loadGraphQLHTTPSubgraph('Users', {
endpoint: 'http://localhost:4002/users'
})
}
],
additionalTypeDefs: /* GraphQL */ `
extend type Post {
user: User!
}
extend type User {
posts: [Post!]!
}
`
})The additionalTypeDefs option provides type extensions (using the extend keyword) that add
additional fields into the combined gateway schema and therefore may cross-reference types from any
subgraph.
However, these extensions alone won’t do anything until they have corresponding resolvers. A complete example would look like this:
import { defineConfig } from '@graphql-mesh/compose-cli'
import { loadGraphQLHTTPSubgraph } from '@graphql-mesh/graphql'
export const composeConfig = defineConfig({
subgraphs: [
{
sourceHandler: loadGraphQLHTTPSubgraph('Posts', {
endpoint: 'http://localhost:4001/posts'
})
},
{
sourceHandler: loadGraphQLHTTPSubgraph('Users', {
endpoint: 'http://localhost:4002/users'
})
}
],
additionalTypeDefs: /* GraphQL */ `
extend type Post {
user: User!
@resolveTo(
sourceName: "Users"
sourceTypeName: "Query"
sourceFieldName: "userById"
requiredSelectionSet: "{ id }"
sourceArgs: { id: "{root.userId}" }
)
}
extend type User {
posts: [Post!]!
@resolveTo(
sourceName: "Posts"
sourceTypeName: "Query"
sourceFieldName: "postsByUserId"
requiredSelectionSet: "{ id }"
sourceArgs: { userId: "{root.id}" }
)
}
`
})When resolving User.posts and Post.user, we delegate each key reference to its corresponding
root query.
sourceNamespecifies the subgraph to delegate to.sourceTypeNameandsourceFieldNamespecify the root query to call.sourceArgsspecifies the arguments to be passed to the delegated fieldrequiredSelectionSetabove specifies the key field(s) needed from an object to query for its associations. For example,Post.userwill require that aPostprovide itsuserId. Rather than relying on incoming queries to manually request this key for the association, the selection set will automatically be included insubgraphrequests to guarantee that these fields are fetched.
By default, resolveTo assumes that the delegated operation will return the same GraphQL type as
the resolved field (ex: a User field would delegate to a User query). If this is not the case,
then you should manually provide a returnType option citing the expected GraphQL return type,
and transform the result accordingly in the resolver.
returnTypespecifies the expected return type of the delegated operation. This is necessary when the return type of the delegated operation differs from the type of the field being resolved. For example, ifPost.userresolves to aUsertype, but the delegated operation returns aUserResponsetype, thenreturnTypeshould be set toUser.resultspecifies the path to the data in the response that should be returned. This is necessary when the data is nested in the response. For example, if the response is{ data: { user: { id: 1, name: "John" } } }, thenresultshould be set todata.user.
Batch Delegation (Array Batching)
The drawback of performing individual resolveTo calls is that they can be fairly inefficient. Say
we request Post.user from an array of ten posts—that would delegate ten individual userById
queries while resolving each user! To improve this, we can instead delegate in batches, where many
instances of a field resolver are consolidated into one delegation.
To setup batching, the first thing we’ll need is a new query in the users’ service that allows fetching many users at once:
usersByIds(ids: [ID!]!): [User]!With this many-users query available, we can now delegate the Post.user field in batches across
many records:
import { defineConfig } from '@graphql-mesh/compose-cli'
import { loadGraphQLHTTPSubgraph } from '@graphql-mesh/graphql'
export const composeConfig = defineConfig({
subgraphs: [
{
sourceHandler: loadGraphQLHTTPSubgraph('Posts', {
endpoint: 'http://localhost:4001/posts'
})
},
{
sourceHandler: loadGraphQLHTTPSubgraph('Users', {
endpoint: 'http://localhost:4002/users'
})
}
],
additionalTypeDefs: /* GraphQL */ `
extend type Post {
user: User!
@resolveTo(
sourceName: "Users"
sourceTypeName: "Query"
sourceFieldName: "usersByIds"
keyField: "userId"
keysArg: "ids"
)
}
`
})Internally, resolveTo wraps a single resolveTo call in a DataLoader scoped by context, field, arguments, and query selection. It assumes that the delegated operation will return an array of objects matching the gateway field’s named GraphQL type (ex: a User field delegates to a [User] query). If this is not the case, then you should manually provide a returnType option citing the expected GraphQL return type. Since it is a thin wrapper around DataLoader, it also makes the following assumptions on the results:
The Array of values must be the same length as the Array of keys, and each index in the Array of values must correspond to the same index in the Array of keys.
If the query you’re delegating to don’t conform to these expectations, you can provide a custom
resolver function in additionalResolvers to the Hive Gateway runtime configuration.
Batch delegation is generally preferable over plain delegation because it eliminates the redundancy of requesting the same field across an array of parent objects. Even so, delegation costs can add up because there is still one subschema request made per batched field—for remote services, this may create many network requests sent to the same service.
Hiding internal fields
Let’s say if you don’t want to expose usersByIds, you can use
Filter Transform to hide it from the final schema. This is helpful
when you want to stitch subgraphs but you don’t want to expose some extra fields that should not be
exposed to the client.
import { createFilterTransform, defineConfig } from '@graphql-mesh/compose-cli'
import { loadGraphQLHTTPSubgraph } from '@graphql-mesh/graphql'
export const composeConfig = defineConfig({
subgraphs: [
{
sourceHandler: loadGraphQLHTTPSubgraph('Posts', {
endpoint: 'http://localhost:4001/posts'
})
},
{
sourceHandler: loadGraphQLHTTPSubgraph('Users', {
endpoint: 'http://localhost:4002/users'
}),
transforms: [
createFilterTransform({
fieldFilter: (typeName, fieldName) => fieldName !== 'usersByIds'
})
]
}
],
additionalTypeDefs: /* GraphQL */ `
extend type Post {
user: User!
@resolveTo(
sourceName: "Users"
sourceTypeName: "Query"
sourceFieldName: "usersByIds"
keyField: "userId"
keysArg: "ids"
)
}
`
})Schema Registry / Hive
You can use Schema Extensions with Hive. Since we don’t have a composition
configuration of supergraph when we use a Hive project, we have to use Hive’s settings to configure
additionalTypeDefs.
All you need to do is to provide the additionalTypeDefs in the Base Schema section of your
project’s Settings tab, like below.