diff --git a/src/__tests__/starWarsIntrospectionTests.js b/src/__tests__/starWarsIntrospectionTests.js index f2e0fef4f6..0bfbafc9d1 100644 --- a/src/__tests__/starWarsIntrospectionTests.js +++ b/src/__tests__/starWarsIntrospectionTests.js @@ -66,6 +66,12 @@ describe('Star Wars Introspection Tests', () => { { name: '__InputValue' }, + { + name: '__Annotation' + }, + { + name: '__AnnotationArgument' + }, { name: '__EnumValue' }, diff --git a/src/language/__tests__/kitchen-sink.graphql b/src/language/__tests__/kitchen-sink.graphql index 0e04e2e42d..4536d2e7f3 100644 --- a/src/language/__tests__/kitchen-sink.graphql +++ b/src/language/__tests__/kitchen-sink.graphql @@ -55,3 +55,8 @@ fragment frag on Friend { unnamed(truthy: true, falsey: false), query } + +extend type User { + @iAmAnAnnotation(default: "Foo") + name: String +} diff --git a/src/language/__tests__/printer.js b/src/language/__tests__/printer.js index c6216ddc4c..57d9b9a870 100644 --- a/src/language/__tests__/printer.js +++ b/src/language/__tests__/printer.js @@ -97,6 +97,11 @@ fragment frag on Friend { unnamed(truthy: true, falsey: false) query } + +extend type User { + @iAmAnAnnotation(default: "Foo") + name: String +} `); }); diff --git a/src/language/__tests__/schema-parser.js b/src/language/__tests__/schema-parser.js index 8b4754ac17..d8d3eda70c 100644 --- a/src/language/__tests__/schema-parser.js +++ b/src/language/__tests__/schema-parser.js @@ -42,17 +42,31 @@ function nameNode(name, loc) { }; } +function annotationNode(name, args, loc) { + return { + kind: 'Annotation', + name, + arguments: args, + loc + }; +} + function fieldNode(name, type, loc) { return fieldNodeWithArgs(name, type, [], loc); } function fieldNodeWithArgs(name, type, args, loc) { + return fieldNodeWithArgsAndAnnotations(name, type, args, [], loc); +} + +function fieldNodeWithArgsAndAnnotations(name, type, args, annotations, loc) { return { kind: 'FieldDefinition', name, arguments: args, type, loc, + annotations, }; } @@ -541,4 +555,86 @@ input Hello { expect(() => parse(body)).to.throw('Error'); }); + it('Simple fields with annotations', () => { + var body = ` +type Hello { + @mock(value: "hello") + world: String + @ignore + @mock(value: 2) + hello: Int +}`; + var doc = parse(body); + var loc = createLocFn(body); + var expected = { + kind: 'Document', + definitions: [ + { + kind: 'ObjectTypeDefinition', + name: nameNode('Hello', loc(6, 11)), + interfaces: [], + fields: [ + fieldNodeWithArgsAndAnnotations( + nameNode('world', loc(40, 45)), + typeNode('String', loc(47, 53)), + [], + [ + annotationNode( + nameNode('mock', loc(17, 21)), + [ + { + kind: 'Argument', + name: nameNode('value', loc(22, 27)), + value: { + kind: 'StringValue', + value: 'hello', + loc: loc(29, 36), + }, + loc: loc(22, 36), + } + ], + loc(16, 37) + ), + ], + loc(16, 53) + ), + fieldNodeWithArgsAndAnnotations( + nameNode('hello', loc(84, 89)), + typeNode('Int', loc(91, 94)), + [], + [ + annotationNode( + nameNode('ignore', loc(57, 63)), + [], + loc(56, 63) + ), + annotationNode( + nameNode('mock', loc(67, 71)), + [ + { + kind: 'Argument', + name: nameNode('value', loc(72, 77)), + value: { + kind: 'IntValue', + value: '2', + loc: loc(79, 80), + }, + loc: loc(72, 80), + } + ], + loc(66, 81) + ), + ], + loc(56, 94) + ) + ], + loc: loc(1, 96), + } + ], + loc: loc(1, 96), + }; + expect(printJson(doc)).to.equal(printJson(expected)); + }); + + }); diff --git a/src/language/__tests__/visitor.js b/src/language/__tests__/visitor.js index f0265d7b04..660abe9553 100644 --- a/src/language/__tests__/visitor.js +++ b/src/language/__tests__/visitor.js @@ -547,7 +547,42 @@ describe('Visitor', () => { [ 'leave', 'Field', 1, undefined ], [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], [ 'leave', 'OperationDefinition', 4, undefined ], - [ 'leave', 'Document', undefined, undefined ] ]); + [ 'enter', 'TypeExtensionDefinition', 5, undefined ], + [ + 'enter', + 'ObjectTypeDefinition', + 'definition', + 'TypeExtensionDefinition', + ], + [ 'enter', 'Name', 'name', 'ObjectTypeDefinition' ], + [ 'leave', 'Name', 'name', 'ObjectTypeDefinition' ], + [ 'enter', 'FieldDefinition', 0, undefined ], + [ 'enter', 'Name', 'name', 'FieldDefinition' ], + [ 'leave', 'Name', 'name', 'FieldDefinition' ], + [ 'enter', 'NamedType', 'type', 'FieldDefinition' ], + [ 'enter', 'Name', 'name', 'NamedType' ], + [ 'leave', 'Name', 'name', 'NamedType' ], + [ 'leave', 'NamedType', 'type', 'FieldDefinition' ], + [ 'enter', 'Annotation', 0, undefined ], + [ 'enter', 'Name', 'name', 'Annotation' ], + [ 'leave', 'Name', 'name', 'Annotation' ], + [ 'enter', 'Argument', 0, undefined ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'StringValue', 'value', 'Argument' ], + [ 'leave', 'StringValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, undefined ], + [ 'leave', 'Annotation', 0, undefined ], + [ 'leave', 'FieldDefinition', 0, undefined ], + [ + 'leave', + 'ObjectTypeDefinition', + 'definition', + 'TypeExtensionDefinition', + ], + [ 'leave', 'TypeExtensionDefinition', 5, undefined ], + [ 'leave', 'Document', undefined, undefined ], + ]); }); describe('visitInParallel', () => { diff --git a/src/language/ast.js b/src/language/ast.js index 28c42a864c..77eb6f336c 100644 --- a/src/language/ast.js +++ b/src/language/ast.js @@ -44,6 +44,7 @@ export type Node = Name | ObjectValue | ObjectField | Directive + | Annotation | ListType | NonNullType | ObjectTypeDefinition @@ -227,6 +228,14 @@ export type Directive = { arguments?: ?Array; } +// Annotation + +export type Annotation = { + kind: 'Annotation'; + loc?: ?Location; + name: Name; + arguments?: ?Array; +} // Type Reference @@ -276,6 +285,7 @@ export type FieldDefinition = { name: Name; arguments: Array; type: Type; + annotations?: ?Array; } export type InputValueDefinition = { diff --git a/src/language/kinds.js b/src/language/kinds.js index 41d28e8fa5..11eb4c4e9c 100644 --- a/src/language/kinds.js +++ b/src/language/kinds.js @@ -42,6 +42,10 @@ export const OBJECT_FIELD = 'ObjectField'; export const DIRECTIVE = 'Directive'; +// Annotation + +export const ANNOTATION = 'Annotation'; + // Types export const NAMED_TYPE = 'NamedType'; diff --git a/src/language/parser.js b/src/language/parser.js index 80daaaa906..d1a8aba0e0 100644 --- a/src/language/parser.js +++ b/src/language/parser.js @@ -34,6 +34,7 @@ import type { ObjectField, Directive, + Annotation, Type, NamedType, @@ -77,6 +78,7 @@ import { OBJECT_FIELD, DIRECTIVE, + ANNOTATION, NAMED_TYPE, LIST_TYPE, @@ -589,6 +591,33 @@ function parseDirective(parser): Directive { }; } +// Implements the parsing rules in the Annotations section. + +/** + * Annotations : Annotation+ + */ +function parseAnnotations(parser): Array { + var annotations = []; + while (peek(parser, TokenKind.AT)) { + annotations.push(parseAnnotation(parser)); + } + return annotations; +} + +/** + * Annotation : @ Name Arguments? + */ +function parseAnnotation(parser): Annotation { + var start = parser.token.start; + expect(parser, TokenKind.AT); + return { + kind: ANNOTATION, + name: parseName(parser), + arguments: parseArguments(parser), + loc: loc(parser, start) + }; +} + // Implements the parsing rules in the Types section. @@ -709,10 +738,11 @@ function parseImplementsInterfaces(parser): Array { } /** - * FieldDefinition : Name ArgumentsDefinition? : Type + * FieldDefinition : Annotations? Name ArgumentsDefinition? : Type */ function parseFieldDefinition(parser): FieldDefinition { var start = parser.token.start; + var annotations = parseAnnotations(parser); var name = parseName(parser); var args = parseArgumentDefs(parser); expect(parser, TokenKind.COLON); @@ -723,6 +753,7 @@ function parseFieldDefinition(parser): FieldDefinition { arguments: args, type, loc: loc(parser, start), + annotations, }; } diff --git a/src/language/printer.js b/src/language/printer.js index eaaa3e511d..6bdd8cd530 100644 --- a/src/language/printer.js +++ b/src/language/printer.js @@ -83,6 +83,11 @@ var printDocASTReducer = { Directive: ({ name, arguments: args }) => '@' + name + wrap('(', join(args, ', '), ')'), + // Annotation + + Annotation: ({ name, arguments: args }) => + '@' + name + wrap('(', join(args, ', '), ')'), + // Type NamedType: ({ name }) => name, @@ -96,8 +101,10 @@ var printDocASTReducer = { wrap('implements ', join(interfaces, ', '), ' ') + block(fields), - FieldDefinition: ({ name, arguments: args, type }) => - name + wrap('(', join(args, ', '), ')') + ': ' + type, + FieldDefinition: ({ name, arguments: args, type, annotations }) => + wrap('', join(annotations, '\n'), '\n') + + name + wrap('(', join(args, ', '), ')') + ': ' + + type, InputValueDefinition: ({ name, type, defaultValue }) => name + ': ' + type + wrap(' = ', defaultValue), diff --git a/src/language/visitor.js b/src/language/visitor.js index 9aa47ef77a..ab58991471 100644 --- a/src/language/visitor.js +++ b/src/language/visitor.js @@ -33,13 +33,14 @@ export var QueryDocumentKeys = { ObjectField: [ 'name', 'value' ], Directive: [ 'name', 'arguments' ], + Annotation: [ 'name', 'arguments' ], NamedType: [ 'name' ], ListType: [ 'type' ], NonNullType: [ 'type' ], ObjectTypeDefinition: [ 'name', 'interfaces', 'fields' ], - FieldDefinition: [ 'name', 'arguments', 'type' ], + FieldDefinition: [ 'name', 'arguments', 'type', 'annotations' ], InputValueDefinition: [ 'name', 'type', 'defaultValue' ], InterfaceTypeDefinition: [ 'name', 'fields' ], UnionTypeDefinition: [ 'name', 'types' ], diff --git a/src/type/__tests__/introspection.js b/src/type/__tests__/introspection.js index 52ef9dae32..115a9916fb 100644 --- a/src/type/__tests__/introspection.js +++ b/src/type/__tests__/introspection.js @@ -1294,4 +1294,75 @@ describe('Introspection', () => { }); }); + it('introspection of directives on fields', async () => { + var TestType = new GraphQLObjectType({ + name: 'TestType', + fields: { + testString: { + type: GraphQLString, + annotations: { + iAmAnAnnotation: { a: '10' }, + annotationWithTwoArguments: { arg1: 'a', arg2: 'b' } + }, + }, + } + }); + + var schema = new GraphQLSchema({ query: TestType }); + var request = ` + { + __type(name: "TestType") { + name + fields { + name + annotations { + name + args { + name + value + } + } + } + } + } + `; + + return expect( + await graphql(schema, request) + ).to.deep.equal({ + data: { + __type: { + name: 'TestType', + fields: [ { + name: 'testString', + annotations: [ + { + name: 'iAmAnAnnotation', + args: [ + { + name: 'a', + value: '10', + }, + ], + }, + { + name: 'annotationWithTwoArguments', + args: [ + { + name: 'arg1', + value: 'a', + }, + { + name: 'arg2', + value: 'b', + }, + ], + }, + ] + } ] + } + } + }); + }); + }); diff --git a/src/type/definition.js b/src/type/definition.js index 6f333a65fc..381857b6b9 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -479,6 +479,22 @@ export type GraphQLFieldConfig = { resolve?: GraphQLFieldResolveFn; deprecationReason?: string; description?: ?string; + annotations?: GraphQLAnnotationsMap +} + +export type GraphQLAnnotationArgumentMap = { + // TODO: not sure if this should be any, or we + // need to restrict it to be only a string + [argName: string]: any +} + +export type GraphQLAnnotation = { + name: string, + args?: GraphQLAnnotationArgumentMap +} + +export type GraphQLAnnotationsMap = { + [directiveName: string]: GraphQLAnnotation } export type GraphQLFieldConfigArgumentMap = { @@ -502,6 +518,7 @@ export type GraphQLFieldDefinition = { args: Array; resolve?: GraphQLFieldResolveFn; deprecationReason?: ?string; + annotations?: GraphQLAnnotationsMap } export type GraphQLArgument = { diff --git a/src/type/introspection.js b/src/type/introspection.js index 3fd4c9b03c..eca0ebc4d3 100644 --- a/src/type/introspection.js +++ b/src/type/introspection.js @@ -89,6 +89,39 @@ var __Directive = new GraphQLObjectType({ }), }); +var __AnnotationArgument = new GraphQLObjectType({ + name: '__AnnotationArgument', + description: + 'Arguments provided to annotations are represented as ' + + '__AnnotationArgument', + fields: () => ({ + name: { type: new GraphQLNonNull(GraphQLString) }, + // TODO: value's type here could be any scalar I guess, + // is there any way we can encode that? If not, maybe + // we can restrict this to be a string. + value: { type: new GraphQLNonNull(GraphQLString) }, + }), +}); + +var __Annotation = new GraphQLObjectType({ + name: '__Annotation', + description: + 'An Annotation provides a way to add metadata to ' + + 'field definitions in the schema', + fields: () => ({ + name: { type: new GraphQLNonNull(GraphQLString) }, + args: { + type: + new GraphQLNonNull(new GraphQLList( + new GraphQLNonNull(__AnnotationArgument) + )), + resolve: annotation => Object.keys(annotation.args).map( + name => ({name, value: annotation.args[name]}) + ) || [] + }, + }), +}); + var __Type = new GraphQLObjectType({ name: '__Type', description: @@ -210,7 +243,13 @@ var __Field = new GraphQLObjectType({ }, deprecationReason: { type: GraphQLString, - } + }, + annotations: { + type: new GraphQLNonNull(new GraphQLList(__Annotation)), + resolve: field => Object.keys(field.annotations).map( + name => ({name, args: field.annotations[name]}) + ) || [] + }, }) }); diff --git a/src/utilities/__tests__/extendSchema.js b/src/utilities/__tests__/extendSchema.js index 6129d9aa73..18a4c65b33 100644 --- a/src/utilities/__tests__/extendSchema.js +++ b/src/utilities/__tests__/extendSchema.js @@ -534,6 +534,51 @@ type Subscription { `); }); + it('type extension\'s fields can have directives', () => { + const ast = parse(` + extend type Foo { + @iAmAnAnnotation(a: 10, b: "c") + newField: String + } + `); + const originalPrint = printSchema(testSchema); + const extendedSchema = extendSchema(testSchema, ast); + expect(extendSchema).to.not.equal(testSchema); + expect(printSchema(testSchema)).to.equal(originalPrint); + expect(printSchema(extendedSchema)).to.equal( +`type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo +} + +type Biz { + fizz: String +} + +type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + @iAmAnAnnotation(a: 10, b: "c") + newField: String +} + +type Query { + foo: Foo + someUnion: SomeUnion + someInterface(id: ID!): SomeInterface +} + +interface SomeInterface { + name: String + some: SomeInterface +} + +union SomeUnion = Foo | Biz +`); + }); + it('does not allow replacing an existing type', () => { const ast = parse(` type Bar { diff --git a/src/utilities/__tests__/schemaPrinter.js b/src/utilities/__tests__/schemaPrinter.js index 790f335cc0..c6d6847972 100644 --- a/src/utilities/__tests__/schemaPrinter.js +++ b/src/utilities/__tests__/schemaPrinter.js @@ -508,6 +508,16 @@ type Root { var Schema = new GraphQLSchema({ query: Root }); var output = '\n' + printIntrospectionSchema(Schema); var introspectionSchema = ` +type __Annotation { + name: String! + args: [__AnnotationArgument!]! +} + +type __AnnotationArgument { + name: String! + value: String! +} + type __Directive { name: String! description: String @@ -531,6 +541,7 @@ type __Field { type: __Type! isDeprecated: Boolean! deprecationReason: String + annotations: [__Annotation]! } type __InputValue { diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index 4c07182a27..4d27eefa75 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -323,6 +323,8 @@ export function extendSchema( type: extendFieldType(field.type), args: keyMap(field.args, arg => arg.name), resolve: throwClientSchemaExecutionError, + annotations: field.annotations && + keyMap(field.annotations, annotation => annotation.name), }; }); @@ -342,6 +344,7 @@ export function extendSchema( newFieldMap[fieldName] = { type: buildFieldType(field.type), args: buildInputValues(field.arguments), + annotations: buildAnnotations(field.annotations), resolve: throwClientSchemaExecutionError, }; }); @@ -453,6 +456,27 @@ export function extendSchema( ); } + function buildAnnotations(annotations: Array) { + var wrap = function (left, str, right, condition) { + return condition ? `${left}${str}${right}` : str; + }; + return keyValMap( + annotations, + annotation => annotation.name.value, + annotation => keyValMap( + annotation.arguments, + argument => argument.name.value, + argument => wrap( + '"', + argument.value.value, + '"', + argument.value.kind === 'StringValue' + ) + ) + ); + } + + function buildFieldType(typeAST: Type): GraphQLType { if (typeAST.kind === LIST_TYPE) { return new GraphQLList(buildFieldType(typeAST.type)); diff --git a/src/utilities/schemaPrinter.js b/src/utilities/schemaPrinter.js index 3b26c8ac09..8041e48a8a 100644 --- a/src/utilities/schemaPrinter.js +++ b/src/utilities/schemaPrinter.js @@ -120,7 +120,8 @@ function printFields(type) { var fieldMap = type.getFields(); var fields = Object.keys(fieldMap).map(fieldName => fieldMap[fieldName]); return fields.map( - f => ` ${f.name}${printArgs(f)}: ${f.type}` + f => `${printAnnotations(f.annotations)}` + + ` ${f.name}${printArgs(f)}: ${f.type}` ).join('\n'); } @@ -138,3 +139,20 @@ function printInputValue(arg) { } return argDecl; } + +function printAnnotations(annotations) { + if (!annotations || Object.keys(annotations).length === 0) { + return ''; + } + var printAnnotationArgs = function (args) { + if (args.length === 0) { + return ''; + } + return '(' + + Object.keys(args).map(name => `${name}: ${args[name]}`).join(', ') + + ')'; + }; + return Object.keys(annotations).map( + name => ` @${name}${printAnnotationArgs(annotations[name])}` + ).join('\n') + '\n'; +}