├── .prettierignore ├── .prettierrc.js ├── jest.config.js ├── example ├── gateway.ts ├── federation-server.ts └── transformed-server.ts ├── src ├── ast-builders.ts ├── transform-sdl.spec.ts ├── transform-federation.spec.ts ├── transform-federation.ts └── transform-sdl.ts ├── .gitignore ├── package.json ├── README.md └── tsconfig.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | singleQuote: true, 4 | proseWrap: 'always', 5 | }; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['/dist/'], 5 | }; 6 | -------------------------------------------------------------------------------- /example/gateway.ts: -------------------------------------------------------------------------------- 1 | import { ServerInfo } from 'apollo-server'; 2 | 3 | const { ApolloServer } = require('apollo-server'); 4 | const { ApolloGateway } = require('@apollo/gateway'); 5 | 6 | const gateway = new ApolloGateway({ 7 | serviceList: [ 8 | { name: 'transformed', url: 'http://localhost:4001/graphql' }, 9 | { name: 'extension', url: 'http://localhost:4002/graphql' }, 10 | ], 11 | }); 12 | 13 | (async () => { 14 | const { schema, executor } = await gateway.load(); 15 | 16 | const server = new ApolloServer({ schema, executor }); 17 | 18 | server.listen().then(({ url }: ServerInfo) => { 19 | console.log(`🚀 Gateway ready at ${url}`); 20 | }); 21 | })(); 22 | -------------------------------------------------------------------------------- /src/ast-builders.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NameNode, 3 | StringValueNode, 4 | DirectiveNode, 5 | ValueNode, 6 | } from 'graphql/language'; 7 | 8 | export function createNameNode(value: string): NameNode { 9 | return { 10 | kind: 'Name', 11 | value, 12 | }; 13 | } 14 | 15 | export function createStringValueNode( 16 | value: string, 17 | block = false, 18 | ): StringValueNode { 19 | return { 20 | kind: 'StringValue', 21 | value, 22 | block, 23 | }; 24 | } 25 | 26 | export function createDirectiveNode( 27 | name: string, 28 | directiveArguments: { [argumentName: string]: ValueNode } = {}, 29 | ): DirectiveNode { 30 | return { 31 | kind: 'Directive', 32 | name: createNameNode(name), 33 | arguments: Object.entries(directiveArguments).map( 34 | ([argumentName, value]) => ({ 35 | kind: 'Argument', 36 | name: createNameNode(argumentName), 37 | value, 38 | }), 39 | ), 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /example/federation-server.ts: -------------------------------------------------------------------------------- 1 | import { ServerInfo } from 'apollo-server'; 2 | 3 | const { ApolloServer, gql } = require('apollo-server'); 4 | const { buildFederatedSchema } = require('@apollo/federation'); 5 | 6 | const typeDefs = gql` 7 | type Product @key(fields: "id") { 8 | id: String! 9 | price: Int 10 | weight: Int 11 | } 12 | 13 | type Query { 14 | findProduct: Product! 15 | } 16 | `; 17 | 18 | interface ProductKey { 19 | id: String; 20 | } 21 | 22 | const product = { 23 | id: '123', 24 | price: 899, 25 | weight: 100, 26 | }; 27 | 28 | const resolvers = { 29 | Query: { 30 | findProduct() { 31 | return product; 32 | }, 33 | }, 34 | }; 35 | 36 | const server = new ApolloServer({ 37 | schema: buildFederatedSchema([ 38 | { 39 | typeDefs, 40 | resolvers, 41 | }, 42 | ]), 43 | }); 44 | 45 | server.listen({ port: 4002 }).then(({ url }: ServerInfo) => { 46 | console.log(`🚀 Federation server ready at ${url}`); 47 | }); 48 | -------------------------------------------------------------------------------- /example/transformed-server.ts: -------------------------------------------------------------------------------- 1 | import { delegateToSchema, makeExecutableSchema } from 'graphql-tools'; 2 | import { ApolloServer } from 'apollo-server'; 3 | import { transformSchemaFederation } from '../src/transform-federation'; 4 | 5 | const products = [ 6 | { 7 | id: '123', 8 | name: 'name from transformed service', 9 | }, 10 | ]; 11 | 12 | interface ProductKey { 13 | id: string; 14 | } 15 | 16 | const schemaWithoutFederation = makeExecutableSchema({ 17 | typeDefs: ` 18 | type Product { 19 | id: String! 20 | name: String! 21 | } 22 | 23 | type Query { 24 | productById(id: String!): Product! 25 | } 26 | `, 27 | resolvers: { 28 | Query: { 29 | productById(source, { id }: ProductKey) { 30 | return products.find((product) => product.id === id); 31 | }, 32 | }, 33 | }, 34 | }); 35 | 36 | const federationSchema = transformSchemaFederation(schemaWithoutFederation, { 37 | Query: { 38 | extend: true, 39 | }, 40 | Product: { 41 | extend: true, 42 | keyFields: ['id'], 43 | fields: { 44 | id: { 45 | external: true, 46 | }, 47 | }, 48 | resolveReference(reference, context: { [key: string]: any }, info) { 49 | return delegateToSchema({ 50 | schema: info.schema, 51 | operation: 'query', 52 | fieldName: 'productById', 53 | args: { 54 | id: (reference as ProductKey).id, 55 | }, 56 | context, 57 | info, 58 | }); 59 | }, 60 | }, 61 | }); 62 | 63 | new ApolloServer({ 64 | schema: federationSchema, 65 | }) 66 | .listen({ 67 | port: 4001, 68 | }) 69 | .then(({ url }) => { 70 | console.log(`🚀 Transformed server ready at ${url}`); 71 | }); 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | .env.test 70 | 71 | # parcel-bundler cache (https://parceljs.org/) 72 | .cache 73 | 74 | # next.js build output 75 | .next 76 | 77 | # nuxt.js build output 78 | .nuxt 79 | 80 | # vuepress build output 81 | .vuepress/dist 82 | 83 | # Serverless directories 84 | .serverless/ 85 | 86 | # FuseBox cache 87 | .fusebox/ 88 | 89 | # DynamoDB Local files 90 | .dynamodb/ 91 | 92 | dist/ 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-transform-federation", 3 | "version": "2.2.0", 4 | "description": "Add GraphQL federation to an existing GraphQL schema", 5 | "main": "dist/transform-federation.js", 6 | "types": "dist/transform-federation.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "test:watch": "npm run test -- --watch", 10 | "format": "prettier --check '**/*.{js,ts,json,md}'", 11 | "format:fix": "prettier --write '**/*.{js,ts,json,md}'", 12 | "example:watch": "nodemon --signal SIGINT -e ts,js -x npm run example", 13 | "example": "concurrently 'npm run example:gateway' 'npm run example:transformed-server' 'npm run example:federation-server'", 14 | "example:gateway": "wait-on tcp:4001 && wait-on tcp:4002 && ts-node example/gateway", 15 | "example:transformed-server": "ts-node example/transformed-server", 16 | "example:federation-server": "ts-node example/federation-server", 17 | "prebuild": "rimraf dist", 18 | "build": "tsc", 19 | "prepublishOnly": "npm run format && npm run test && npm run build" 20 | }, 21 | "files": [ 22 | "src", 23 | "dist", 24 | "README.md" 25 | ], 26 | "keywords": [], 27 | "author": "Ruben Oostinga", 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/0xR/graphql-transform-federation.git" 31 | }, 32 | "license": "ISC", 33 | "devDependencies": { 34 | "@apollo/federation": "^0.20.6", 35 | "@apollo/gateway": "^0.21.3", 36 | "@types/dedent": "^0.7.0", 37 | "@types/graphql": "^14.5.0", 38 | "@types/jest": "^26.0.15", 39 | "@types/node": "^14.14.10", 40 | "apollo-server": "^2.19.0", 41 | "dedent": "^0.7.0", 42 | "graphql": "^14.7.0", 43 | "graphql-tools": "^7.0.2", 44 | "jest": "^26.6.3", 45 | "nodemon": "^2.0.6", 46 | "prettier": "^2.2.0", 47 | "ts-jest": "^26.4.4", 48 | "ts-node": "^9.0.0", 49 | "typescript": "^4.1.2", 50 | "wait-on": "^5.2.0" 51 | }, 52 | "peerDependencies": { 53 | "@apollo/federation": "0", 54 | "graphql": "^14.0.0 || ^15.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/transform-sdl.spec.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { addFederationAnnotations } from './transform-sdl'; 3 | 4 | describe('transform-sdl', () => { 5 | it('should add key directives to sdl', () => { 6 | expect( 7 | addFederationAnnotations( 8 | ` 9 | type Product @keep { 10 | id: Int 11 | }`, 12 | { 13 | Product: { 14 | keyFields: ['id'], 15 | }, 16 | }, 17 | ), 18 | ).toEqual(dedent` 19 | type Product @keep @key(fields: "id") { 20 | id: Int 21 | }\n`); 22 | }); 23 | 24 | it('should throw an error if not all keys were added', () => { 25 | expect(() => { 26 | addFederationAnnotations( 27 | ` 28 | type Product { 29 | id: Int 30 | } 31 | `, 32 | { 33 | NotProduct: { 34 | keyFields: ['mock keyFields'], 35 | }, 36 | NotProduct2: { 37 | keyFields: ['mock keyFields'], 38 | }, 39 | }, 40 | ); 41 | }).toThrow( 42 | 'Could not add key directives or extend types: NotProduct, NotProduct2', 43 | ); 44 | }); 45 | 46 | it('should convert types to extend types', () => { 47 | expect( 48 | addFederationAnnotations( 49 | ` 50 | type Product { 51 | id: Int 52 | }`, 53 | { 54 | Product: { 55 | extend: true, 56 | }, 57 | }, 58 | ), 59 | ).toEqual(dedent` 60 | extend type Product { 61 | id: Int 62 | }\n`); 63 | }); 64 | 65 | it('should add directive to fields', () => { 66 | expect( 67 | addFederationAnnotations( 68 | ` 69 | type Product { 70 | id: Int 71 | }`, 72 | { 73 | Product: { 74 | fields: { 75 | id: { 76 | external: true, 77 | provides: 'mock provides', 78 | requires: 'a { query }', 79 | }, 80 | }, 81 | }, 82 | }, 83 | ), 84 | ).toEqual(dedent` 85 | type Product { 86 | id: Int @external @provides(fields: "mock provides") @requires(fields: "a { query }") 87 | }\n`); 88 | }); 89 | 90 | it('should throw an error if not all external fields could get a directive', () => { 91 | expect(() => { 92 | addFederationAnnotations( 93 | ` 94 | type Product { 95 | id: Int 96 | } 97 | `, 98 | { 99 | NotProduct: { 100 | fields: { 101 | field1: { 102 | external: true, 103 | }, 104 | field2: { 105 | provides: 'mock provides', 106 | }, 107 | field3: { 108 | requires: 'mock requires', 109 | }, 110 | }, 111 | }, 112 | }, 113 | ); 114 | }).toThrow( 115 | 'Could not add directive to these fields: NotProduct.field1, NotProduct.field2, NotProduct.field3', 116 | ); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/transform-federation.spec.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from 'graphql-tools'; 2 | import { transformSchemaFederation } from './transform-federation'; 3 | import { execute } from 'graphql/execution/execute'; 4 | import { parse } from 'graphql/language'; 5 | import dedent = require('dedent'); 6 | 7 | describe('Transform Federation', () => { 8 | it('should add a _service field', async () => { 9 | const executableSchema = makeExecutableSchema({ 10 | typeDefs: ` 11 | type Product { 12 | id: ID! 13 | } 14 | `, 15 | resolvers: {}, 16 | }); 17 | 18 | const federationSchema = transformSchemaFederation(executableSchema, { 19 | Product: { 20 | keyFields: ['id'], 21 | }, 22 | }); 23 | 24 | expect( 25 | await execute({ 26 | schema: federationSchema, 27 | document: parse(` 28 | query { 29 | _service { 30 | sdl 31 | } 32 | } 33 | `), 34 | }), 35 | ).toEqual({ 36 | data: { 37 | _service: { 38 | sdl: dedent` 39 | type Product @key(fields: "id") { 40 | id: ID! 41 | }\n 42 | `, 43 | }, 44 | }, 45 | }); 46 | }); 47 | it('should resolve references', async () => { 48 | const executableSchema = makeExecutableSchema({ 49 | typeDefs: ` 50 | type Product { 51 | id: ID! 52 | name: String! 53 | } 54 | `, 55 | resolvers: {}, 56 | }); 57 | 58 | const federationSchema = transformSchemaFederation(executableSchema, { 59 | Product: { 60 | keyFields: ['id'], 61 | extend: true, 62 | resolveReference(reference) { 63 | return { 64 | ...reference, 65 | name: 'mock name', 66 | }; 67 | }, 68 | }, 69 | }); 70 | 71 | expect( 72 | await execute({ 73 | schema: federationSchema, 74 | document: parse(` 75 | query{ 76 | _entities (representations: { 77 | __typename:"Product" 78 | id: "1" 79 | }) { 80 | __typename 81 | ...on Product { 82 | id 83 | name 84 | } 85 | } 86 | } 87 | `), 88 | }), 89 | ).toEqual({ 90 | data: { 91 | _entities: [ 92 | { 93 | __typename: 'Product', 94 | id: '1', 95 | name: 'mock name', 96 | }, 97 | ], 98 | }, 99 | }); 100 | }); 101 | 102 | it('should throw and error when adding resolveReference on a scalar', () => { 103 | const executableSchema = makeExecutableSchema({ 104 | typeDefs: 'scalar MockScalar', 105 | resolvers: {}, 106 | }); 107 | 108 | expect(() => 109 | transformSchemaFederation(executableSchema, { 110 | MockScalar: { 111 | resolveReference() { 112 | return {}; 113 | }, 114 | }, 115 | }), 116 | ).toThrow( 117 | 'Type "MockScalar" is not an object type and can\'t have a resolveReference function', 118 | ); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/transform-federation.ts: -------------------------------------------------------------------------------- 1 | import { transformSchema } from 'apollo-graphql'; 2 | import { 3 | GraphQLObjectType, 4 | GraphQLSchema, 5 | GraphQLUnionType, 6 | isObjectType, 7 | isUnionType, 8 | printSchema, 9 | } from 'graphql'; 10 | import { addFederationAnnotations } from './transform-sdl'; 11 | import { 12 | entitiesField, 13 | EntityType, 14 | GraphQLReferenceResolver, 15 | serviceField, 16 | } from '@apollo/federation/dist/types'; 17 | 18 | export interface FederationFieldConfig { 19 | external?: boolean; 20 | provides?: string; 21 | requires?: string; 22 | } 23 | 24 | export interface FederationFieldsConfig { 25 | [fieldName: string]: FederationFieldConfig; 26 | } 27 | 28 | export interface FederationObjectConfig { 29 | keyFields?: string[]; 30 | extend?: boolean; 31 | resolveReference?: GraphQLReferenceResolver; 32 | fields?: FederationFieldsConfig; 33 | } 34 | 35 | export interface FederationConfig { 36 | [objectName: string]: FederationObjectConfig; 37 | } 38 | 39 | export function transformSchemaFederation( 40 | schema: GraphQLSchema, 41 | federationConfig: FederationConfig, 42 | ): GraphQLSchema { 43 | const schemaWithFederationDirectives = addFederationAnnotations( 44 | printSchema(schema), 45 | federationConfig, 46 | ); 47 | 48 | const schemaWithQueryType = !schema.getQueryType() 49 | ? new GraphQLSchema({ 50 | ...schema.toConfig(), 51 | query: new GraphQLObjectType({ 52 | name: 'Query', 53 | fields: {}, 54 | }), 55 | }) 56 | : schema; 57 | 58 | const entityTypes = Object.fromEntries( 59 | Object.entries(federationConfig) 60 | .filter(([, { keyFields }]) => keyFields && keyFields.length) 61 | .map(([objectName]) => { 62 | const type = schemaWithQueryType.getType(objectName); 63 | if (!isObjectType(type)) { 64 | throw new Error( 65 | `Type "${objectName}" is not an object type and can't have a key directive`, 66 | ); 67 | } 68 | return [objectName, type]; 69 | }), 70 | ); 71 | 72 | const hasEntities = !!Object.keys(entityTypes).length; 73 | 74 | const schemaWithFederationQueryType = transformSchema( 75 | schemaWithQueryType, 76 | (type) => { 77 | // Add `_entities` and `_service` fields to query root type 78 | if (isObjectType(type) && type === schemaWithQueryType.getQueryType()) { 79 | const config = type.toConfig(); 80 | return new GraphQLObjectType({ 81 | ...config, 82 | fields: { 83 | ...config.fields, 84 | ...(hasEntities && { _entities: entitiesField }), 85 | _service: { 86 | ...serviceField, 87 | resolve: () => ({ sdl: schemaWithFederationDirectives }), 88 | }, 89 | }, 90 | }); 91 | } 92 | return undefined; 93 | }, 94 | ); 95 | 96 | const schemaWithUnionType = transformSchema( 97 | schemaWithFederationQueryType, 98 | (type) => { 99 | if (isUnionType(type) && type.name === EntityType.name) { 100 | return new GraphQLUnionType({ 101 | ...EntityType.toConfig(), 102 | types: Object.values(entityTypes), 103 | }); 104 | } 105 | return undefined; 106 | }, 107 | ); 108 | 109 | // Not using transformSchema since it will remove resolveReference 110 | Object.entries(federationConfig).forEach( 111 | ([objectName, currentFederationConfig]) => { 112 | if (currentFederationConfig.resolveReference) { 113 | const type = schemaWithUnionType.getType(objectName); 114 | if (!isObjectType(type)) { 115 | throw new Error( 116 | `Type "${objectName}" is not an object type and can't have a resolveReference function`, 117 | ); 118 | } 119 | type.resolveReference = currentFederationConfig.resolveReference; 120 | } 121 | }, 122 | ); 123 | return schemaWithUnionType; 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-transform-federation 2 | 3 | If you want to use 4 | [GraphQL federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), 5 | but you can't rebuild your current GraphQL schema, you can use this transform to 6 | add GraphQL federation functionality to an existing schema. You need this when 7 | you are using a managed GraphQL service or a generated schema which doesn't 8 | support federation (yet). 9 | 10 | If you are using apollo-server or another schema builder that supports 11 | federation you don't need this transform you should 12 | [add the federation directives](https://www.apollographql.com/docs/apollo-server/federation/implementing/) 13 | directly. 14 | 15 | This transform will add the resolvers and directives to conform to the 16 | [federation specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#federation-schema-specification). 17 | Much of the 18 | [federation sourcecode](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-federation) 19 | could be reused ensuring it is compliant to the specification. 20 | 21 | Check out the 22 | [blogpost introducing graphql-tranform-federation](https://xebia.com/blog/graphql-federation-for-everyone/) 23 | for more background information. 24 | 25 | ![Architecture diagram for graphql-transform-federation](https://docs.google.com/drawings/d/e/2PACX-1vQkWQKeH9OClskaHoV0XPoVGl-w1_MEFGkhuRW03KG0R3SHXJXv9E4pOF4IR0EnoubS1vn1a_33UAnb/pub?w=990&h=956 'Architecture using a remote schema') 26 | 27 | ## Usage 28 | 29 | You can use this transform on a local or a remote GraphQL schema. When using a 30 | remote schema your service acts a middleware layer as shown in the diagram 31 | above. Check the 32 | [remote schema documentation](https://www.apollographql.com/docs/graphql-tools/remote-schemas/) 33 | for how to get an executable schema that you can use with this transform. 34 | 35 | The example below shows a configuration where the transformed schema extends an 36 | existing schema. It already had a resolver `productById` which is used to relate 37 | products between the two schemas. This example can be started using 38 | [npm run example](#npm-run-example). 39 | 40 | ```typescript 41 | import { transformSchemaFederation } from 'graphql-transform-federation'; 42 | import { delegateToSchema } from 'graphql-tools'; 43 | 44 | const schemaWithoutFederation = // your existing executable schema 45 | 46 | const federationSchema = transformSchemaFederation(schemaWithoutFederation, { 47 | Query: { 48 | // Ensure the root queries of this schema show up the combined schema 49 | extend: true, 50 | }, 51 | Product: { 52 | // extend Product { 53 | extend: true, 54 | // Product @key(fields: "id") { 55 | keyFields: ['id'], 56 | fields: { 57 | // id: Int! @external 58 | id: { 59 | external: true 60 | } 61 | }, 62 | resolveReference({ id }, context, info) { 63 | return delegateToSchema({ 64 | schema: info.schema, 65 | operation: 'query', 66 | fieldName: 'productById', 67 | args: { 68 | id, 69 | }, 70 | context, 71 | info, 72 | }); 73 | }, 74 | }, 75 | }); 76 | ``` 77 | 78 | To allow objects of an existing schema to be extended by other schemas it only 79 | needs to get `@key(...)` directives. 80 | 81 | ```typescript 82 | const federationSchema = transformSchemaFederation(schemaWithoutFederation, { 83 | Product: { 84 | // Product @key(fields: "id") { 85 | keyFields: ['id'], 86 | }, 87 | }); 88 | ``` 89 | 90 | ## API reference 91 | 92 | ```typescript 93 | import { GraphQLSchema } from 'graphql'; 94 | import { GraphQLReferenceResolver } from '@apollo/federation/dist/types'; 95 | 96 | interface FederationFieldConfig { 97 | external?: boolean; 98 | provides?: string; 99 | requires?: string; 100 | } 101 | 102 | interface FederationFieldsConfig { 103 | [fieldName: string]: FederationFieldConfig; 104 | } 105 | 106 | interface FederationObjectConfig { 107 | // An array so you can add multiple @key(...) directives 108 | keyFields?: string[]; 109 | extend?: boolean; 110 | resolveReference?: GraphQLReferenceResolver; 111 | fields?: FederationFieldsConfig; 112 | } 113 | 114 | interface FederationConfig { 115 | [objectName: string]: FederationObjectConfig; 116 | } 117 | 118 | function transformSchemaFederation( 119 | schema: GraphQLSchema, 120 | federationConfig: FederationConfig, 121 | ): GraphQLSchema; 122 | ``` 123 | 124 | ## `npm run example` 125 | 126 | Runs 2 GraphQL servers and a federation gateway to combine both schemas. 127 | [Transformed-server](./example/transformed-server.ts) is a regular GraphQL 128 | schema that is tranformed using this library. The 129 | [federation-server](example/federation-server.ts) is a federation server which 130 | is extended by a type defined by the `transformed-server`. The 131 | [gateway](./example/gateway.ts) combines both schemas using the apollo gateway. 132 | 133 | ## `npm run example:watch` 134 | 135 | Runs the example in watch mode for development. 136 | 137 | ## `npm run test` 138 | 139 | Run the tests 140 | -------------------------------------------------------------------------------- /src/transform-sdl.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldDefinitionNode, 3 | ObjectTypeDefinitionNode, 4 | ObjectTypeExtensionNode, 5 | parse, 6 | print, 7 | visit, 8 | } from 'graphql/language'; 9 | import { createDirectiveNode, createStringValueNode } from './ast-builders'; 10 | import { 11 | FederationConfig, 12 | FederationFieldConfig, 13 | FederationFieldsConfig, 14 | FederationObjectConfig, 15 | } from './transform-federation'; 16 | 17 | function createDirectiveWithFields(directiveName: string, fields: string) { 18 | return createDirectiveNode(directiveName, { 19 | fields: createStringValueNode(fields), 20 | }); 21 | } 22 | 23 | function isFieldConfigToDo({ 24 | external, 25 | provides, 26 | requires, 27 | }: FederationFieldConfig): boolean { 28 | return Boolean(external || provides || requires); 29 | } 30 | 31 | function filterFieldsConfigToDo( 32 | fieldsConfig: FederationFieldsConfig, 33 | ): FederationFieldsConfig { 34 | return Object.fromEntries( 35 | Object.entries(fieldsConfig).filter(([, fieldConfig]) => 36 | isFieldConfigToDo(fieldConfig), 37 | ), 38 | ); 39 | } 40 | 41 | function isObjectConfigToDo({ 42 | extend, 43 | keyFields, 44 | }: FederationObjectConfig): boolean { 45 | return Boolean((keyFields && keyFields.length) || extend); 46 | } 47 | 48 | export function addFederationAnnotations( 49 | schema: string, 50 | federationConfig: FederationConfig, 51 | ): string { 52 | const ast = parse(schema); 53 | 54 | const objectTypesTodo = new Set( 55 | Object.entries(federationConfig) 56 | .filter(([, config]) => isObjectConfigToDo(config)) 57 | .map(([objectName]) => objectName), 58 | ); 59 | 60 | const fieldTypesTodo: { 61 | [objectName: string]: FederationFieldsConfig; 62 | } = Object.fromEntries( 63 | Object.entries(federationConfig) 64 | .flatMap(([objectName, { fields }]) => 65 | fields ? [[objectName, filterFieldsConfigToDo(fields)]] : [], 66 | ) 67 | .filter(([, fieldsConfig]) => Object.keys(fieldsConfig).length), 68 | ); 69 | 70 | let currentObjectName: string | undefined = undefined; 71 | 72 | const withDirectives = visit(ast, { 73 | ObjectTypeDefinition: { 74 | enter( 75 | node: ObjectTypeDefinitionNode, 76 | ): ObjectTypeDefinitionNode | ObjectTypeExtensionNode | undefined { 77 | currentObjectName = node.name.value; 78 | if (objectTypesTodo.has(currentObjectName)) { 79 | objectTypesTodo.delete(currentObjectName); 80 | 81 | const { keyFields, extend } = federationConfig[currentObjectName]; 82 | 83 | const newDirectives = keyFields 84 | ? keyFields.map((keyField) => 85 | createDirectiveWithFields('key', keyField), 86 | ) 87 | : []; 88 | 89 | return { 90 | ...node, 91 | directives: [...(node.directives || []), ...newDirectives], 92 | kind: extend ? 'ObjectTypeExtension' : node.kind, 93 | }; 94 | } 95 | }, 96 | leave() { 97 | currentObjectName = undefined; 98 | }, 99 | }, 100 | FieldDefinition(node): FieldDefinitionNode | undefined { 101 | const currentFieldsTodo = 102 | currentObjectName && fieldTypesTodo[currentObjectName]; 103 | if ( 104 | currentObjectName && 105 | currentFieldsTodo && 106 | currentFieldsTodo[node.name.value] 107 | ) { 108 | const currentFieldConfig = currentFieldsTodo[node.name.value]; 109 | delete currentFieldsTodo[node.name.value]; 110 | if (Object.keys(currentFieldsTodo).length === 0) { 111 | delete fieldTypesTodo[currentObjectName]; 112 | } 113 | 114 | return { 115 | ...node, 116 | directives: [ 117 | ...(node.directives || []), 118 | ...(currentFieldConfig.external 119 | ? [createDirectiveNode('external')] 120 | : []), 121 | ...(currentFieldConfig.provides 122 | ? [ 123 | createDirectiveWithFields( 124 | 'provides', 125 | currentFieldConfig.provides, 126 | ), 127 | ] 128 | : []), 129 | ...(currentFieldConfig.requires 130 | ? [ 131 | createDirectiveWithFields( 132 | 'requires', 133 | currentFieldConfig.requires, 134 | ), 135 | ] 136 | : []), 137 | ], 138 | }; 139 | } 140 | return undefined; 141 | }, 142 | }); 143 | 144 | if (objectTypesTodo.size !== 0) { 145 | throw new Error( 146 | `Could not add key directives or extend types: ${Array.from( 147 | objectTypesTodo, 148 | ).join(', ')}`, 149 | ); 150 | } 151 | 152 | if (Object.keys(fieldTypesTodo).length !== 0) { 153 | throw new Error( 154 | `Could not add directive to these fields: ${Object.entries(fieldTypesTodo) 155 | .flatMap(([objectName, fieldsConfig]) => { 156 | return Object.keys(fieldsConfig).map( 157 | (externalField) => `${objectName}.${externalField}`, 158 | ); 159 | }) 160 | .join(', ')}`, 161 | ); 162 | } 163 | 164 | return print(withDirectives); 165 | } 166 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "es2019" 9 | ] /* Specify library files to be included in the compilation. */, 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true /* Generates corresponding '.map' file. */, 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | }, 65 | "include": ["src"] 66 | } 67 | --------------------------------------------------------------------------------