Skip to content

Commit

Permalink
Add relay helpers (#3234)
Browse files Browse the repository at this point in the history
* feat(stitch): add helpers for Relay

* Docs

* Add changeset

* Improve inheritance

* Improve tests
  • Loading branch information
ardatan authored Jul 27, 2021
1 parent 9bbcd8b commit bea81f2
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/olive-impalas-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/stitch': minor
---

@ardatanfeat(stitch): add helpers for Relay
1 change: 1 addition & 0 deletions packages/stitch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { forwardArgsToSelectionSet } from './selectionSetArgs';

export * from './subschemaConfigTransforms';
export * from './types';
export * from './relay';
93 changes: 93 additions & 0 deletions packages/stitch/src/relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { MergedTypeConfig, SubschemaConfig } from '@graphql-tools/delegate';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { GraphQLResolveInfo, isInterfaceType, Kind } from 'graphql';

const defaultRelayMergeConfig: MergedTypeConfig = {
selectionSet: `{ id }`,
fieldName: 'node',
args: ({ id }: any) => ({ id }),
};

export function handleRelaySubschemas(subschemas: SubschemaConfig[], getTypeNameFromId?: (id: string) => string) {
const typeNames: string[] = [];

for (const subschema of subschemas) {
const nodeType = subschema.schema.getType('Node');
if (nodeType) {
if (!isInterfaceType(nodeType)) {
throw new Error(`Node type should be an interface!`);
}
const implementations = subschema.schema.getImplementations(nodeType);
for (const implementedType of implementations.objects) {
typeNames.push(implementedType.name);

subschema.merge = subschema.merge || {};
subschema.merge[implementedType.name] = defaultRelayMergeConfig;
}
}
}

const relaySubschemaConfig: SubschemaConfig = {
schema: makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
node(id: ID!): Node
}
interface Node {
id: ID!
}
${typeNames
.map(
typeName => `
type ${typeName} implements Node {
id: ID!
}
`
)
.join('\n')}
`,
resolvers: {
Query: {
node: (_, { id }) => ({ id }),
},
Node: {
__resolveType: ({ id }: { id: string }, _: any, info: GraphQLResolveInfo) => {
if (!getTypeNameFromId) {
const possibleTypeNames = new Set<string>();
for (const fieldNode of info.fieldNodes) {
if (fieldNode.selectionSet?.selections) {
for (const selection of fieldNode.selectionSet?.selections) {
switch (selection.kind) {
case Kind.FRAGMENT_SPREAD: {
const fragment = info.fragments[selection.name.value];
possibleTypeNames.add(fragment.typeCondition.name.value);
break;
}
case Kind.INLINE_FRAGMENT: {
const possibleTypeName = selection.typeCondition?.name.value;
if (possibleTypeName) {
possibleTypeNames.add(possibleTypeName);
}
break;
}
}
}
}
}
if (possibleTypeNames.size !== 1) {
console.warn(
`You need to define getTypeNameFromId as a parameter to handleRelaySubschemas or add a fragment for "node" operation with specific single type condition!`
);
}
return [...possibleTypeNames][0] || typeNames[0];
}
return getTypeNameFromId(id);
},
},
},
}),
};

subschemas.push(relaySubschemaConfig);
return subschemas;
}
189 changes: 189 additions & 0 deletions packages/stitch/tests/relay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
import { execute, parse } from "graphql";
import { handleRelaySubschemas, stitchSchemas } from "../src";

const users = [
{
id: 'User_0',
name: 'John Doe'
},
{
id: 'User_1',
name: 'Jane Doe'
}
];

const posts = [
{ id: 'Post_0', content: 'Lorem Ipsum', userId: 'User_1' }, { id: 'Post_1', content: 'Dolor Sit Amet', userId: 'User_0' }
];

describe('Relay', () => {
it('should', async () => {
const userSchema = makeExecutableSchema({
typeDefs: /* GraphQL */`
type Query {
node(id: ID!): Node
}
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
`,
resolvers: {
Node: {
__resolveType: ({ id }: { id: string }) => id.split('_')[0],
},
Query: {
node: (_, { id }) => {
if (id.startsWith('User_')) {
return users.find(user => user.id === id);
}
return {
id,
};
}
}
}
});
const userResult = await execute({
schema: userSchema,
document: parse(/* GraphQL */`
fragment User on User {
id
name
}
query UserSchemaQuery {
user0: node(id: "User_0") {
...User
}
user1: node(id: "User_1") {
...User
}
}
`)
});
expect(userResult.data?.["user0"]?.name).toBe(users[0].name);
expect(userResult.data?.["user1"]?.name).toBe(users[1].name);
const postSchema = makeExecutableSchema({
typeDefs: /* GraphQL */`
type Query {
node(id: ID!): Node
}
interface Node {
id: ID!
}
type User implements Node {
id: ID!
posts: [Post]
}
type Post implements Node {
id: ID!
content: String!
}
`,
resolvers: {
Node: {
__resolveType: ({ id }: { id: string }) => id.split('_')[0],
},
Query: {
node: (_, { id }) => {
if (id.startsWith('Post_')) {
return posts.find(post => post.id === id);
}
return {
id,
};
}
},
User: {
posts: ({ id }) => posts.filter(({ userId }) => id === userId),
}
}
});
const postResult = await execute({
schema: postSchema,
document: parse(/* GraphQL */`
fragment Post on Post {
id
content
}
fragment User on User {
id
posts {
id
content
}
}
query PostSchemaQuery {
post0: node(id: "Post_0") {
...Post
}
post1: node(id: "Post_1") {
...Post
}
user0: node(id: "User_0") {
...User
}
user1: node(id: "User_1") {
...User
}
}
`)
});
expect(postResult.data?.["post0"]?.content).toBe(posts[0].content);
expect(postResult.data?.["post1"]?.content).toBe(posts[1].content);
expect(postResult.data?.["user0"]?.id).toBe(users[0].id);
expect(postResult.data?.["user0"]?.posts[0].content).toBe(posts[1].content);
expect(postResult.data?.["user1"]?.id).toBe(users[1].id);
expect(postResult.data?.["user1"]?.posts[0].content).toBe(posts[0].content);

const stitchedSchema = stitchSchemas({
subschemas: handleRelaySubschemas([
{ schema: postSchema },
{ schema: userSchema },
], id => id.split('_')[0])
});

const stitchedResult = await execute({
schema: stitchedSchema,
document: parse(/* GraphQL */`
fragment Post on Post {
id
content
}
fragment User on User {
id
name
posts {
id
content
}
}
query PostSchemaQuery {
post0: node(id: "Post_0") {
...Post
}
post1: node(id: "Post_1") {
...Post
}
user0: node(id: "User_0") {
...User
}
user1: node(id: "User_1") {
...User
}
}
`)
});

expect(stitchedResult.data?.["post0"]?.content).toBe(posts[0].content);
expect(stitchedResult.data?.["post1"]?.content).toBe(posts[1].content);
expect(stitchedResult.data?.["user0"]?.name).toBe(users[0].name);
expect(stitchedResult.data?.["user0"]?.posts[0].content).toBe(posts[1].content);
expect(stitchedResult.data?.["user1"]?.name).toBe(users[1].name);
expect(stitchedResult.data?.["user1"]?.posts[0].content).toBe(posts[0].content);
})
})
File renamed without changes.
24 changes: 23 additions & 1 deletion website/docs/stitch-type-merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,10 +552,32 @@ A field-level `selectionSet` specifies field dependencies while the `computed` s
The main disadvantage of computed fields is that they cannot be resolved independently from the stitched gateway. Tolerance for this subservice inconsistency is largely dependent on your own service architecture. An imperfect solution is to deprecate all computed fields within a subschema, and then normalize their behavior in the gateway schema with a [`RemoveObjectFieldDeprecations`](/docs/schema-wrapping#grooming) transform. See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/computed-fields).

## Federation services
## Consume Federation services in Stitching

If you're familiar with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), then you may notice that the above pattern of computed fields looks similar to the `_entities` service design of the [Apollo Federation specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). Federation resources may be included in a stitched gateway; this comes in handy when integrating with third-party services or in the process of a migration. See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/federation-services) for more information.

## Consume Relay services in Stitching

If you have multiple services using [Relay Specification](https://relay.dev/docs/guides/graphql-server-specification/#schema), you can easily combine them with the `handleRelaySubschemas` utility function from `@graphql-tools/stitch` package, and your unified schema will handle `Node` interface and `node` operation automatically using Type Merging.

```
import { stitchSchemas, handleRelaySubschemas } from '@graphql-tools/stitch';
const stitchedSchema = stitchSchemas({
subschemas: handleRelaySubschemas(
[
userSchema,
postSchema,
bookSchema
],
id => getTypeNameFromGlobalID(id) // This is an optional parameter. If not exists, it will inherit the typename from the type condition of the fragment inside the query
)
});
```

You can check the unit tests to see the complete usage;
https://github.com/ardatan/graphql-tools/blob/master/packages/stitch/tests/relay.test.ts

## Canonical definitions

Managing the gateway schema definition of each type and field becomes challenging as the same type names are introduced across subschemas. By default, the final definition of each named GraphQL element found in the stitched `subschemas` array provides its gateway definition. However, preferred definitions may be marked as `canonical` to receive this final priority. Canonical definitions provide:
Expand Down

0 comments on commit bea81f2

Please sign in to comment.