From eede7375b449b2178b3c05abb67bd1bf0b2fcd52 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 18:53:33 -0700 Subject: [PATCH 01/10] Initial commit This commit introduces a fresh copy of graphql-js schema printer. Copying it here as the first commit gives us a good diffing point for review. --- .../src/service/newPrintFederatedSchema.ts | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 packages/apollo-federation/src/service/newPrintFederatedSchema.ts diff --git a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts new file mode 100644 index 00000000000..3f35e96623c --- /dev/null +++ b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts @@ -0,0 +1,372 @@ +// @flow strict + +import flatMap from '../polyfills/flatMap'; +import objectValues from '../polyfills/objectValues'; + +import inspect from '../jsutils/inspect'; +import invariant from '../jsutils/invariant'; + +import { print } from '../language/printer'; +import { printBlockString } from '../language/blockString'; + +import { type GraphQLSchema } from '../type/schema'; +import { isIntrospectionType } from '../type/introspection'; +import { GraphQLString, isSpecifiedScalarType } from '../type/scalars'; +import { + GraphQLDirective, + DEFAULT_DEPRECATION_REASON, + isSpecifiedDirective, +} from '../type/directives'; +import { + type GraphQLNamedType, + type GraphQLScalarType, + type GraphQLEnumType, + type GraphQLObjectType, + type GraphQLInterfaceType, + type GraphQLUnionType, + type GraphQLInputObjectType, + isScalarType, + isObjectType, + isInterfaceType, + isUnionType, + isEnumType, + isInputObjectType, +} from '../type/definition'; + +import { astFromValue } from '../utilities/astFromValue'; + +type Options = {| + /** + * Descriptions are defined as preceding string literals, however an older + * experimental version of the SDL supported preceding comments as + * descriptions. Set to true to enable this deprecated behavior. + * This option is provided to ease adoption and will be removed in v16. + * + * Default: false + */ + commentDescriptions?: boolean, +|}; + +/** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + */ +export function printSchema(schema: GraphQLSchema, options?: Options): string { + return printFilteredSchema( + schema, + n => !isSpecifiedDirective(n), + isDefinedType, + options, + ); +} + +export function printIntrospectionSchema( + schema: GraphQLSchema, + options?: Options, +): string { + return printFilteredSchema( + schema, + isSpecifiedDirective, + isIntrospectionType, + options, + ); +} + +function isDefinedType(type: GraphQLNamedType): boolean { + return !isSpecifiedScalarType(type) && !isIntrospectionType(type); +} + +function printFilteredSchema( + schema: GraphQLSchema, + directiveFilter: (type: GraphQLDirective) => boolean, + typeFilter: (type: GraphQLNamedType) => boolean, + options, +): string { + const directives = schema.getDirectives().filter(directiveFilter); + const typeMap = schema.getTypeMap(); + const types = objectValues(typeMap) + .sort((type1, type2) => type1.name.localeCompare(type2.name)) + .filter(typeFilter); + + return ( + [printSchemaDefinition(schema)] + .concat( + directives.map(directive => printDirective(directive, options)), + types.map(type => printType(type, options)), + ) + .filter(Boolean) + .join('\n\n') + '\n' + ); +} + +function printSchemaDefinition(schema: GraphQLSchema): ?string { + if (isSchemaOfCommonNames(schema)) { + return; + } + + const operationTypes = []; + + const queryType = schema.getQueryType(); + if (queryType) { + operationTypes.push(` query: ${queryType.name}`); + } + + const mutationType = schema.getMutationType(); + if (mutationType) { + operationTypes.push(` mutation: ${mutationType.name}`); + } + + const subscriptionType = schema.getSubscriptionType(); + if (subscriptionType) { + operationTypes.push(` subscription: ${subscriptionType.name}`); + } + + return `schema {\n${operationTypes.join('\n')}\n}`; +} + +/** + * GraphQL schema define root types for each type of operation. These types are + * the same as any other type and can be named in any manner, however there is + * a common naming convention: + * + * schema { + * query: Query + * mutation: Mutation + * } + * + * When using this naming convention, the schema description can be omitted. + */ +function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { + const queryType = schema.getQueryType(); + if (queryType && queryType.name !== 'Query') { + return false; + } + + const mutationType = schema.getMutationType(); + if (mutationType && mutationType.name !== 'Mutation') { + return false; + } + + const subscriptionType = schema.getSubscriptionType(); + if (subscriptionType && subscriptionType.name !== 'Subscription') { + return false; + } + + return true; +} + +export function printType(type: GraphQLNamedType, options?: Options): string { + if (isScalarType(type)) { + return printScalar(type, options); + } else if (isObjectType(type)) { + return printObject(type, options); + } else if (isInterfaceType(type)) { + return printInterface(type, options); + } else if (isUnionType(type)) { + return printUnion(type, options); + } else if (isEnumType(type)) { + return printEnum(type, options); + } else if (isInputObjectType(type)) { + return printInputObject(type, options); + } + + // Not reachable. All possible types have been considered. + invariant(false, 'Unexpected type: ' + inspect((type: empty))); +} + +function printScalar(type: GraphQLScalarType, options): string { + return printDescription(options, type) + `scalar ${type.name}`; +} + +function printObject(type: GraphQLObjectType, options): string { + const interfaces = type.getInterfaces(); + const implementedInterfaces = interfaces.length + ? ' implements ' + interfaces.map(i => i.name).join(' & ') + : ''; + return ( + printDescription(options, type) + + `type ${type.name}${implementedInterfaces}` + + printFields(options, type) + ); +} + +function printInterface(type: GraphQLInterfaceType, options): string { + return ( + printDescription(options, type) + + `interface ${type.name}` + + printFields(options, type) + ); +} + +function printUnion(type: GraphQLUnionType, options): string { + const types = type.getTypes(); + const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; + return printDescription(options, type) + 'union ' + type.name + possibleTypes; +} + +function printEnum(type: GraphQLEnumType, options): string { + const values = type + .getValues() + .map( + (value, i) => + printDescription(options, value, ' ', !i) + + ' ' + + value.name + + printDeprecated(value), + ); + + return ( + printDescription(options, type) + `enum ${type.name}` + printBlock(values) + ); +} + +function printInputObject(type: GraphQLInputObjectType, options): string { + const fields = objectValues(type.getFields()).map( + (f, i) => + printDescription(options, f, ' ', !i) + ' ' + printInputValue(f), + ); + return ( + printDescription(options, type) + `input ${type.name}` + printBlock(fields) + ); +} + +function printFields(options, type) { + const fields = objectValues(type.getFields()).map( + (f, i) => + printDescription(options, f, ' ', !i) + + ' ' + + f.name + + printArgs(options, f.args, ' ') + + ': ' + + String(f.type) + + printDeprecated(f), + ); + return printBlock(fields); +} + +function printBlock(items) { + return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; +} + +function printArgs(options, args, indentation = '') { + if (args.length === 0) { + return ''; + } + + // If every arg does not have a description, print them on one line. + if (args.every(arg => !arg.description)) { + return '(' + args.map(printInputValue).join(', ') + ')'; + } + + return ( + '(\n' + + args + .map( + (arg, i) => + printDescription(options, arg, ' ' + indentation, !i) + + ' ' + + indentation + + printInputValue(arg), + ) + .join('\n') + + '\n' + + indentation + + ')' + ); +} + +function printInputValue(arg) { + const defaultAST = astFromValue(arg.defaultValue, arg.type); + let argDecl = arg.name + ': ' + String(arg.type); + if (defaultAST) { + argDecl += ` = ${print(defaultAST)}`; + } + return argDecl; +} + +function printDirective(directive, options) { + return ( + printDescription(options, directive) + + 'directive @' + + directive.name + + printArgs(options, directive.args) + + (directive.isRepeatable ? ' repeatable' : '') + + ' on ' + + directive.locations.join(' | ') + ); +} + +function printDeprecated(fieldOrEnumVal) { + if (!fieldOrEnumVal.isDeprecated) { + return ''; + } + const reason = fieldOrEnumVal.deprecationReason; + const reasonAST = astFromValue(reason, GraphQLString); + if (reasonAST && reason !== '' && reason !== DEFAULT_DEPRECATION_REASON) { + return ' @deprecated(reason: ' + print(reasonAST) + ')'; + } + return ' @deprecated'; +} + +function printDescription( + options, + def, + indentation = '', + firstInBlock = true, +): string { + if (!def.description) { + return ''; + } + + const lines = descriptionLines(def.description, 120 - indentation.length); + if (options && options.commentDescriptions) { + return printDescriptionWithComments(lines, indentation, firstInBlock); + } + + const text = lines.join('\n'); + const preferMultipleLines = text.length > 70; + const blockString = printBlockString(text, '', preferMultipleLines); + const prefix = + indentation && !firstInBlock ? '\n' + indentation : indentation; + + return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; +} + +function printDescriptionWithComments(lines, indentation, firstInBlock) { + let description = indentation && !firstInBlock ? '\n' : ''; + for (const line of lines) { + if (line === '') { + description += indentation + '#\n'; + } else { + description += indentation + '# ' + line + '\n'; + } + } + return description; +} + +function descriptionLines(description: string, maxLen: number): Array { + const rawLines = description.split('\n'); + return flatMap(rawLines, line => { + if (line.length < maxLen + 5) { + return line; + } + // For > 120 character long lines, cut at space boundaries into sublines + // of ~80 chars. + return breakLine(line, maxLen); + }); +} + +function breakLine(line: string, maxLen: number): Array { + const parts = line.split(new RegExp(`((?: |^).{15,${maxLen - 40}}(?= |$))`)); + if (parts.length < 4) { + return [line]; + } + const sublines = [parts[0] + parts[1] + parts[2]]; + for (let i = 3; i < parts.length; i += 2) { + sublines.push(parts[i].slice(1) + parts[i + 1]); + } + return sublines; +} From 92f00a8017fbeca4c00a74421e585ed19e878cdb Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 20:00:14 -0700 Subject: [PATCH 02/10] Incorporate TS and federation changes --- .../src/service/newPrintFederatedSchema.ts | 270 ++++++++++++------ 1 file changed, 176 insertions(+), 94 deletions(-) diff --git a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts index 3f35e96623c..0d963d6c0b7 100644 --- a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts +++ b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts @@ -1,41 +1,44 @@ -// @flow strict - -import flatMap from '../polyfills/flatMap'; -import objectValues from '../polyfills/objectValues'; - -import inspect from '../jsutils/inspect'; -import invariant from '../jsutils/invariant'; - -import { print } from '../language/printer'; -import { printBlockString } from '../language/blockString'; +/** + * Forked from graphql-js schemaPrinter.js file @ v14.7.0 + * This file has been modified to support printing federated + * schema, including associated federation directives. + */ -import { type GraphQLSchema } from '../type/schema'; -import { isIntrospectionType } from '../type/introspection'; -import { GraphQLString, isSpecifiedScalarType } from '../type/scalars'; import { - GraphQLDirective, - DEFAULT_DEPRECATION_REASON, + GraphQLSchema, isSpecifiedDirective, -} from '../type/directives'; -import { - type GraphQLNamedType, - type GraphQLScalarType, - type GraphQLEnumType, - type GraphQLObjectType, - type GraphQLInterfaceType, - type GraphQLUnionType, - type GraphQLInputObjectType, + isIntrospectionType, + isSpecifiedScalarType, + GraphQLNamedType, + GraphQLDirective, isScalarType, isObjectType, isInterfaceType, isUnionType, isEnumType, isInputObjectType, -} from '../type/definition'; - -import { astFromValue } from '../utilities/astFromValue'; - -type Options = {| + GraphQLScalarType, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLArgument, + GraphQLInputField, + astFromValue, + print, + GraphQLField, + GraphQLEnumValue, + GraphQLString, + DEFAULT_DEPRECATION_REASON, + ASTNode, +} from 'graphql'; +import { Maybe } from '../composition'; +import { isFederationType } from '../types'; +import { isFederationDirective } from '../composition/utils'; +import federationDirectives, { gatherDirectives } from '../directives'; + +type Options = { /** * Descriptions are defined as preceding string literals, however an older * experimental version of the SDL supported preceding comments as @@ -44,8 +47,8 @@ type Options = {| * * Default: false */ - commentDescriptions?: boolean, -|}; + commentDescriptions?: boolean; +}; /** * Accepts options as a second argument: @@ -57,7 +60,10 @@ type Options = {| export function printSchema(schema: GraphQLSchema, options?: Options): string { return printFilteredSchema( schema, - n => !isSpecifiedDirective(n), + // Federation change: treat the directives defined by the federation spec + // similarly to the directives defined by the GraphQL spec (ie, don't print + // their definitions). + (n) => !isSpecifiedDirective(n) && !isFederationDirective(n), isDefinedType, options, ); @@ -75,19 +81,25 @@ export function printIntrospectionSchema( ); } +// Federation change: treat the types defined by the federation spec +// similarly to the directives defined by the GraphQL spec (ie, don't print +// their definitions). function isDefinedType(type: GraphQLNamedType): boolean { - return !isSpecifiedScalarType(type) && !isIntrospectionType(type); + return ( + !isSpecifiedScalarType(type) && + !isIntrospectionType(type) && + !isFederationType(type) + ); } function printFilteredSchema( schema: GraphQLSchema, directiveFilter: (type: GraphQLDirective) => boolean, typeFilter: (type: GraphQLNamedType) => boolean, - options, + options?: Options, ): string { const directives = schema.getDirectives().filter(directiveFilter); - const typeMap = schema.getTypeMap(); - const types = objectValues(typeMap) + const types = Object.values(schema.getTypeMap()) .sort((type1, type2) => type1.name.localeCompare(type2.name)) .filter(typeFilter); @@ -102,7 +114,7 @@ function printFilteredSchema( ); } -function printSchemaDefinition(schema: GraphQLSchema): ?string { +function printSchemaDefinition(schema: GraphQLSchema): string | undefined { if (isSchemaOfCommonNames(schema)) { return; } @@ -173,41 +185,66 @@ export function printType(type: GraphQLNamedType, options?: Options): string { return printInputObject(type, options); } - // Not reachable. All possible types have been considered. - invariant(false, 'Unexpected type: ' + inspect((type: empty))); + throw Error('Unexpected type: ' + (type as GraphQLNamedType).toString()); } -function printScalar(type: GraphQLScalarType, options): string { +function printScalar(type: GraphQLScalarType, options?: Options): string { return printDescription(options, type) + `scalar ${type.name}`; } -function printObject(type: GraphQLObjectType, options): string { +function printObject(type: GraphQLObjectType, options?: Options): string { const interfaces = type.getInterfaces(); const implementedInterfaces = interfaces.length ? ' implements ' + interfaces.map(i => i.name).join(' & ') : ''; + + // Federation change: print `extend` keyword on type extensions. + // + // The implementation assumes that an owned type will have fields defined + // since that is required for a valid schema. Types that are *only* + // extensions will not have fields on the astNode since that ast doesn't + // exist. + // + // XXX revist extension checking + const isExtension = + type.extensionASTNodes && type.astNode && !type.astNode.fields; + return ( printDescription(options, type) + + (isExtension ? 'extend ' : '') + `type ${type.name}${implementedInterfaces}` + + // Federation addition for printing @key usages + printFederationDirectives(type) + printFields(options, type) ); } -function printInterface(type: GraphQLInterfaceType, options): string { +function printInterface(type: GraphQLInterfaceType, options?: Options): string { + // Federation change: print `extend` keyword on type extensions. + // See printObject for assumptions made. + // + // XXX revist extension checking + const isExtension = + type.extensionASTNodes && type.astNode && !type.astNode.fields; + return ( printDescription(options, type) + + (isExtension ? 'extend ' : '') + `interface ${type.name}` + + // Federation change: graphql@14 doesn't support interfaces implementing interfaces + // printImplementedInterfaces(type) + + printFederationDirectives(type) + printFields(options, type) ); } -function printUnion(type: GraphQLUnionType, options): string { +function printUnion(type: GraphQLUnionType, options?: Options): string { const types = type.getTypes(); const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; return printDescription(options, type) + 'union ' + type.name + possibleTypes; } -function printEnum(type: GraphQLEnumType, options): string { +function printEnum(type: GraphQLEnumType, options?: Options): string { const values = type .getValues() .map( @@ -223,8 +260,8 @@ function printEnum(type: GraphQLEnumType, options): string { ); } -function printInputObject(type: GraphQLInputObjectType, options): string { - const fields = objectValues(type.getFields()).map( +function printInputObject(type: GraphQLInputObjectType, options?: Options): string { + const fields = Object.values(type.getFields()).map( (f, i) => printDescription(options, f, ' ', !i) + ' ' + printInputValue(f), ); @@ -233,8 +270,11 @@ function printInputObject(type: GraphQLInputObjectType, options): string { ); } -function printFields(options, type) { - const fields = objectValues(type.getFields()).map( +function printFields( + options: Options | undefined, + type: GraphQLObjectType | GraphQLInterfaceType, +) { + const fields = Object.values(type.getFields()).map( (f, i) => printDescription(options, f, ' ', !i) + ' ' + @@ -242,16 +282,44 @@ function printFields(options, type) { printArgs(options, f.args, ' ') + ': ' + String(f.type) + - printDeprecated(f), + printDeprecated(f) + + printFederationDirectives(f), ); return printBlock(fields); } -function printBlock(items) { +// Federation change: *do* print the usages of federation directives. +function printFederationDirectives( + type: GraphQLNamedType | GraphQLField, +): string { + if (!type.astNode) return ''; + if (isInputObjectType(type)) return ''; + + const allDirectives = gatherDirectives(type) + .filter((n) => + federationDirectives.some((fedDir) => fedDir.name === n.name.value), + ) + .map(print); + const dedupedDirectives = [...new Set(allDirectives)]; + + return dedupedDirectives.length > 0 ? ' ' + dedupedDirectives.join(' ') : ''; +} + +export function printWithReducedWhitespace(ast: ASTNode): string { + return print(ast) + .replace(/\s+/g, ' ') + .trim(); +} + +function printBlock(items: string[]) { return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; } -function printArgs(options, args, indentation = '') { +function printArgs( + options: Options | undefined, + args: GraphQLArgument[], + indentation = '', +) { if (args.length === 0) { return ''; } @@ -278,7 +346,7 @@ function printArgs(options, args, indentation = '') { ); } -function printInputValue(arg) { +function printInputValue(arg: GraphQLInputField) { const defaultAST = astFromValue(arg.defaultValue, arg.type); let argDecl = arg.name + ': ' + String(arg.type); if (defaultAST) { @@ -287,7 +355,7 @@ function printInputValue(arg) { return argDecl; } -function printDirective(directive, options) { +function printDirective(directive: GraphQLDirective, options?: Options) { return ( printDescription(options, directive) + 'directive @' + @@ -299,7 +367,9 @@ function printDirective(directive, options) { ); } -function printDeprecated(fieldOrEnumVal) { +function printDeprecated( + fieldOrEnumVal: GraphQLField | GraphQLEnumValue, +) { if (!fieldOrEnumVal.isDeprecated) { return ''; } @@ -311,62 +381,74 @@ function printDeprecated(fieldOrEnumVal) { return ' @deprecated'; } -function printDescription( - options, - def, +function printDescription }>( + options: Options | undefined, + def: T, indentation = '', firstInBlock = true, ): string { - if (!def.description) { + const { description } = def; + if (description == null) { return ''; } - const lines = descriptionLines(def.description, 120 - indentation.length); - if (options && options.commentDescriptions) { - return printDescriptionWithComments(lines, indentation, firstInBlock); + if (options?.commentDescriptions === true) { + return printDescriptionWithComments(description, indentation, firstInBlock); } - const text = lines.join('\n'); - const preferMultipleLines = text.length > 70; - const blockString = printBlockString(text, '', preferMultipleLines); + const preferMultipleLines = description.length > 70; + const blockString = printBlockString(description, '', preferMultipleLines); const prefix = indentation && !firstInBlock ? '\n' + indentation : indentation; return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; } -function printDescriptionWithComments(lines, indentation, firstInBlock) { - let description = indentation && !firstInBlock ? '\n' : ''; - for (const line of lines) { - if (line === '') { - description += indentation + '#\n'; - } else { - description += indentation + '# ' + line + '\n'; - } - } - return description; -} - -function descriptionLines(description: string, maxLen: number): Array { - const rawLines = description.split('\n'); - return flatMap(rawLines, line => { - if (line.length < maxLen + 5) { - return line; - } - // For > 120 character long lines, cut at space boundaries into sublines - // of ~80 chars. - return breakLine(line, maxLen); - }); +function printDescriptionWithComments( + description: string, + indentation: string, + firstInBlock: boolean, +) { + const prefix = indentation && !firstInBlock ? '\n' : ''; + const comment = description + .split('\n') + .map(line => indentation + (line !== '' ? '# ' + line : '#')) + .join('\n'); + + return prefix + comment + '\n'; } -function breakLine(line: string, maxLen: number): Array { - const parts = line.split(new RegExp(`((?: |^).{15,${maxLen - 40}}(?= |$))`)); - if (parts.length < 4) { - return [line]; +/** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + * + * @internal + */ +export function printBlockString( + value: string, + indentation: string = '', + preferMultipleLines: boolean = false, +): string { + const isSingleLine = value.indexOf('\n') === -1; + const hasLeadingSpace = value[0] === ' ' || value[0] === '\t'; + const hasTrailingQuote = value[value.length - 1] === '"'; + const hasTrailingSlash = value[value.length - 1] === '\\'; + const printAsMultipleLines = + !isSingleLine || + hasTrailingQuote || + hasTrailingSlash || + preferMultipleLines; + + let result = ''; + // Format a multi-line block quote to account for leading space. + if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) { + result += '\n' + indentation; } - const sublines = [parts[0] + parts[1] + parts[2]]; - for (let i = 3; i < parts.length; i += 2) { - sublines.push(parts[i].slice(1) + parts[i + 1]); + result += indentation ? value.replace(/\n/g, '\n' + indentation) : value; + if (printAsMultipleLines) { + result += '\n'; } - return sublines; + + return '"""' + result.replace(/"""/g, '\\"""') + '"""'; } From a313f3cae365e7faf63a43d1ab47f9c64f62172e Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 20:34:28 -0700 Subject: [PATCH 03/10] Temporary test to validate cutover --- .../__tests__/validatePrinterCutover.test.ts | 16 ++++++++++++++++ .../src/service/newPrintFederatedSchema.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts diff --git a/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts b/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts new file mode 100644 index 00000000000..656ac27cb20 --- /dev/null +++ b/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts @@ -0,0 +1,16 @@ +import { fixtures } from 'apollo-federation-integration-testsuite'; +import { composeAndValidate } from '../../composition'; +import { printSchema } from '../printFederatedSchema'; +import { printSchema as newPrintSchema } from '../newPrintFederatedSchema'; + +describe('printFederatedSchema and newPrintFederatedSchema equality', () => { + const { schema, errors } = composeAndValidate(fixtures); + + it('composes without errors', () => { + expect(errors).toHaveLength(0); + }); + + it('previous and new printers are identical', () => { + expect(printSchema(schema)).toEqual(newPrintSchema(schema)); + }); +}); diff --git a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts index 0d963d6c0b7..792ee8d6a58 100644 --- a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts +++ b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts @@ -300,9 +300,9 @@ function printFederationDirectives( federationDirectives.some((fedDir) => fedDir.name === n.name.value), ) .map(print); - const dedupedDirectives = [...new Set(allDirectives)]; + // const dedupedDirectives = [...new Set(allDirectives)]; - return dedupedDirectives.length > 0 ? ' ' + dedupedDirectives.join(' ') : ''; + return allDirectives.length > 0 ? ' ' + allDirectives.join(' ') : ''; } export function printWithReducedWhitespace(ast: ASTNode): string { From bd6905807a1c4bbaa3f54aafc3ef9dd06c9899bd Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Fri, 24 Jul 2020 10:15:21 -0700 Subject: [PATCH 04/10] Replace previous printSchema with updated version Copy over the now validated `newPrintFederatedSchema.ts` contents into the former `printFederatedSchema.ts` as an "under-the-hood" swap. --- .../__tests__/buildFederatedSchema.test.ts | 20 +- .../__tests__/printFederatedSchema.test.ts | 211 ++++++++ .../__tests__/validatePrinterCutover.test.ts | 16 - .../src/service/newPrintFederatedSchema.ts | 454 ------------------ .../src/service/printFederatedSchema.ts | 379 +++++++++------ 5 files changed, 447 insertions(+), 633 deletions(-) create mode 100644 packages/apollo-federation/src/service/__tests__/printFederatedSchema.test.ts delete mode 100644 packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts delete mode 100644 packages/apollo-federation/src/service/newPrintFederatedSchema.ts diff --git a/packages/apollo-federation/src/service/__tests__/buildFederatedSchema.test.ts b/packages/apollo-federation/src/service/__tests__/buildFederatedSchema.test.ts index d77cc39a8eb..3693b1b5aab 100644 --- a/packages/apollo-federation/src/service/__tests__/buildFederatedSchema.test.ts +++ b/packages/apollo-federation/src/service/__tests__/buildFederatedSchema.test.ts @@ -102,25 +102,25 @@ type Money { const { data, errors } = await graphql(schema, query); expect(errors).toBeUndefined(); - expect(data._service.sdl).toEqual(`""" -A user. This user is very complicated and requires so so so so so so so so so so -so so so so so so so so so so so so so so so so so so so so so so much -description text + expect(data?._service.sdl).toEqual(`""" +A user. This user is very complicated and requires so so so so so so so so so so so so so so so so so so so so so so so so so so so so so so so so much description text """ type User @key(fields: "id") { - "The unique ID of the user." + """The unique ID of the user.""" id: ID! - "The user's name." + + """The user's name.""" name: String username: String foo( - "Description 1" + """Description 1""" arg1: String - "Description 2" + + """Description 2""" arg2: String + """ - Description 3 Description 3 Description 3 Description 3 Description 3 - Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 + Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 Description 3 """ arg3: String ): String diff --git a/packages/apollo-federation/src/service/__tests__/printFederatedSchema.test.ts b/packages/apollo-federation/src/service/__tests__/printFederatedSchema.test.ts new file mode 100644 index 00000000000..4495899835c --- /dev/null +++ b/packages/apollo-federation/src/service/__tests__/printFederatedSchema.test.ts @@ -0,0 +1,211 @@ +import { fixtures } from 'apollo-federation-integration-testsuite'; +import { composeAndValidate } from '../../composition'; +import { printSchema } from '../printFederatedSchema'; + +describe('printFederatedSchema', () => { + const { schema, errors } = composeAndValidate(fixtures); + + it('composes without errors', () => { + expect(errors).toHaveLength(0); + }); + + it('prints a fully composed schema correctly', () => { + expect(printSchema(schema)).toMatchInlineSnapshot(` + "directive @stream on FIELD + + directive @transform(from: String!) on FIELD + + union AccountType = PasswordAccount | SMSAccount + + type Amazon { + referrer: String + } + + union Body = Image | Text + + type Book implements Product @key(fields: \\"isbn\\") { + isbn: String! + title: String + year: Int + similarBooks: [Book]! + metadata: [MetadataOrError] + inStock: Boolean + isCheckedOut: Boolean + upc: String! + sku: String! + name(delimeter: String = \\" \\"): String @requires(fields: \\"title year\\") + price: String + details: ProductDetailsBook + reviews: [Review] + relatedReviews: [Review!]! @requires(fields: \\"similarBooks { isbn }\\") + } + + union Brand = Ikea | Amazon + + type Car implements Vehicle @key(fields: \\"id\\") { + id: String! + description: String + price: String + retailPrice: String @requires(fields: \\"price\\") + } + + type Error { + code: Int + message: String + } + + type Furniture implements Product @key(fields: \\"sku\\") @key(fields: \\"upc\\") { + upc: String! + sku: String! + name: String + price: String + brand: Brand + metadata: [MetadataOrError] + details: ProductDetailsFurniture + inStock: Boolean + isHeavy: Boolean + reviews: [Review] + } + + type Ikea { + asile: Int + } + + type Image { + name: String! + attributes: ImageAttributes! + } + + type ImageAttributes { + url: String! + } + + type KeyValue { + key: String! + value: String! + } + + type Library @key(fields: \\"id\\") { + id: ID! + name: String + userAccount(id: ID! = 1): User @requires(fields: \\"name\\") + } + + union MetadataOrError = KeyValue | Error + + type Mutation { + login(username: String!, password: String!): User + reviewProduct(upc: String!, body: String!): Product + updateReview(review: UpdateReviewInput!): Review + deleteReview(id: ID!): Boolean + } + + type PasswordAccount @key(fields: \\"email\\") { + email: String! + } + + interface Product { + upc: String! + sku: String! + name: String + price: String + details: ProductDetails + inStock: Boolean + reviews: [Review] + } + + interface ProductDetails { + country: String + } + + type ProductDetailsBook implements ProductDetails { + country: String + pages: Int + } + + type ProductDetailsFurniture implements ProductDetails { + country: String + color: String + } + + type Query { + user(id: ID!): User + me: User + book(isbn: String!): Book + books: [Book] + library(id: ID!): Library + body: Body! + product(upc: String!): Product + vehicle(id: String!): Vehicle + topProducts(first: Int = 5): [Product] + topCars(first: Int = 5): [Car] + topReviews(first: Int = 5): [Review] + } + + type Review @key(fields: \\"id\\") { + id: ID! + body(format: Boolean = false): String + author: User @provides(fields: \\"username\\") + product: Product + metadata: [MetadataOrError] + } + + type SMSAccount @key(fields: \\"number\\") { + number: String + } + + type Text { + name: String! + attributes: TextAttributes! + } + + type TextAttributes { + bold: Boolean + text: String + } + + union Thing = Car | Ikea + + input UpdateReviewInput { + id: ID! + body: String + } + + type User @key(fields: \\"id\\") { + id: ID! + name: String + username: String + birthDate(locale: String): String + account: AccountType + metadata: [UserMetadata] + goodDescription: Boolean @requires(fields: \\"metadata { description }\\") + vehicle: Vehicle + thing: Thing + reviews: [Review] + numberOfReviews: Int! + goodAddress: Boolean @requires(fields: \\"metadata { address }\\") + } + + type UserMetadata { + name: String + address: String + description: String + } + + type Van implements Vehicle @key(fields: \\"id\\") { + id: String! + description: String + price: String + retailPrice: String @requires(fields: \\"price\\") + } + + interface Vehicle { + id: String! + description: String + price: String + retailPrice: String + } + " + `); + }); +}); diff --git a/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts b/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts deleted file mode 100644 index 656ac27cb20..00000000000 --- a/packages/apollo-federation/src/service/__tests__/validatePrinterCutover.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fixtures } from 'apollo-federation-integration-testsuite'; -import { composeAndValidate } from '../../composition'; -import { printSchema } from '../printFederatedSchema'; -import { printSchema as newPrintSchema } from '../newPrintFederatedSchema'; - -describe('printFederatedSchema and newPrintFederatedSchema equality', () => { - const { schema, errors } = composeAndValidate(fixtures); - - it('composes without errors', () => { - expect(errors).toHaveLength(0); - }); - - it('previous and new printers are identical', () => { - expect(printSchema(schema)).toEqual(newPrintSchema(schema)); - }); -}); diff --git a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts b/packages/apollo-federation/src/service/newPrintFederatedSchema.ts deleted file mode 100644 index 792ee8d6a58..00000000000 --- a/packages/apollo-federation/src/service/newPrintFederatedSchema.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Forked from graphql-js schemaPrinter.js file @ v14.7.0 - * This file has been modified to support printing federated - * schema, including associated federation directives. - */ - -import { - GraphQLSchema, - isSpecifiedDirective, - isIntrospectionType, - isSpecifiedScalarType, - GraphQLNamedType, - GraphQLDirective, - isScalarType, - isObjectType, - isInterfaceType, - isUnionType, - isEnumType, - isInputObjectType, - GraphQLScalarType, - GraphQLObjectType, - GraphQLInterfaceType, - GraphQLUnionType, - GraphQLEnumType, - GraphQLInputObjectType, - GraphQLArgument, - GraphQLInputField, - astFromValue, - print, - GraphQLField, - GraphQLEnumValue, - GraphQLString, - DEFAULT_DEPRECATION_REASON, - ASTNode, -} from 'graphql'; -import { Maybe } from '../composition'; -import { isFederationType } from '../types'; -import { isFederationDirective } from '../composition/utils'; -import federationDirectives, { gatherDirectives } from '../directives'; - -type Options = { - /** - * Descriptions are defined as preceding string literals, however an older - * experimental version of the SDL supported preceding comments as - * descriptions. Set to true to enable this deprecated behavior. - * This option is provided to ease adoption and will be removed in v16. - * - * Default: false - */ - commentDescriptions?: boolean; -}; - -/** - * Accepts options as a second argument: - * - * - commentDescriptions: - * Provide true to use preceding comments as the description. - * - */ -export function printSchema(schema: GraphQLSchema, options?: Options): string { - return printFilteredSchema( - schema, - // Federation change: treat the directives defined by the federation spec - // similarly to the directives defined by the GraphQL spec (ie, don't print - // their definitions). - (n) => !isSpecifiedDirective(n) && !isFederationDirective(n), - isDefinedType, - options, - ); -} - -export function printIntrospectionSchema( - schema: GraphQLSchema, - options?: Options, -): string { - return printFilteredSchema( - schema, - isSpecifiedDirective, - isIntrospectionType, - options, - ); -} - -// Federation change: treat the types defined by the federation spec -// similarly to the directives defined by the GraphQL spec (ie, don't print -// their definitions). -function isDefinedType(type: GraphQLNamedType): boolean { - return ( - !isSpecifiedScalarType(type) && - !isIntrospectionType(type) && - !isFederationType(type) - ); -} - -function printFilteredSchema( - schema: GraphQLSchema, - directiveFilter: (type: GraphQLDirective) => boolean, - typeFilter: (type: GraphQLNamedType) => boolean, - options?: Options, -): string { - const directives = schema.getDirectives().filter(directiveFilter); - const types = Object.values(schema.getTypeMap()) - .sort((type1, type2) => type1.name.localeCompare(type2.name)) - .filter(typeFilter); - - return ( - [printSchemaDefinition(schema)] - .concat( - directives.map(directive => printDirective(directive, options)), - types.map(type => printType(type, options)), - ) - .filter(Boolean) - .join('\n\n') + '\n' - ); -} - -function printSchemaDefinition(schema: GraphQLSchema): string | undefined { - if (isSchemaOfCommonNames(schema)) { - return; - } - - const operationTypes = []; - - const queryType = schema.getQueryType(); - if (queryType) { - operationTypes.push(` query: ${queryType.name}`); - } - - const mutationType = schema.getMutationType(); - if (mutationType) { - operationTypes.push(` mutation: ${mutationType.name}`); - } - - const subscriptionType = schema.getSubscriptionType(); - if (subscriptionType) { - operationTypes.push(` subscription: ${subscriptionType.name}`); - } - - return `schema {\n${operationTypes.join('\n')}\n}`; -} - -/** - * GraphQL schema define root types for each type of operation. These types are - * the same as any other type and can be named in any manner, however there is - * a common naming convention: - * - * schema { - * query: Query - * mutation: Mutation - * } - * - * When using this naming convention, the schema description can be omitted. - */ -function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { - const queryType = schema.getQueryType(); - if (queryType && queryType.name !== 'Query') { - return false; - } - - const mutationType = schema.getMutationType(); - if (mutationType && mutationType.name !== 'Mutation') { - return false; - } - - const subscriptionType = schema.getSubscriptionType(); - if (subscriptionType && subscriptionType.name !== 'Subscription') { - return false; - } - - return true; -} - -export function printType(type: GraphQLNamedType, options?: Options): string { - if (isScalarType(type)) { - return printScalar(type, options); - } else if (isObjectType(type)) { - return printObject(type, options); - } else if (isInterfaceType(type)) { - return printInterface(type, options); - } else if (isUnionType(type)) { - return printUnion(type, options); - } else if (isEnumType(type)) { - return printEnum(type, options); - } else if (isInputObjectType(type)) { - return printInputObject(type, options); - } - - throw Error('Unexpected type: ' + (type as GraphQLNamedType).toString()); -} - -function printScalar(type: GraphQLScalarType, options?: Options): string { - return printDescription(options, type) + `scalar ${type.name}`; -} - -function printObject(type: GraphQLObjectType, options?: Options): string { - const interfaces = type.getInterfaces(); - const implementedInterfaces = interfaces.length - ? ' implements ' + interfaces.map(i => i.name).join(' & ') - : ''; - - // Federation change: print `extend` keyword on type extensions. - // - // The implementation assumes that an owned type will have fields defined - // since that is required for a valid schema. Types that are *only* - // extensions will not have fields on the astNode since that ast doesn't - // exist. - // - // XXX revist extension checking - const isExtension = - type.extensionASTNodes && type.astNode && !type.astNode.fields; - - return ( - printDescription(options, type) + - (isExtension ? 'extend ' : '') + - `type ${type.name}${implementedInterfaces}` + - // Federation addition for printing @key usages - printFederationDirectives(type) + - printFields(options, type) - ); -} - -function printInterface(type: GraphQLInterfaceType, options?: Options): string { - // Federation change: print `extend` keyword on type extensions. - // See printObject for assumptions made. - // - // XXX revist extension checking - const isExtension = - type.extensionASTNodes && type.astNode && !type.astNode.fields; - - return ( - printDescription(options, type) + - (isExtension ? 'extend ' : '') + - `interface ${type.name}` + - // Federation change: graphql@14 doesn't support interfaces implementing interfaces - // printImplementedInterfaces(type) + - printFederationDirectives(type) + - printFields(options, type) - ); -} - -function printUnion(type: GraphQLUnionType, options?: Options): string { - const types = type.getTypes(); - const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; - return printDescription(options, type) + 'union ' + type.name + possibleTypes; -} - -function printEnum(type: GraphQLEnumType, options?: Options): string { - const values = type - .getValues() - .map( - (value, i) => - printDescription(options, value, ' ', !i) + - ' ' + - value.name + - printDeprecated(value), - ); - - return ( - printDescription(options, type) + `enum ${type.name}` + printBlock(values) - ); -} - -function printInputObject(type: GraphQLInputObjectType, options?: Options): string { - const fields = Object.values(type.getFields()).map( - (f, i) => - printDescription(options, f, ' ', !i) + ' ' + printInputValue(f), - ); - return ( - printDescription(options, type) + `input ${type.name}` + printBlock(fields) - ); -} - -function printFields( - options: Options | undefined, - type: GraphQLObjectType | GraphQLInterfaceType, -) { - const fields = Object.values(type.getFields()).map( - (f, i) => - printDescription(options, f, ' ', !i) + - ' ' + - f.name + - printArgs(options, f.args, ' ') + - ': ' + - String(f.type) + - printDeprecated(f) + - printFederationDirectives(f), - ); - return printBlock(fields); -} - -// Federation change: *do* print the usages of federation directives. -function printFederationDirectives( - type: GraphQLNamedType | GraphQLField, -): string { - if (!type.astNode) return ''; - if (isInputObjectType(type)) return ''; - - const allDirectives = gatherDirectives(type) - .filter((n) => - federationDirectives.some((fedDir) => fedDir.name === n.name.value), - ) - .map(print); - // const dedupedDirectives = [...new Set(allDirectives)]; - - return allDirectives.length > 0 ? ' ' + allDirectives.join(' ') : ''; -} - -export function printWithReducedWhitespace(ast: ASTNode): string { - return print(ast) - .replace(/\s+/g, ' ') - .trim(); -} - -function printBlock(items: string[]) { - return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; -} - -function printArgs( - options: Options | undefined, - args: GraphQLArgument[], - indentation = '', -) { - if (args.length === 0) { - return ''; - } - - // If every arg does not have a description, print them on one line. - if (args.every(arg => !arg.description)) { - return '(' + args.map(printInputValue).join(', ') + ')'; - } - - return ( - '(\n' + - args - .map( - (arg, i) => - printDescription(options, arg, ' ' + indentation, !i) + - ' ' + - indentation + - printInputValue(arg), - ) - .join('\n') + - '\n' + - indentation + - ')' - ); -} - -function printInputValue(arg: GraphQLInputField) { - const defaultAST = astFromValue(arg.defaultValue, arg.type); - let argDecl = arg.name + ': ' + String(arg.type); - if (defaultAST) { - argDecl += ` = ${print(defaultAST)}`; - } - return argDecl; -} - -function printDirective(directive: GraphQLDirective, options?: Options) { - return ( - printDescription(options, directive) + - 'directive @' + - directive.name + - printArgs(options, directive.args) + - (directive.isRepeatable ? ' repeatable' : '') + - ' on ' + - directive.locations.join(' | ') - ); -} - -function printDeprecated( - fieldOrEnumVal: GraphQLField | GraphQLEnumValue, -) { - if (!fieldOrEnumVal.isDeprecated) { - return ''; - } - const reason = fieldOrEnumVal.deprecationReason; - const reasonAST = astFromValue(reason, GraphQLString); - if (reasonAST && reason !== '' && reason !== DEFAULT_DEPRECATION_REASON) { - return ' @deprecated(reason: ' + print(reasonAST) + ')'; - } - return ' @deprecated'; -} - -function printDescription }>( - options: Options | undefined, - def: T, - indentation = '', - firstInBlock = true, -): string { - const { description } = def; - if (description == null) { - return ''; - } - - if (options?.commentDescriptions === true) { - return printDescriptionWithComments(description, indentation, firstInBlock); - } - - const preferMultipleLines = description.length > 70; - const blockString = printBlockString(description, '', preferMultipleLines); - const prefix = - indentation && !firstInBlock ? '\n' + indentation : indentation; - - return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; -} - -function printDescriptionWithComments( - description: string, - indentation: string, - firstInBlock: boolean, -) { - const prefix = indentation && !firstInBlock ? '\n' : ''; - const comment = description - .split('\n') - .map(line => indentation + (line !== '' ? '# ' + line : '#')) - .join('\n'); - - return prefix + comment + '\n'; -} - -/** - * Print a block string in the indented block form by adding a leading and - * trailing blank line. However, if a block string starts with whitespace and is - * a single-line, adding a leading blank line would strip that whitespace. - * - * @internal - */ -export function printBlockString( - value: string, - indentation: string = '', - preferMultipleLines: boolean = false, -): string { - const isSingleLine = value.indexOf('\n') === -1; - const hasLeadingSpace = value[0] === ' ' || value[0] === '\t'; - const hasTrailingQuote = value[value.length - 1] === '"'; - const hasTrailingSlash = value[value.length - 1] === '\\'; - const printAsMultipleLines = - !isSingleLine || - hasTrailingQuote || - hasTrailingSlash || - preferMultipleLines; - - let result = ''; - // Format a multi-line block quote to account for leading space. - if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) { - result += '\n' + indentation; - } - result += indentation ? value.replace(/\n/g, '\n' + indentation) : value; - if (printAsMultipleLines) { - result += '\n'; - } - - return '"""' + result.replace(/"""/g, '\\"""') + '"""'; -} diff --git a/packages/apollo-federation/src/service/printFederatedSchema.ts b/packages/apollo-federation/src/service/printFederatedSchema.ts index 53b0bea2e33..0d963d6c0b7 100644 --- a/packages/apollo-federation/src/service/printFederatedSchema.ts +++ b/packages/apollo-federation/src/service/printFederatedSchema.ts @@ -1,87 +1,119 @@ -/* - * - * This is largely a fork of printSchema from graphql-js with added support for - * federation directives. The default printSchema includes all directive - * *definitions* but doesn't include any directive *usages*. This version strips - * federation directive definitions (which will be the same in every federated - * schema), but keeps all their usages (so the gateway can process them). - * +/** + * Forked from graphql-js schemaPrinter.js file @ v14.7.0 + * This file has been modified to support printing federated + * schema, including associated federation directives. */ import { - DEFAULT_DEPRECATION_REASON, - GraphQLArgument, - GraphQLDirective, - GraphQLEnumType, - GraphQLEnumValue, - GraphQLField, - GraphQLInputField, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLNamedType, - GraphQLObjectType, - GraphQLScalarType, GraphQLSchema, - GraphQLString, - GraphQLUnionType, - astFromValue, - isEnumType, - isInputObjectType, - isInterfaceType, + isSpecifiedDirective, isIntrospectionType, - isObjectType, - isScalarType, isSpecifiedScalarType, + GraphQLNamedType, + GraphQLDirective, + isScalarType, + isObjectType, + isInterfaceType, isUnionType, + isEnumType, + isInputObjectType, + GraphQLScalarType, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLArgument, + GraphQLInputField, + astFromValue, print, - specifiedDirectives, + GraphQLField, + GraphQLEnumValue, + GraphQLString, + DEFAULT_DEPRECATION_REASON, + ASTNode, } from 'graphql'; -import federationDirectives, { gatherDirectives } from '../directives'; +import { Maybe } from '../composition'; import { isFederationType } from '../types'; +import { isFederationDirective } from '../composition/utils'; +import federationDirectives, { gatherDirectives } from '../directives'; -// Federation change: treat the directives defined by the federation spec -// similarly to the directives defined by the GraphQL spec (ie, don't print -// their definitions). -function isSpecifiedDirective(directive: GraphQLDirective): boolean { - return [...specifiedDirectives, ...federationDirectives].some( - specifiedDirective => specifiedDirective.name === directive.name, +type Options = { + /** + * Descriptions are defined as preceding string literals, however an older + * experimental version of the SDL supported preceding comments as + * descriptions. Set to true to enable this deprecated behavior. + * This option is provided to ease adoption and will be removed in v16. + * + * Default: false + */ + commentDescriptions?: boolean; +}; + +/** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + */ +export function printSchema(schema: GraphQLSchema, options?: Options): string { + return printFilteredSchema( + schema, + // Federation change: treat the directives defined by the federation spec + // similarly to the directives defined by the GraphQL spec (ie, don't print + // their definitions). + (n) => !isSpecifiedDirective(n) && !isFederationDirective(n), + isDefinedType, + options, + ); +} + +export function printIntrospectionSchema( + schema: GraphQLSchema, + options?: Options, +): string { + return printFilteredSchema( + schema, + isSpecifiedDirective, + isIntrospectionType, + options, ); } // Federation change: treat the types defined by the federation spec // similarly to the directives defined by the GraphQL spec (ie, don't print // their definitions). -function isDefinedType(type: GraphQLNamedType | GraphQLScalarType): boolean { +function isDefinedType(type: GraphQLNamedType): boolean { return ( - !isSpecifiedScalarType(type as GraphQLScalarType) && + !isSpecifiedScalarType(type) && !isIntrospectionType(type) && !isFederationType(type) ); } -export function printSchema(schema: GraphQLSchema): string { - const directives = schema - .getDirectives() - .filter(n => !isSpecifiedDirective(n)); - const typeMap = schema.getTypeMap(); - const types = Object.values(typeMap) +function printFilteredSchema( + schema: GraphQLSchema, + directiveFilter: (type: GraphQLDirective) => boolean, + typeFilter: (type: GraphQLNamedType) => boolean, + options?: Options, +): string { + const directives = schema.getDirectives().filter(directiveFilter); + const types = Object.values(schema.getTypeMap()) .sort((type1, type2) => type1.name.localeCompare(type2.name)) - .filter(isDefinedType); + .filter(typeFilter); return ( [printSchemaDefinition(schema)] .concat( - directives.map(directive => printDirective(directive)), - types.map(type => printType(type)), + directives.map(directive => printDirective(directive, options)), + types.map(type => printType(type, options)), ) .filter(Boolean) .join('\n\n') + '\n' ); } -/* - * below is directly copied from graphql-js with some minor modifications - */ function printSchemaDefinition(schema: GraphQLSchema): string | undefined { if (isSchemaOfCommonNames(schema)) { return; @@ -138,48 +170,34 @@ function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { return true; } -function printType(type: GraphQLNamedType): string { +export function printType(type: GraphQLNamedType, options?: Options): string { if (isScalarType(type)) { - return printScalar(type); + return printScalar(type, options); } else if (isObjectType(type)) { - return printObject(type); + return printObject(type, options); } else if (isInterfaceType(type)) { - return printInterface(type); + return printInterface(type, options); } else if (isUnionType(type)) { - return printUnion(type); + return printUnion(type, options); } else if (isEnumType(type)) { - return printEnum(type); + return printEnum(type, options); } else if (isInputObjectType(type)) { - return printInputObject(type); + return printInputObject(type, options); } - // Not reachable. All possible types have been considered. - /* istanbul ignore next */ - throw new Error(`Unexpected type: "${type}".`); + throw Error('Unexpected type: ' + (type as GraphQLNamedType).toString()); } -function printScalar(type: GraphQLScalarType): string { - return printDescription(type) + `scalar ${type.name}`; -} - -// Federation change: *do* print the usages of federation directives. -function printFederationDirectives( - type: GraphQLNamedType | GraphQLField, -): string { - if (!type.astNode) return ''; - if (isInputObjectType(type)) return ''; - const directives = gatherDirectives(type) - .filter(n => - federationDirectives.some(fedDir => fedDir.name === n.name.value), - ) - .map(print) - .join(' '); - - return directives.length > 0 ? ' ' + directives : ''; +function printScalar(type: GraphQLScalarType, options?: Options): string { + return printDescription(options, type) + `scalar ${type.name}`; } -function printObject(type: GraphQLObjectType): string { +function printObject(type: GraphQLObjectType, options?: Options): string { const interfaces = type.getInterfaces(); + const implementedInterfaces = interfaces.length + ? ' implements ' + interfaces.map(i => i.name).join(' & ') + : ''; + // Federation change: print `extend` keyword on type extensions. // // The implementation assumes that an owned type will have fields defined @@ -190,84 +208,118 @@ function printObject(type: GraphQLObjectType): string { // XXX revist extension checking const isExtension = type.extensionASTNodes && type.astNode && !type.astNode.fields; - const implementedInterfaces = interfaces.length - ? ' implements ' + interfaces.map(i => i.name).join(' & ') - : ''; + return ( - printDescription(type) + - `${isExtension ? 'extend ' : ''}type ${ - type.name - }${implementedInterfaces}${printFederationDirectives(type)}` + - printFields(type) + printDescription(options, type) + + (isExtension ? 'extend ' : '') + + `type ${type.name}${implementedInterfaces}` + + // Federation addition for printing @key usages + printFederationDirectives(type) + + printFields(options, type) ); } -function printInterface(type: GraphQLInterfaceType): string { +function printInterface(type: GraphQLInterfaceType, options?: Options): string { // Federation change: print `extend` keyword on type extensions. // See printObject for assumptions made. // // XXX revist extension checking const isExtension = type.extensionASTNodes && type.astNode && !type.astNode.fields; + return ( - printDescription(type) + - `${isExtension ? 'extend ' : ''}interface ${ - type.name - }${printFederationDirectives(type)}` + - printFields(type) + printDescription(options, type) + + (isExtension ? 'extend ' : '') + + `interface ${type.name}` + + // Federation change: graphql@14 doesn't support interfaces implementing interfaces + // printImplementedInterfaces(type) + + printFederationDirectives(type) + + printFields(options, type) ); } -function printUnion(type: GraphQLUnionType): string { +function printUnion(type: GraphQLUnionType, options?: Options): string { const types = type.getTypes(); const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; - return printDescription(type) + 'union ' + type.name + possibleTypes; + return printDescription(options, type) + 'union ' + type.name + possibleTypes; } -function printEnum(type: GraphQLEnumType): string { +function printEnum(type: GraphQLEnumType, options?: Options): string { const values = type .getValues() .map( - value => - printDescription(value, ' ') + + (value, i) => + printDescription(options, value, ' ', !i) + ' ' + value.name + printDeprecated(value), ); - return printDescription(type) + `enum ${type.name}` + printBlock(values); + return ( + printDescription(options, type) + `enum ${type.name}` + printBlock(values) + ); } -function printInputObject(type: GraphQLInputObjectType): string { +function printInputObject(type: GraphQLInputObjectType, options?: Options): string { const fields = Object.values(type.getFields()).map( - f => printDescription(f, ' ') + ' ' + printInputValue(f), + (f, i) => + printDescription(options, f, ' ', !i) + ' ' + printInputValue(f), + ); + return ( + printDescription(options, type) + `input ${type.name}` + printBlock(fields) ); - return printDescription(type) + `input ${type.name}` + printBlock(fields); } function printFields( - type: GraphQLInterfaceType | GraphQLObjectType | GraphQLInputObjectType, + options: Options | undefined, + type: GraphQLObjectType | GraphQLInterfaceType, ) { const fields = Object.values(type.getFields()).map( - f => - printDescription(f, ' ') + + (f, i) => + printDescription(options, f, ' ', !i) + ' ' + f.name + - printArgs(f.args, ' ') + + printArgs(options, f.args, ' ') + ': ' + String(f.type) + printDeprecated(f) + - // Federation change: print usages of federation directives. printFederationDirectives(f), ); return printBlock(fields); } +// Federation change: *do* print the usages of federation directives. +function printFederationDirectives( + type: GraphQLNamedType | GraphQLField, +): string { + if (!type.astNode) return ''; + if (isInputObjectType(type)) return ''; + + const allDirectives = gatherDirectives(type) + .filter((n) => + federationDirectives.some((fedDir) => fedDir.name === n.name.value), + ) + .map(print); + const dedupedDirectives = [...new Set(allDirectives)]; + + return dedupedDirectives.length > 0 ? ' ' + dedupedDirectives.join(' ') : ''; +} + +export function printWithReducedWhitespace(ast: ASTNode): string { + return print(ast) + .replace(/\s+/g, ' ') + .trim(); +} + function printBlock(items: string[]) { return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; } -function printArgs(args: GraphQLArgument[], indentation = '') { +function printArgs( + options: Options | undefined, + args: GraphQLArgument[], + indentation = '', +) { if (args.length === 0) { return ''; } @@ -281,8 +333,8 @@ function printArgs(args: GraphQLArgument[], indentation = '') { '(\n' + args .map( - arg => - printDescription(arg, ' ' + indentation) + + (arg, i) => + printDescription(options, arg, ' ' + indentation, !i) + ' ' + indentation + printInputValue(arg), @@ -294,7 +346,7 @@ function printArgs(args: GraphQLArgument[], indentation = '') { ); } -function printInputValue(arg: GraphQLInputField | GraphQLArgument) { +function printInputValue(arg: GraphQLInputField) { const defaultAST = astFromValue(arg.defaultValue, arg.type); let argDecl = arg.name + ': ' + String(arg.type); if (defaultAST) { @@ -303,12 +355,13 @@ function printInputValue(arg: GraphQLInputField | GraphQLArgument) { return argDecl; } -function printDirective(directive: GraphQLDirective) { +function printDirective(directive: GraphQLDirective, options?: Options) { return ( - printDescription(directive) + + printDescription(options, directive) + 'directive @' + directive.name + - printArgs(directive.args) + + printArgs(options, directive.args) + + (directive.isRepeatable ? ' repeatable' : '') + ' on ' + directive.locations.join(' | ') ); @@ -328,54 +381,74 @@ function printDeprecated( return ' @deprecated'; } -function printDescription( - def: - | GraphQLArgument - | GraphQLDirective - | GraphQLEnumType - | GraphQLField - | GraphQLInputField - | GraphQLInputObjectType - | GraphQLInterfaceType - | GraphQLNamedType - | GraphQLEnumValue - | GraphQLUnionType, - indentation: string = '', +function printDescription }>( + options: Options | undefined, + def: T, + indentation = '', + firstInBlock = true, ): string { - if (def.description == null) { + const { description } = def; + if (description == null) { return ''; } - const lines = descriptionLines(def.description, 120 - indentation.length); - if (lines.length === 1) { - return indentation + `"${lines[0]}"\n`; - } else { - return ( - indentation + ['"""', ...lines, '"""'].join('\n' + indentation) + '\n' - ); + if (options?.commentDescriptions === true) { + return printDescriptionWithComments(description, indentation, firstInBlock); } + + const preferMultipleLines = description.length > 70; + const blockString = printBlockString(description, '', preferMultipleLines); + const prefix = + indentation && !firstInBlock ? '\n' + indentation : indentation; + + return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; } -function descriptionLines(description: string, maxLen: number): Array { - const rawLines = description.split('\n'); - return rawLines.flatMap(line => { - if (line.length < maxLen + 5) { - return line; - } - // For > 120 character long lines, cut at space boundaries into sublines - // of ~80 chars. - return breakLine(line, maxLen); - }); +function printDescriptionWithComments( + description: string, + indentation: string, + firstInBlock: boolean, +) { + const prefix = indentation && !firstInBlock ? '\n' : ''; + const comment = description + .split('\n') + .map(line => indentation + (line !== '' ? '# ' + line : '#')) + .join('\n'); + + return prefix + comment + '\n'; } -function breakLine(line: string, maxLen: number): Array { - const parts = line.split(new RegExp(`((?: |^).{15,${maxLen - 40}}(?= |$))`)); - if (parts.length < 4) { - return [line]; +/** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + * + * @internal + */ +export function printBlockString( + value: string, + indentation: string = '', + preferMultipleLines: boolean = false, +): string { + const isSingleLine = value.indexOf('\n') === -1; + const hasLeadingSpace = value[0] === ' ' || value[0] === '\t'; + const hasTrailingQuote = value[value.length - 1] === '"'; + const hasTrailingSlash = value[value.length - 1] === '\\'; + const printAsMultipleLines = + !isSingleLine || + hasTrailingQuote || + hasTrailingSlash || + preferMultipleLines; + + let result = ''; + // Format a multi-line block quote to account for leading space. + if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) { + result += '\n' + indentation; } - const sublines = [parts[0] + parts[1] + parts[2]]; - for (let i = 3; i < parts.length; i += 2) { - sublines.push(parts[i].slice(1) + parts[i + 1]); + result += indentation ? value.replace(/\n/g, '\n' + indentation) : value; + if (printAsMultipleLines) { + result += '\n'; } - return sublines; + + return '"""' + result.replace(/"""/g, '\\"""') + '"""'; } From 52a0841fa89d111d5ac4a963d395069bf8b45e0f Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 20:50:37 -0700 Subject: [PATCH 05/10] Introduce new composition printer / implementation This forks from the newly updated schema printer. However, the goal of this printer is to print the FINAL composition, rather than the schema for a single federated service. By peeking at the existing annotations in the `extensions` on the composed `GraphQLSchema`, the composition printer can gather important metadata like type and field ownership which is then annotated in the final SDL. --- .../__tests__/printComposedSdl.test.ts | 263 +++++++++ .../src/service/printComposedSdl.ts | 498 ++++++++++++++++++ 2 files changed, 761 insertions(+) create mode 100644 packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts create mode 100644 packages/apollo-federation/src/service/printComposedSdl.ts diff --git a/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts b/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts new file mode 100644 index 00000000000..a5e29b7988b --- /dev/null +++ b/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts @@ -0,0 +1,263 @@ +import { fixtures } from 'apollo-federation-integration-testsuite'; +import { composeAndValidate } from '../../composition'; +import { printComposedSdl } from '../printComposedSdl'; + +describe('printComposedSdl', () => { + const { schema, errors } = composeAndValidate(fixtures); + + it('composes without errors', () => { + expect(errors).toHaveLength(0); + }); + + it('prints a fully composed schema correctly', () => { + expect(printComposedSdl(schema, fixtures)).toMatchInlineSnapshot(` + "schema + @graph(name: \\"accounts\\", url: \\"undefined\\") + @graph(name: \\"books\\", url: \\"undefined\\") + @graph(name: \\"documents\\", url: \\"undefined\\") + @graph(name: \\"inventory\\", url: \\"undefined\\") + @graph(name: \\"product\\", url: \\"undefined\\") + @graph(name: \\"reviews\\", url: \\"undefined\\") + @composedGraph(version: 1) + { + query: Query + mutation: Mutation + } + + directive @stream on FIELD + + directive @transform(from: String!) on FIELD + + union AccountType = PasswordAccount | SMSAccount + + type Amazon { + referrer: String + } + + union Body = Image | Text + + type Book implements Product + @owner(graph: \\"books\\") + @key(fields: \\"isbn\\", graph: \\"books\\") + @key(fields: \\"isbn\\", graph: \\"inventory\\") + @key(fields: \\"isbn\\", graph: \\"product\\") + @key(fields: \\"isbn\\", graph: \\"reviews\\") + { + isbn: String! + title: String + year: Int + similarBooks: [Book]! + metadata: [MetadataOrError] + inStock: Boolean @resolve(graph: \\"inventory\\") + isCheckedOut: Boolean @resolve(graph: \\"inventory\\") + upc: String! @resolve(graph: \\"product\\") + sku: String! @resolve(graph: \\"product\\") + name(delimeter: String = \\" \\"): String @resolve(graph: \\"product\\") @requires(fields: \\"title year\\") + price: String @resolve(graph: \\"product\\") + details: ProductDetailsBook @resolve(graph: \\"product\\") + reviews: [Review] @resolve(graph: \\"reviews\\") + relatedReviews: [Review!]! @resolve(graph: \\"reviews\\") @requires(fields: \\"similarBooks { isbn }\\") + } + + union Brand = Ikea | Amazon + + type Car implements Vehicle + @owner(graph: \\"product\\") + @key(fields: \\"id\\", graph: \\"product\\") + @key(fields: \\"id\\", graph: \\"reviews\\") + { + id: String! + description: String + price: String + retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"price\\") + } + + type Error { + code: Int + message: String + } + + type Furniture implements Product + @owner(graph: \\"product\\") + @key(fields: \\"upc\\", graph: \\"product\\") + @key(fields: \\"sku\\", graph: \\"product\\") + @key(fields: \\"sku\\", graph: \\"inventory\\") + @key(fields: \\"upc\\", graph: \\"reviews\\") + { + upc: String! + sku: String! + name: String + price: String + brand: Brand + metadata: [MetadataOrError] + details: ProductDetailsFurniture + inStock: Boolean @resolve(graph: \\"inventory\\") + isHeavy: Boolean @resolve(graph: \\"inventory\\") + reviews: [Review] @resolve(graph: \\"reviews\\") + } + + type Ikea { + asile: Int + } + + type Image { + name: String! + attributes: ImageAttributes! + } + + type ImageAttributes { + url: String! + } + + type KeyValue { + key: String! + value: String! + } + + type Library + @owner(graph: \\"books\\") + @key(fields: \\"id\\", graph: \\"books\\") + @key(fields: \\"id\\", graph: \\"accounts\\") + { + id: ID! + name: String + userAccount(id: ID! = 1): User @resolve(graph: \\"accounts\\") @requires(fields: \\"name\\") + } + + union MetadataOrError = KeyValue | Error + + type Mutation { + login(username: String!, password: String!): User @resolve(graph: \\"accounts\\") + reviewProduct(upc: String!, body: String!): Product @resolve(graph: \\"reviews\\") + updateReview(review: UpdateReviewInput!): Review @resolve(graph: \\"reviews\\") + deleteReview(id: ID!): Boolean @resolve(graph: \\"reviews\\") + } + + type PasswordAccount + @owner(graph: \\"accounts\\") + @key(fields: \\"email\\", graph: \\"accounts\\") + { + email: String! + } + + interface Product { + upc: String! + sku: String! + name: String + price: String + details: ProductDetails + inStock: Boolean + reviews: [Review] + } + + interface ProductDetails { + country: String + } + + type ProductDetailsBook implements ProductDetails { + country: String + pages: Int + } + + type ProductDetailsFurniture implements ProductDetails { + country: String + color: String + } + + type Query { + user(id: ID!): User @resolve(graph: \\"accounts\\") + me: User @resolve(graph: \\"accounts\\") + book(isbn: String!): Book @resolve(graph: \\"books\\") + books: [Book] @resolve(graph: \\"books\\") + library(id: ID!): Library @resolve(graph: \\"books\\") + body: Body! @resolve(graph: \\"documents\\") + product(upc: String!): Product @resolve(graph: \\"product\\") + vehicle(id: String!): Vehicle @resolve(graph: \\"product\\") + topProducts(first: Int = 5): [Product] @resolve(graph: \\"product\\") + topCars(first: Int = 5): [Car] @resolve(graph: \\"product\\") + topReviews(first: Int = 5): [Review] @resolve(graph: \\"reviews\\") + } + + type Review + @owner(graph: \\"reviews\\") + @key(fields: \\"id\\", graph: \\"reviews\\") + { + id: ID! + body(format: Boolean = false): String + author: User @provides(fields: \\"username\\") + product: Product + metadata: [MetadataOrError] + } + + type SMSAccount + @owner(graph: \\"accounts\\") + @key(fields: \\"number\\", graph: \\"accounts\\") + { + number: String + } + + type Text { + name: String! + attributes: TextAttributes! + } + + type TextAttributes { + bold: Boolean + text: String + } + + union Thing = Car | Ikea + + input UpdateReviewInput { + id: ID! + body: String + } + + type User + @owner(graph: \\"accounts\\") + @key(fields: \\"id\\", graph: \\"accounts\\") + @key(fields: \\"id\\", graph: \\"inventory\\") + @key(fields: \\"id\\", graph: \\"product\\") + @key(fields: \\"id\\", graph: \\"reviews\\") + { + id: ID! + name: String + username: String + birthDate(locale: String): String + account: AccountType + metadata: [UserMetadata] + goodDescription: Boolean @resolve(graph: \\"inventory\\") @requires(fields: \\"metadata { description }\\") + vehicle: Vehicle @resolve(graph: \\"product\\") + thing: Thing @resolve(graph: \\"product\\") + reviews: [Review] @resolve(graph: \\"reviews\\") + numberOfReviews: Int! @resolve(graph: \\"reviews\\") + goodAddress: Boolean @resolve(graph: \\"reviews\\") @requires(fields: \\"metadata { address }\\") + } + + type UserMetadata { + name: String + address: String + description: String + } + + type Van implements Vehicle + @owner(graph: \\"product\\") + @key(fields: \\"id\\", graph: \\"product\\") + @key(fields: \\"id\\", graph: \\"reviews\\") + { + id: String! + description: String + price: String + retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"price\\") + } + + interface Vehicle { + id: String! + description: String + price: String + retailPrice: String + } + " + `); + }); +}); diff --git a/packages/apollo-federation/src/service/printComposedSdl.ts b/packages/apollo-federation/src/service/printComposedSdl.ts new file mode 100644 index 00000000000..e88f37394a9 --- /dev/null +++ b/packages/apollo-federation/src/service/printComposedSdl.ts @@ -0,0 +1,498 @@ +import { + GraphQLSchema, + isSpecifiedDirective, + isIntrospectionType, + isSpecifiedScalarType, + GraphQLNamedType, + GraphQLDirective, + isScalarType, + isObjectType, + isInterfaceType, + isUnionType, + isEnumType, + isInputObjectType, + GraphQLScalarType, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLArgument, + GraphQLInputField, + astFromValue, + print, + GraphQLField, + GraphQLEnumValue, + GraphQLString, + DEFAULT_DEPRECATION_REASON, + ASTNode, +} from 'graphql'; +import { Maybe, ServiceDefinition, FederationType, FederationField } from '../composition'; +import { isFederationType } from '../types'; +import { isFederationDirective } from '../composition/utils'; + +type Options = { + /** + * Descriptions are defined as preceding string literals, however an older + * experimental version of the SDL supported preceding comments as + * descriptions. Set to true to enable this deprecated behavior. + * This option is provided to ease adoption and will be removed in v16. + * + * Default: false + */ + commentDescriptions?: boolean; +}; + +/** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + */ +export function printComposedSdl( + schema: GraphQLSchema, + serviceList: ServiceDefinition[], + options?: Options, +): string { + return printFilteredSchema( + schema, + // Federation change: we need service and url information for the @graph directives + serviceList, + // Federation change: treat the directives defined by the federation spec + // similarly to the directives defined by the GraphQL spec (ie, don't print + // their definitions). + (n) => !isSpecifiedDirective(n) && !isFederationDirective(n), + isDefinedType, + options, + ); +} + +export function printIntrospectionSchema( + schema: GraphQLSchema, + options?: Options, +): string { + return printFilteredSchema( + schema, + [], + isSpecifiedDirective, + isIntrospectionType, + options, + ); +} + +// Federation change: treat the types defined by the federation spec +// similarly to the directives defined by the GraphQL spec (ie, don't print +// their definitions). +function isDefinedType(type: GraphQLNamedType): boolean { + return ( + !isSpecifiedScalarType(type) && + !isIntrospectionType(type) && + !isFederationType(type) + ); +} + +function printFilteredSchema( + schema: GraphQLSchema, + // Federation change: we need service and url information for the @graph directives + serviceList: ServiceDefinition[], + directiveFilter: (type: GraphQLDirective) => boolean, + typeFilter: (type: GraphQLNamedType) => boolean, + options?: Options, +): string { + const directives = schema.getDirectives().filter(directiveFilter); + const types = Object.values(schema.getTypeMap()) + .sort((type1, type2) => type1.name.localeCompare(type2.name)) + .filter(typeFilter); + + return ( + [printSchemaDefinition(schema, serviceList)] + .concat( + directives.map(directive => printDirective(directive, options)), + types.map(type => printType(type, options)), + ) + .filter(Boolean) + .join('\n\n') + '\n' + ); +} + +function printSchemaDefinition( + schema: GraphQLSchema, + serviceList: ServiceDefinition[], +): string | undefined { + const operationTypes = []; + + const queryType = schema.getQueryType(); + if (queryType) { + operationTypes.push(` query: ${queryType.name}`); + } + + const mutationType = schema.getMutationType(); + if (mutationType) { + operationTypes.push(` mutation: ${mutationType.name}`); + } + + const subscriptionType = schema.getSubscriptionType(); + if (subscriptionType) { + operationTypes.push(` subscription: ${subscriptionType.name}`); + } + + return ( + 'schema' + + // Federation change: print @graph and @composedGraph schema directives + printFederationSchemaDirectives(serviceList) + + `\n{\n${operationTypes.join('\n')}\n}` + ); +} + +function printFederationSchemaDirectives(serviceList: ServiceDefinition[]) { + return ( + serviceList.map(service => `\n @graph(name: "${service.name}", url: "${service.url}")`).join('') + + `\n @composedGraph(version: 1)` + ); +} + +export function printType(type: GraphQLNamedType, options?: Options): string { + if (isScalarType(type)) { + return printScalar(type, options); + } else if (isObjectType(type)) { + return printObject(type, options); + } else if (isInterfaceType(type)) { + return printInterface(type, options); + } else if (isUnionType(type)) { + return printUnion(type, options); + } else if (isEnumType(type)) { + return printEnum(type, options); + } else if (isInputObjectType(type)) { + return printInputObject(type, options); + } + + throw Error('Unexpected type: ' + (type as GraphQLNamedType).toString()); +} + +function printScalar(type: GraphQLScalarType, options?: Options): string { + return printDescription(options, type) + `scalar ${type.name}`; +} + +function printObject(type: GraphQLObjectType, options?: Options): string { + const interfaces = type.getInterfaces(); + const implementedInterfaces = interfaces.length + ? ' implements ' + interfaces.map(i => i.name).join(' & ') + : ''; + + // Federation change: print `extend` keyword on type extensions. + // + // The implementation assumes that an owned type will have fields defined + // since that is required for a valid schema. Types that are *only* + // extensions will not have fields on the astNode since that ast doesn't + // exist. + // + // XXX revist extension checking + const isExtension = + type.extensionASTNodes && type.astNode && !type.astNode.fields; + + return ( + printDescription(options, type) + + (isExtension ? 'extend ' : '') + + `type ${type.name}` + + implementedInterfaces + + // Federation addition for printing @owner and @key usages + printFederationTypeDirectives(type) + + printFields(options, type) + ); +} + +// Federation change: print usages of the @owner and @key directives. +function printFederationTypeDirectives(type: GraphQLObjectType): string { + const metadata: FederationType = type.extensions?.federation; + if (!metadata) return ''; + + const { serviceName: ownerService, keys } = metadata; + if (!ownerService || !keys) return ''; + + // List keys with owner keys first + const sortedKeys = Object.entries(keys).sort(([serviceName]) => + serviceName === ownerService ? -1 : 0, + ); + + return ( + `\n @owner(graph: "${ownerService}")` + + sortedKeys.map(([service, keys]) => + keys + .map( + (selections) => + `\n @key(fields: "${selections.map(print)}", graph: "${service}")`, + ) + .join(''), + ) + .join('') + ); +} + +function printInterface(type: GraphQLInterfaceType, options?: Options): string { + // Federation change: print `extend` keyword on type extensions. + // See printObject for assumptions made. + // + // XXX revist extension checking + const isExtension = + type.extensionASTNodes && type.astNode && !type.astNode.fields; + + return ( + printDescription(options, type) + + (isExtension ? 'extend ' : '') + + `interface ${type.name}` + + printFields(options, type) + ); +} + +function printUnion(type: GraphQLUnionType, options?: Options): string { + const types = type.getTypes(); + const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; + return printDescription(options, type) + 'union ' + type.name + possibleTypes; +} + +function printEnum(type: GraphQLEnumType, options?: Options): string { + const values = type + .getValues() + .map( + (value, i) => + printDescription(options, value, ' ', !i) + + ' ' + + value.name + + printDeprecated(value), + ); + + return ( + printDescription(options, type) + `enum ${type.name}` + printBlock(values) + ); +} + +function printInputObject( + type: GraphQLInputObjectType, + options?: Options, +): string { + const fields = Object.values(type.getFields()).map( + (f, i) => + printDescription(options, f, ' ', !i) + ' ' + printInputValue(f), + ); + return ( + printDescription(options, type) + `input ${type.name}` + printBlock(fields) + ); +} + +function printFields( + options: Options | undefined, + type: GraphQLObjectType | GraphQLInterfaceType, +) { + + const fields = Object.values(type.getFields()).map( + (f, i) => + printDescription(options, f, ' ', !i) + + ' ' + + f.name + + printArgs(options, f.args, ' ') + + ': ' + + String(f.type) + + printDeprecated(f) + + printFederationFieldDirectives(f, type), + ); + + // Federation change: for entities, we want to print the block on a new line. + // This is just a formatting nice-to-have. + const isEntity = Boolean(type.extensions?.federation?.keys); + + return printBlock(fields, isEntity); +} + +export function printWithReducedWhitespace(ast: ASTNode): string { + return print(ast) + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Federation change: print @resolve, @requires, and @provides directives + * + * @param field + * @param parentType + */ +function printFederationFieldDirectives( + field: GraphQLField, + parentType: GraphQLObjectType | GraphQLInterfaceType, +): string { + if (!field.extensions?.federation) return ''; + + const { + serviceName, + requires = [], + provides = [], + }: FederationField = field.extensions.federation; + + let printed = ''; + // If a `serviceName` exists, we only want to print a `@resolve` directive + // if the `serviceName` differs from the `parentType`'s `serviceName` + if ( + serviceName && + serviceName !== parentType.extensions?.federation?.serviceName + ) { + printed += ` @resolve(graph: "${serviceName}")`; + } + + if (requires.length > 0) { + printed += ` @requires(fields: "${requires.map(printWithReducedWhitespace).join(' ')}")`; + } + + if (provides.length > 0) { + printed += ` @provides(fields: "${provides.map(printWithReducedWhitespace).join(' ')}")`; + } + + return printed; +} + +// Federation change: `onNewLine` is a formatting nice-to-have for printing +// types that have a list of directives attached, i.e. an entity. +function printBlock(items: string[], onNewLine?: boolean) { + return items.length !== 0 + ? onNewLine + ? '\n{\n' + items.join('\n') + '\n}' + : ' {\n' + items.join('\n') + '\n}' + : ''; +} + +function printArgs( + options: Options | undefined, + args: GraphQLArgument[], + indentation = '', +) { + if (args.length === 0) { + return ''; + } + + // If every arg does not have a description, print them on one line. + if (args.every((arg) => !arg.description)) { + return '(' + args.map(printInputValue).join(', ') + ')'; + } + + return ( + '(\n' + + args + .map( + (arg, i) => + printDescription(options, arg, ' ' + indentation, !i) + + ' ' + + indentation + + printInputValue(arg), + ) + .join('\n') + + '\n' + + indentation + + ')' + ); +} + +function printInputValue(arg: GraphQLInputField) { + const defaultAST = astFromValue(arg.defaultValue, arg.type); + let argDecl = arg.name + ': ' + String(arg.type); + if (defaultAST) { + argDecl += ` = ${print(defaultAST)}`; + } + return argDecl; +} + +function printDirective(directive: GraphQLDirective, options?: Options) { + return ( + printDescription(options, directive) + + 'directive @' + + directive.name + + printArgs(options, directive.args) + + (directive.isRepeatable ? ' repeatable' : '') + + ' on ' + + directive.locations.join(' | ') + ); +} + +function printDeprecated( + fieldOrEnumVal: GraphQLField | GraphQLEnumValue, +) { + if (!fieldOrEnumVal.isDeprecated) { + return ''; + } + const reason = fieldOrEnumVal.deprecationReason; + const reasonAST = astFromValue(reason, GraphQLString); + if (reasonAST && reason !== DEFAULT_DEPRECATION_REASON) { + return ' @deprecated(reason: ' + print(reasonAST) + ')'; + } + return ' @deprecated'; +} + +function printDescription }>( + options: Options | undefined, + def: T, + indentation = '', + firstInBlock = true, +): string { + const { description } = def; + if (description == null) { + return ''; + } + + if (options?.commentDescriptions === true) { + return printDescriptionWithComments(description, indentation, firstInBlock); + } + + const preferMultipleLines = description.length > 70; + const blockString = printBlockString(description, '', preferMultipleLines); + const prefix = + indentation && !firstInBlock ? '\n' + indentation : indentation; + + return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; +} + +function printDescriptionWithComments( + description: string, + indentation: string, + firstInBlock: boolean, +) { + const prefix = indentation && !firstInBlock ? '\n' : ''; + const comment = description + .split('\n') + .map((line) => indentation + (line !== '' ? '# ' + line : '#')) + .join('\n'); + + return prefix + comment + '\n'; +} + +/** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + * + * @internal + */ +export function printBlockString( + value: string, + indentation: string = '', + preferMultipleLines: boolean = false, +): string { + const isSingleLine = value.indexOf('\n') === -1; + const hasLeadingSpace = value[0] === ' ' || value[0] === '\t'; + const hasTrailingQuote = value[value.length - 1] === '"'; + const hasTrailingSlash = value[value.length - 1] === '\\'; + const printAsMultipleLines = + !isSingleLine || + hasTrailingQuote || + hasTrailingSlash || + preferMultipleLines; + + let result = ''; + // Format a multi-line block quote to account for leading space. + if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) { + result += '\n' + indentation; + } + result += indentation ? value.replace(/\n/g, '\n' + indentation) : value; + if (printAsMultipleLines) { + result += '\n'; + } + + return '"""' + result.replace(/"""/g, '\\"""') + '"""'; +} From 2f5c0963fab7c755c12ab80095b8ee518c2d0295 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 22:06:26 -0700 Subject: [PATCH 06/10] Stop sorting, pull owner keys up by name --- .../src/service/printComposedSdl.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/apollo-federation/src/service/printComposedSdl.ts b/packages/apollo-federation/src/service/printComposedSdl.ts index e88f37394a9..73c5fea01b8 100644 --- a/packages/apollo-federation/src/service/printComposedSdl.ts +++ b/packages/apollo-federation/src/service/printComposedSdl.ts @@ -26,6 +26,7 @@ import { GraphQLString, DEFAULT_DEPRECATION_REASON, ASTNode, + SelectionNode, } from 'graphql'; import { Maybe, ServiceDefinition, FederationType, FederationField } from '../composition'; import { isFederationType } from '../types'; @@ -210,14 +211,15 @@ function printFederationTypeDirectives(type: GraphQLObjectType): string { const { serviceName: ownerService, keys } = metadata; if (!ownerService || !keys) return ''; - // List keys with owner keys first - const sortedKeys = Object.entries(keys).sort(([serviceName]) => - serviceName === ownerService ? -1 : 0, - ); + // Separate owner @keys from the rest of the @keys so we can print them + // adjacent to the @owner directive. + const { [ownerService]: ownerKeys, ...restKeys } = keys + const ownerEntry: [string, (readonly SelectionNode[])[]] = [ownerService, ownerKeys]; + const restEntries = Object.entries(restKeys); return ( `\n @owner(graph: "${ownerService}")` + - sortedKeys.map(([service, keys]) => + [ownerEntry, ...restEntries].map(([service, keys]) => keys .map( (selections) => From 0784ff7a06e6152346a97a2dcbbeb874598a2246 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 23 Jul 2020 22:06:46 -0700 Subject: [PATCH 07/10] Return composed SDL result from composeAndValidate --- .../src/composition/composeAndValidate.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/apollo-federation/src/composition/composeAndValidate.ts b/packages/apollo-federation/src/composition/composeAndValidate.ts index 7502f96ca7d..0683092dba5 100644 --- a/packages/apollo-federation/src/composition/composeAndValidate.ts +++ b/packages/apollo-federation/src/composition/composeAndValidate.ts @@ -6,6 +6,7 @@ import { } from './validate'; import { ServiceDefinition } from './types'; import { normalizeTypeDefs } from './normalize'; +import { printComposedSdl } from '../service/printComposedSdl'; export function composeAndValidate(serviceList: ServiceDefinition[]) { const errors = validateServicesBeforeNormalization(serviceList); @@ -30,6 +31,13 @@ export function composeAndValidate(serviceList: ServiceDefinition[]) { }), ); + const composedSdl = printComposedSdl(compositionResult.schema, serviceList); + // TODO remove the warnings array once no longer used by clients - return { schema: compositionResult.schema, warnings: [], errors }; + return { + schema: compositionResult.schema, + warnings: [], + errors, + composedSdl, + }; } From ea86fc6ab2137c07acc14e71f539cfe2520d2c26 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Fri, 24 Jul 2020 10:37:46 -0700 Subject: [PATCH 08/10] Update changelog --- packages/apollo-federation/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apollo-federation/CHANGELOG.md b/packages/apollo-federation/CHANGELOG.md index 883c9ba0b4d..7ffdaf85310 100644 --- a/packages/apollo-federation/CHANGELOG.md +++ b/packages/apollo-federation/CHANGELOG.md @@ -4,7 +4,7 @@ > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. -- _Nothing yet! Stay tuned!_ +- New federation composition format. Capture federation metadata in SDL [PR #4405](https://github.com/apollographql/apollo-server/pull/4405) ## v0.18.0 From 481481dfcdf255f6512bb9c704846bd71c538e84 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Tue, 28 Jul 2020 10:38:24 -0700 Subject: [PATCH 09/10] Add dummy urls to test fixtures --- .../src/fixtures/accounts.ts | 1 + .../src/fixtures/books.ts | 1 + .../src/fixtures/documents.ts | 1 + .../src/fixtures/inventory.ts | 1 + .../src/fixtures/product.ts | 1 + .../src/fixtures/reviews.ts | 1 + 6 files changed, 6 insertions(+) diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/accounts.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/accounts.ts index 375ea53b5e5..c61ff5eed97 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/accounts.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/accounts.ts @@ -2,6 +2,7 @@ import gql from 'graphql-tag'; import { GraphQLResolverMap } from 'apollo-graphql'; export const name = 'accounts'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/books.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/books.ts index 3ae43cbab8e..4565214a21c 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/books.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/books.ts @@ -2,6 +2,7 @@ import gql from 'graphql-tag'; import { GraphQLResolverMap } from 'apollo-graphql'; export const name = 'books'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/documents.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/documents.ts index 6f04d2f5de1..a237449ec8e 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/documents.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/documents.ts @@ -1,6 +1,7 @@ import gql from 'graphql-tag'; export const name = 'documents'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/inventory.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/inventory.ts index 3cbafc2b15c..603e1bc175a 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/inventory.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/inventory.ts @@ -2,6 +2,7 @@ import gql from 'graphql-tag'; import { GraphQLResolverMap } from 'apollo-graphql'; export const name = 'inventory'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/product.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/product.ts index e1de28eb495..344222eb46f 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/product.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/product.ts @@ -2,6 +2,7 @@ import gql from 'graphql-tag'; import { GraphQLResolverMap } from 'apollo-graphql'; export const name = 'product'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD diff --git a/packages/apollo-federation-integration-testsuite/src/fixtures/reviews.ts b/packages/apollo-federation-integration-testsuite/src/fixtures/reviews.ts index 69b8f58ef90..7660773bc49 100644 --- a/packages/apollo-federation-integration-testsuite/src/fixtures/reviews.ts +++ b/packages/apollo-federation-integration-testsuite/src/fixtures/reviews.ts @@ -2,6 +2,7 @@ import { GraphQLResolverMap } from 'apollo-graphql'; import gql from 'graphql-tag'; export const name = 'reviews'; +export const url = `https://${name}.api.com`; export const typeDefs = gql` directive @stream on FIELD directive @transform(from: String!) on FIELD From f4ae4918925dc1ac18ea3b1df5085c283cda44cb Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Tue, 28 Jul 2020 11:17:12 -0700 Subject: [PATCH 10/10] Update snapshot with urls --- .../src/service/__tests__/printComposedSdl.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts b/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts index a5e29b7988b..8fe1bb98a54 100644 --- a/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts +++ b/packages/apollo-federation/src/service/__tests__/printComposedSdl.test.ts @@ -12,12 +12,12 @@ describe('printComposedSdl', () => { it('prints a fully composed schema correctly', () => { expect(printComposedSdl(schema, fixtures)).toMatchInlineSnapshot(` "schema - @graph(name: \\"accounts\\", url: \\"undefined\\") - @graph(name: \\"books\\", url: \\"undefined\\") - @graph(name: \\"documents\\", url: \\"undefined\\") - @graph(name: \\"inventory\\", url: \\"undefined\\") - @graph(name: \\"product\\", url: \\"undefined\\") - @graph(name: \\"reviews\\", url: \\"undefined\\") + @graph(name: \\"accounts\\", url: \\"https://accounts.api.com\\") + @graph(name: \\"books\\", url: \\"https://books.api.com\\") + @graph(name: \\"documents\\", url: \\"https://documents.api.com\\") + @graph(name: \\"inventory\\", url: \\"https://inventory.api.com\\") + @graph(name: \\"product\\", url: \\"https://product.api.com\\") + @graph(name: \\"reviews\\", url: \\"https://reviews.api.com\\") @composedGraph(version: 1) { query: Query