diff --git a/src/utilities/__tests__/buildASTSchema-test.js b/src/utilities/__tests__/buildASTSchema-test.js index 541cf48a1f..8317b44c1a 100644 --- a/src/utilities/__tests__/buildASTSchema-test.js +++ b/src/utilities/__tests__/buildASTSchema-test.js @@ -66,6 +66,36 @@ type Hello { expect(output).to.equal(body); }); + it('Supports descriptions', () => { + const body = ` +schema { + query: Hello +} + +# This is a directive +directive @foo( + # It has an argument + arg: Int +) on FIELD + +# With an enum +enum Color { + RED + + # Not a creative color + GREEN + BLUE +} + +# What a great type +type Hello { + # And a field to boot + str: String +} +`; + const output = cycleOutput(body); + expect(output).to.equal(body); + }); it('Maintains @skip & @include', () => { const body = ` diff --git a/src/utilities/__tests__/extendSchema-test.js b/src/utilities/__tests__/extendSchema-test.js index 87ae5e096f..0cfb7c1d68 100644 --- a/src/utilities/__tests__/extendSchema-test.js +++ b/src/utilities/__tests__/extendSchema-test.js @@ -131,6 +131,20 @@ describe('extendSchema', () => { } ]); }); + it('can describe the extended fields', async () => { + const ast = parse(` + extend type Query { + # New field description. + newField: String + } + `); + const extendedSchema = extendSchema(testSchema, ast); + + expect( + extendedSchema.getType('Query').getFields().newField.description + ).to.equal('New field description.'); + }); + it('extends objects by adding new fields', () => { const ast = parse(` extend type Foo { diff --git a/src/utilities/__tests__/schemaPrinter-test.js b/src/utilities/__tests__/schemaPrinter-test.js index b75cbf8431..a68e9863db 100644 --- a/src/utilities/__tests__/schemaPrinter-test.js +++ b/src/utilities/__tests__/schemaPrinter-test.js @@ -601,12 +601,32 @@ schema { query: Root } -directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE - +# Directs the executor to include this field or fragment only when the \`if\` argument is true. +directive @include( + # Included when true. + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +# Directs the executor to skip this field or fragment when the \`if\` argument is true. +directive @skip( + # Skipped when true. + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +# Marks an element of a GraphQL schema as no longer supported. +directive @deprecated( + # Explains why this element was deprecated, usually also including a suggestion + # for how to access supported similar data. Formatted in + # [Markdown](https://daringfireball.net/projects/markdown/). + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE + +# A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +# +# In some cases, you need to provide options to alter GraphQL's execution behavior +# in ways field arguments will not suffice, such as conditionally including or +# skipping a field. Directives provide this by describing additional information +# to the executor. type __Directive { name: String! description: String @@ -617,27 +637,67 @@ type __Directive { onField: Boolean! @deprecated(reason: "Use \`locations\`.") } +# A Directive can be adjacent to many parts of the GraphQL language, a +# __DirectiveLocation describes one such possible adjacencies. enum __DirectiveLocation { + # Location adjacent to a query operation. QUERY + + # Location adjacent to a mutation operation. MUTATION + + # Location adjacent to a subscription operation. SUBSCRIPTION + + # Location adjacent to a field. FIELD + + # Location adjacent to a fragment definition. FRAGMENT_DEFINITION + + # Location adjacent to a fragment spread. FRAGMENT_SPREAD + + # Location adjacent to an inline fragment. INLINE_FRAGMENT + + # Location adjacent to a schema definition. SCHEMA + + # Location adjacent to a scalar definition. SCALAR + + # Location adjacent to an object type definition. OBJECT + + # Location adjacent to a field definition. FIELD_DEFINITION + + # Location adjacent to an argument definition. ARGUMENT_DEFINITION + + # Location adjacent to an interface definition. INTERFACE + + # Location adjacent to a union definition. UNION + + # Location adjacent to an enum definition. ENUM + + # Location adjacent to an enum value definition. ENUM_VALUE + + # Location adjacent to an input object type definition. INPUT_OBJECT + + # Location adjacent to an input object field definition. INPUT_FIELD_DEFINITION } +# One possible value for a given Enum. Enum values are unique values, not a +# placeholder for a string or numeric value. However an Enum value is returned in +# a JSON response as a string. type __EnumValue { name: String! description: String @@ -645,6 +705,8 @@ type __EnumValue { deprecationReason: String } +# Object and Interface types are described by a list of Fields, each of which has +# a name, potentially a list of arguments, and a return type. type __Field { name: String! description: String @@ -654,21 +716,46 @@ type __Field { deprecationReason: String } +# Arguments provided to Fields or Directives and the input fields of an +# InputObject are represented as Input Values which describe their type and +# optionally a default value. type __InputValue { name: String! description: String type: __Type! + + # A GraphQL-formatted string representing the default value for this input value. defaultValue: String } +# A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +# available types and directives on the server, as well as the entry points for +# query, mutation, and subscription operations. type __Schema { + # A list of all types supported by this server. types: [__Type!]! + + # The type that query operations will be rooted at. queryType: __Type! + + # If this server supports mutation, the type that mutation operations will be rooted at. mutationType: __Type + + # If this server support subscription, the type that subscription operations will be rooted at. subscriptionType: __Type + + # A list of all directives supported by this server. directives: [__Directive!]! } +# The fundamental unit of any GraphQL Schema is the type. There are many kinds of +# types in GraphQL as represented by the \`__TypeKind\` enum. +# +# Depending on the kind of a type, certain fields describe information about that +# type. Scalar types provide no information beyond a name and description, while +# Enum types provide their values. Object and Interface types provide the fields +# they describe. Abstract types, Union and Interface, provide the Object types +# possible at runtime. List and NonNull types compose other types. type __Type { kind: __TypeKind! name: String @@ -681,17 +768,34 @@ type __Type { ofType: __Type } +# An enum describing what kind of type a given \`__Type\` is. enum __TypeKind { + # Indicates this type is a scalar. SCALAR + + # Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields. OBJECT + + # Indicates this type is an interface. \`fields\` and \`possibleTypes\` are valid fields. INTERFACE + + # Indicates this type is a union. \`possibleTypes\` is a valid field. UNION + + # Indicates this type is an enum. \`enumValues\` is a valid field. ENUM + + # Indicates this type is an input object. \`inputFields\` is a valid field. INPUT_OBJECT + + # Indicates this type is a list. \`ofType\` is a valid field. LIST + + # Indicates this type is a non-null. \`ofType\` is a valid field. NON_NULL } `; + console.error(output); // eslint-disable-line expect(output).to.equal(introspectionSchema); }); }); diff --git a/src/utilities/buildASTSchema.js b/src/utilities/buildASTSchema.js index cdab65ce3c..334dcba1aa 100644 --- a/src/utilities/buildASTSchema.js +++ b/src/utilities/buildASTSchema.js @@ -13,7 +13,7 @@ import invariant from '../jsutils/invariant'; import keyMap from '../jsutils/keyMap'; import keyValMap from '../jsutils/keyValMap'; import { valueFromAST } from './valueFromAST'; - +import { TokenKind } from '../language/lexer'; import { getArgumentValues } from '../execution/values'; import { @@ -31,6 +31,7 @@ import { } from '../language/kinds'; import type { + Location, Document, Directive, Type, @@ -261,6 +262,7 @@ export function buildASTSchema(ast: Document): GraphQLSchema { function getDirective(directiveAST: DirectiveDefinition): GraphQLDirective { return new GraphQLDirective({ name: directiveAST.name.value, + description: getDescription(directiveAST), locations: directiveAST.locations.map( node => ((node.value: any): DirectiveLocationEnum) ), @@ -348,12 +350,12 @@ export function buildASTSchema(ast: Document): GraphQLSchema { function makeTypeDef(def: ObjectTypeDefinition) { const typeName = def.name.value; - const config = { + return new GraphQLObjectType({ name: typeName, + description: getDescription(def), fields: () => makeFieldDefMap(def), interfaces: () => makeImplementedInterfaces(def), - }; - return new GraphQLObjectType(config); + }); } function makeFieldDefMap( @@ -364,6 +366,7 @@ export function buildASTSchema(ast: Document): GraphQLSchema { field => field.name.value, field => ({ type: produceOutputType(field.type), + description: getDescription(field), args: makeInputValues(field.arguments), deprecationReason: getDeprecationReason(field.directives) }) @@ -381,28 +384,34 @@ export function buildASTSchema(ast: Document): GraphQLSchema { value => value.name.value, value => { const type = produceInputType(value.type); - return { type, defaultValue: valueFromAST(value.defaultValue, type) }; + return { + type, + description: getDescription(value), + defaultValue: valueFromAST(value.defaultValue, type) + }; } ); } function makeInterfaceDef(def: InterfaceTypeDefinition) { const typeName = def.name.value; - const config = { + return new GraphQLInterfaceType({ name: typeName, + description: getDescription(def), resolveType: () => null, fields: () => makeFieldDefMap(def), - }; - return new GraphQLInterfaceType(config); + }); } function makeEnumDef(def: EnumTypeDefinition) { const enumType = new GraphQLEnumType({ name: def.name.value, + description: getDescription(def), values: keyValMap( def.values, enumValue => enumValue.name.value, enumValue => ({ + description: getDescription(enumValue), deprecationReason: getDeprecationReason(enumValue.directives) }) ), @@ -414,6 +423,7 @@ export function buildASTSchema(ast: Document): GraphQLSchema { function makeUnionDef(def: UnionTypeDefinition) { return new GraphQLUnionType({ name: def.name.value, + description: getDescription(def), resolveType: () => null, types: def.types.map(t => produceObjectType(t)), }); @@ -422,6 +432,7 @@ export function buildASTSchema(ast: Document): GraphQLSchema { function makeScalarDef(def: ScalarTypeDefinition) { return new GraphQLScalarType({ name: def.name.value, + description: getDescription(def), serialize: () => null, // Note: validation calls the parse functions to determine if a // literal value is correct. Returning null would cause use of custom @@ -435,6 +446,7 @@ export function buildASTSchema(ast: Document): GraphQLSchema { function makeInputObjectDef(def: InputObjectTypeDefinition) { return new GraphQLInputObjectType({ name: def.name.value, + description: getDescription(def), fields: () => makeInputValues(def.fields), }); } @@ -454,3 +466,47 @@ function getDeprecationReason(directives: ?Array): ?string { ); return (reason: any); } + +/** + * Given an ast node, returns its string description based on a contiguous + * block full-line of comments preceding it. + */ +export function getDescription(node: { loc?: Location }): ?string { + const loc = node.loc; + if (!loc) { + return; + } + const comments = []; + let minSpaces; + let token = loc.startToken.prev; + while ( + token && + token.kind === TokenKind.COMMENT && + token.next && token.prev && + token.line + 1 === token.next.line && + token.line !== token.prev.line + ) { + const value = String(token.value); + const spaces = leadingSpaces(value); + if (minSpaces === undefined || spaces < minSpaces) { + minSpaces = spaces; + } + comments.push(value); + token = token.prev; + } + return comments + .reverse() + .map(comment => comment.slice(minSpaces)) + .join('\n'); +} + +// Count the number of spaces on the starting side of a string. +function leadingSpaces(str) { + let i = 0; + for (; i < str.length; i++) { + if (str[i] !== ' ') { + break; + } + } + return i; +} diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index 3065258353..d22c3c93dd 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -11,6 +11,7 @@ import invariant from '../jsutils/invariant'; import keyMap from '../jsutils/keyMap'; import keyValMap from '../jsutils/keyValMap'; +import { getDescription } from './buildASTSchema'; import { valueFromAST } from './valueFromAST'; import { GraphQLError } from '../error/GraphQLError'; import { GraphQLSchema } from '../type/schema'; @@ -426,6 +427,7 @@ export function extendSchema( ); } newFieldMap[fieldName] = { + description: getDescription(field), type: buildOutputFieldType(field.type), args: buildInputValues(field.arguments), resolve: cannotExecuteClientSchema, @@ -462,6 +464,7 @@ export function extendSchema( function buildObjectType(typeAST: ObjectTypeDefinition): GraphQLObjectType { return new GraphQLObjectType({ name: typeAST.name.value, + description: getDescription(typeAST), interfaces: () => buildImplementedInterfaces(typeAST), fields: () => buildFieldMap(typeAST), }); @@ -470,6 +473,7 @@ export function extendSchema( function buildInterfaceType(typeAST: InterfaceTypeDefinition) { return new GraphQLInterfaceType({ name: typeAST.name.value, + description: getDescription(typeAST), fields: () => buildFieldMap(typeAST), resolveType: cannotExecuteClientSchema, }); @@ -478,6 +482,7 @@ export function extendSchema( function buildUnionType(typeAST: UnionTypeDefinition) { return new GraphQLUnionType({ name: typeAST.name.value, + description: getDescription(typeAST), types: typeAST.types.map(getObjectTypeFromAST), resolveType: cannotExecuteClientSchema, }); @@ -486,6 +491,7 @@ export function extendSchema( function buildScalarType(typeAST: ScalarTypeDefinition) { return new GraphQLScalarType({ name: typeAST.name.value, + description: getDescription(typeAST), serialize: () => null, // Note: validation calls the parse functions to determine if a // literal value is correct. Returning null would cause use of custom @@ -499,6 +505,7 @@ export function extendSchema( function buildEnumType(typeAST: EnumTypeDefinition) { return new GraphQLEnumType({ name: typeAST.name.value, + description: getDescription(typeAST), values: keyValMap(typeAST.values, v => v.name.value, () => ({})), }); } @@ -506,6 +513,7 @@ export function extendSchema( function buildInputObjectType(typeAST: InputObjectTypeDefinition) { return new GraphQLInputObjectType({ name: typeAST.name.value, + description: getDescription(typeAST), fields: () => buildInputValues(typeAST.fields), }); } @@ -533,6 +541,7 @@ export function extendSchema( field => field.name.value, field => ({ type: buildOutputFieldType(field.type), + description: getDescription(field), args: buildInputValues(field.arguments), resolve: cannotExecuteClientSchema, }) @@ -547,6 +556,7 @@ export function extendSchema( const type = buildInputFieldType(value.type); return { type, + description: getDescription(value), defaultValue: valueFromAST(value.defaultValue, type) }; } diff --git a/src/utilities/schemaPrinter.js b/src/utilities/schemaPrinter.js index fe2d0ae524..41e2e523e5 100644 --- a/src/utilities/schemaPrinter.js +++ b/src/utilities/schemaPrinter.js @@ -116,52 +116,97 @@ function printType(type: GraphQLType): string { } function printScalar(type: GraphQLScalarType): string { - return `scalar ${type.name}`; + return printDescription(type) + + `scalar ${type.name}`; } function printObject(type: GraphQLObjectType): string { const interfaces = type.getInterfaces(); const implementedInterfaces = interfaces.length ? ' implements ' + interfaces.map(i => i.name).join(', ') : ''; - return `type ${type.name}${implementedInterfaces} {\n` + - printFields(type) + '\n' + - '}'; + return printDescription(type) + + `type ${type.name}${implementedInterfaces} {\n` + + printFields(type) + '\n' + + '}'; } function printInterface(type: GraphQLInterfaceType): string { - return `interface ${type.name} {\n` + - printFields(type) + '\n' + - '}'; + return printDescription(type) + + `interface ${type.name} {\n` + + printFields(type) + '\n' + + '}'; } function printUnion(type: GraphQLUnionType): string { - return `union ${type.name} = ${type.getTypes().join(' | ')}`; + return printDescription(type) + + `union ${type.name} = ${type.getTypes().join(' | ')}`; } function printEnum(type: GraphQLEnumType): string { - const values = type.getValues(); - return `enum ${type.name} {\n` + - values.map(v => ' ' + v.name + printDeprecated(v)).join('\n') + '\n' + - '}'; + return printDescription(type) + + `enum ${type.name} {\n` + + printEnumValues(type.getValues()) + '\n' + + '}'; +} + +function printEnumValues(values): string { + return values.map((value, i) => + printDescription(value, ' ', !i) + ' ' + + value.name + printDeprecated(value) + ).join('\n'); } function printInputObject(type: GraphQLInputObjectType): string { const fieldMap = type.getFields(); const fields = Object.keys(fieldMap).map(fieldName => fieldMap[fieldName]); - return `input ${type.name} {\n` + - fields.map(f => ' ' + printInputValue(f)).join('\n') + '\n' + - '}'; + return printDescription(type) + + `input ${type.name} {\n` + + fields.map((f, i) => + printDescription(f, ' ', !i) + ' ' + printInputValue(f) + ).join('\n') + '\n' + + '}'; } function printFields(type) { const fieldMap = type.getFields(); const fields = Object.keys(fieldMap).map(fieldName => fieldMap[fieldName]); - return fields.map( - f => ' ' + f.name + printArgs(f) + ': ' + - String(f.type) + printDeprecated(f) + return fields.map((f, i) => + printDescription(f, ' ', !i) + ' ' + + f.name + printArgs(f.args, ' ') + ': ' + + String(f.type) + printDeprecated(f) ).join('\n'); } +function printArgs(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(arg, ' ' + indentation, !i) + ' ' + indentation + + printInputValue(arg) + ).join('\n') + '\n' + indentation + ')'; +} + +function printInputValue(arg) { + let argDecl = arg.name + ': ' + String(arg.type); + if (!isNullish(arg.defaultValue)) { + argDecl += ` = ${print(astFromValue(arg.defaultValue, arg.type))}`; + } + return argDecl; +} + +function printDirective(directive) { + return printDescription(directive) + + 'directive @' + directive.name + printArgs(directive.args) + + ' on ' + directive.locations.join(' | '); +} + function printDeprecated(fieldOrEnumVal) { const reason = fieldOrEnumVal.deprecationReason; if (isNullish(reason)) { @@ -177,22 +222,38 @@ function printDeprecated(fieldOrEnumVal) { print(astFromValue(reason, GraphQLString)) + ')'; } -function printArgs(fieldOrDirectives) { - if (fieldOrDirectives.args.length === 0) { +function printDescription(def, indentation = '', firstInBlock = true): string { + if (!def.description) { return ''; } - return '(' + fieldOrDirectives.args.map(printInputValue).join(', ') + ')'; -} - -function printInputValue(arg) { - let argDecl = arg.name + ': ' + String(arg.type); - if (!isNullish(arg.defaultValue)) { - argDecl += ` = ${print(astFromValue(arg.defaultValue, arg.type))}`; + const lines = def.description.split('\n'); + let description = indentation && !firstInBlock ? '\n' : ''; + for (let i = 0; i < lines.length; i++) { + if (lines[i] === '') { + description += indentation + '#\n'; + } else { + // For > 120 character long lines, cut at space boundaries into sublines + // of ~80 chars. + const sublines = breakLine(lines[i], 120 - indentation.length); + for (let j = 0; j < sublines.length; j++) { + description += indentation + '# ' + sublines[j] + '\n'; + } + } } - return argDecl; + return description; } -function printDirective(directive) { - return 'directive @' + directive.name + printArgs(directive) + - ' on ' + directive.locations.join(' | '); +function breakLine(line: string, len: number): Array { + if (line.length < len + 5) { + return [ line ]; + } + const parts = line.split(new RegExp(`((?: |^).{15,${len - 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; }