Skip to content

Commit

Permalink
feat(stitching-directives): move federation-to-stitching-sdl (#3144)
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan authored Jul 6, 2021
1 parent f1d7b3c commit 70cd65e
Show file tree
Hide file tree
Showing 6 changed files with 415 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/quiet-carrots-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/stitching-directives': minor
---

feat(stitching-directives): move federation-to-stitching-sdl
146 changes: 146 additions & 0 deletions packages/stitching-directives/src/federationToStitchingSDL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Taken from https://github.com/gmac/federation-to-stitching-sdl/blob/main/index.js

import {
print,
DefinitionNode,
DirectiveNode,
InterfaceTypeDefinitionNode,
InterfaceTypeExtensionNode,
Kind,
ObjectTypeDefinitionNode,
ObjectTypeExtensionNode,
parse,
SchemaDefinitionNode,
} from 'graphql';
import { stitchingDirectives } from './stitchingDirectives';

const extensionKind = /Extension$/;

type EntityKind =
| ObjectTypeDefinitionNode
| ObjectTypeExtensionNode
| InterfaceTypeDefinitionNode
| InterfaceTypeExtensionNode;

const entityKinds: typeof Kind[keyof typeof Kind][] = [
Kind.OBJECT_TYPE_DEFINITION,
Kind.OBJECT_TYPE_EXTENSION,
Kind.INTERFACE_TYPE_DEFINITION,
Kind.INTERFACE_TYPE_EXTENSION,
];

function isEntityKind(def: DefinitionNode): def is EntityKind {
return entityKinds.includes(def.kind);
}

function getQueryTypeDef(definitions: readonly DefinitionNode[]): ObjectTypeDefinitionNode | undefined {
const schemaDef = definitions.find(def => def.kind === Kind.SCHEMA_DEFINITION) as SchemaDefinitionNode;
const typeName = schemaDef
? schemaDef.operationTypes.find(({ operation }) => operation === 'query')?.type.name.value
: 'Query';
return definitions.find(
def => def.kind === Kind.OBJECT_TYPE_DEFINITION && def.name.value === typeName
) as ObjectTypeDefinitionNode;
}

// Federation services are actually fairly complex,
// as the `buildFederatedSchema` helper does a fair amount
// of hidden work to setup the Federation schema specification:
// https://www.apollographql.com/docs/federation/federation-spec/#federation-schema-specification
export function federationToStitchingSDL(federationSDL: string, stitchingConfig = stitchingDirectives()) {
const doc = parse(federationSDL);
const entityTypes: string[] = [];
const baseTypeNames = doc.definitions.reduce((memo, typeDef) => {
if (!extensionKind.test(typeDef.kind) && 'name' in typeDef && typeDef.name) {
memo[typeDef.name.value] = true;
}
return memo;
}, {});

doc.definitions.forEach(typeDef => {
// Un-extend all types (remove "extends" keywords)...
// extended types are invalid GraphQL without a local base type to extend from.
// Stitching merges flat types in lieu of hierarchical extensions.
if (extensionKind.test(typeDef.kind) && 'name' in typeDef && typeDef.name && !baseTypeNames[typeDef.name.value]) {
(typeDef.kind as string) = typeDef.kind.replace(extensionKind, 'Definition');
}

if (!isEntityKind(typeDef)) return;

// Find object definitions with "@key" directives;
// these are federated entities that get turned into merged types.
const keyDirs: DirectiveNode[] = [];
const otherDirs: DirectiveNode[] = [];

typeDef.directives?.forEach(dir => {
if (dir.name.value === 'key') {
keyDirs.push(dir);
} else {
otherDirs.push(dir);
}
});

if (!keyDirs.length) return;

// Setup stitching MergedTypeConfig for all federated entities:
const selectionSet = `{ ${keyDirs.map((dir: any) => dir.arguments[0].value.value).join(' ')} }`;
const keyFields = (parse(selectionSet).definitions[0] as any).selectionSet.selections.map(
(sel: any) => sel.name.value
);
const keyDir = keyDirs[0];
(keyDir.name.value as string) = stitchingConfig.keyDirective.name;

(keyDir.arguments as any)[0].name.value = 'selectionSet';
(keyDir.arguments as any)[0].value.value = selectionSet;
(typeDef.directives as any[]) = [keyDir, ...otherDirs];

// Remove non-key "@external" fields from the type...
// the stitching query planner expects services to only publish their own fields.
// This makes "@provides" moot because the query planner can automate the logic.
(typeDef.fields as any) = typeDef.fields?.filter(fieldDef => {
return (
keyFields.includes(fieldDef.name.value) || !fieldDef.directives?.find(dir => dir.name.value === 'external')
);
});

// Discard remaining "@external" directives and any "@provides" directives
typeDef.fields?.forEach((fieldDef: any) => {
fieldDef.directives = fieldDef.directives.filter((dir: any) => !/^(external|provides)$/.test(dir.name.value));
fieldDef.directives.forEach((dir: any) => {
if (dir.name.value === 'requires') {
dir.name.value = stitchingConfig.computedDirective.name;
dir.arguments[0].name.value = 'selectionSet';
dir.arguments[0].value.value = `{ ${dir.arguments[0].value.value} }`;
}
});
});

if (typeDef.kind === Kind.OBJECT_TYPE_DEFINITION || typeDef.kind === Kind.OBJECT_TYPE_EXTENSION) {
entityTypes.push(typeDef.name.value);
}
});

// Federation service SDLs are incomplete because they omit the federation spec itself...
// (https://www.apollographql.com/docs/federation/federation-spec/#federation-schema-specification)
// To make federation SDLs into valid and parsable GraphQL schemas,
// we must fill in the missing details from the specification.
if (entityTypes.length) {
const queryDef = getQueryTypeDef(doc.definitions);
const entitiesSchema = parse(`
scalar _Any
union _Entity = ${entityTypes.filter((v, i, a) => a.indexOf(v) === i).join(' | ')}
type Query { _entities(representations: [_Any!]!): [_Entity]! @${stitchingConfig.mergeDirective.name} }
`).definitions as unknown as DefinitionNode & { fields: any[] };

(doc.definitions as any).push(entitiesSchema[0]);
(doc.definitions as any).push(entitiesSchema[1]);

if (queryDef) {
(queryDef.fields as any).push(entitiesSchema[2].fields[0]);
} else {
(doc.definitions as any).push(entitiesSchema[2]);
}
}

return [stitchingConfig.stitchingDirectivesTypeDefs, print(doc)].join('\n');
}
1 change: 1 addition & 0 deletions packages/stitching-directives/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './stitchingDirectives';
export * from './types';
export * from './federationToStitchingSDL';
141 changes: 141 additions & 0 deletions packages/stitching-directives/tests/federationToStitchingSDL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { federationToStitchingSDL } from "../src/federationToStitchingSDL";
import { stitchingDirectives } from "../src/stitchingDirectives";

const defaultStitchingDirectives = stitchingDirectives();

function normalizeString(str: string) {
return str.replace('\n', ' ').replace(/\s+/g, ' ').trim();
}

describe('federation sdl', () => {
test('translates to stitching annotations', async () => {
const federationSdl = `
extend type Product implements IProduct @key(fields: "id") {
id: ID! @external
weight: Int @external
shippingCost: Int @requires(fields: "weight")
parent: Product @provides(fields: "weight")
}
extend interface IProduct @key(fields: "id") {
id: ID! @external
weight: Int @external
shippingCost: Int @requires(fields: "weight")
parent: Product @provides(fields: "weight")
}
`;

const stitchingSdl = `
${defaultStitchingDirectives.stitchingDirectivesTypeDefs}
type Product implements IProduct @key(selectionSet: "{ id }") {
id: ID!
shippingCost: Int @computed(selectionSet: "{ weight }")
parent: Product
}
interface IProduct @key(selectionSet: "{ id }") {
id: ID!
shippingCost: Int @computed(selectionSet: "{ weight }")
parent: Product
}
scalar _Any
union _Entity = Product
type Query {
_entities(representations: [_Any!]!): [_Entity]! @merge
}
`;

const result = federationToStitchingSDL(federationSdl);
expect(normalizeString(result)).toEqual(normalizeString(stitchingSdl));
});

test('adds _entities to existing Query', async () => {
const federationSdl = `
extend type Product @key(fields: "id") {
id: ID!
}
type Query {
product(id: ID!): Product
}
`;

const stitchingSdl = `
${defaultStitchingDirectives.stitchingDirectivesTypeDefs}
type Product @key(selectionSet: "{ id }") {
id: ID!
}
type Query {
product(id: ID!): Product
_entities(representations: [_Any!]!): [_Entity]! @merge
}
scalar _Any
union _Entity = Product
`;

const result = federationToStitchingSDL(federationSdl);
expect(normalizeString(result)).toEqual(normalizeString(stitchingSdl));
});

test('adds _entities to schema-defined query type', async () => {
const federationSdl = `
extend type Product @key(fields: "id") {
id: ID!
}
type RootQuery {
product(id: ID!): Product
}
schema {
query: RootQuery
}
`;

const stitchingSdl = `
${defaultStitchingDirectives.stitchingDirectivesTypeDefs}
type Product @key(selectionSet: "{ id }") {
id: ID!
}
type RootQuery {
product(id: ID!): Product
_entities(representations: [_Any!]!): [_Entity]! @merge
}
schema {
query: RootQuery
}
scalar _Any
union _Entity = Product
`;

const result = federationToStitchingSDL(federationSdl);
expect(normalizeString(result)).toEqual(normalizeString(stitchingSdl));
});

test('only un-extends types without a base', async () => {
const federationSdl = `
extend type Product {
id: ID!
name: String
}
type Thing {
id: ID
}
extend type Thing {
name: String
}
`;

const stitchingSdl = `
${defaultStitchingDirectives.stitchingDirectivesTypeDefs}
type Product {
id: ID!
name: String
}
type Thing {
id: ID
}
extend type Thing {
name: String
}
`;

const result = federationToStitchingSDL(federationSdl);
expect(normalizeString(result)).toEqual(normalizeString(stitchingSdl));
});
});
Loading

0 comments on commit 70cd65e

Please sign in to comment.