diff --git a/src/__tests__/starWarsIntrospection-test.js b/src/__tests__/starWarsIntrospection-test.js index 6646825e34..70241b5799 100644 --- a/src/__tests__/starWarsIntrospection-test.js +++ b/src/__tests__/starWarsIntrospection-test.js @@ -66,6 +66,12 @@ describe('Star Wars Introspection Tests', () => { { name: '__InputValue' }, + { + name: '__Annotation' + }, + { + name: '__AnnotationArgument' + }, { name: '__EnumValue' }, diff --git a/src/jsutils/isScalarValue.js b/src/jsutils/isScalarValue.js new file mode 100644 index 0000000000..331c5db99f --- /dev/null +++ b/src/jsutils/isScalarValue.js @@ -0,0 +1,20 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * Returns true if a value is a javascript scalar type: String, Number, Boolean + */ + +const JS_SCALAR_RX = /\[object String|Number|Boolean\]/; +const toString = Object.prototype.toString; + +export default function isScalarValue(value: mixed): boolean { + return JS_SCALAR_RX.test(toString.call(value)); +} diff --git a/src/language/__tests__/kitchen-sink.graphql b/src/language/__tests__/kitchen-sink.graphql index 0e04e2e42d..068db21a19 100644 --- a/src/language/__tests__/kitchen-sink.graphql +++ b/src/language/__tests__/kitchen-sink.graphql @@ -26,6 +26,7 @@ query queryName($foo: ComplexType, $site: Site = MOBILE) { } } +@@AnnotationOnOperationDefinition(value: "Foo") mutation likeStory { like(story: 123) @defer { story { @@ -35,6 +36,7 @@ mutation likeStory { } subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { + @@AnnotationOnField(default: "Qux") storyLikeSubscribe(input: $input) { story { likers { @@ -47,6 +49,7 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } } +@@AnnotationOnFragmentDefinition(value: "Bar") fragment frag on Friend { foo(size: $size, bar: $b, obj: {key: "value"}) } @@ -55,3 +58,8 @@ fragment frag on Friend { unnamed(truthy: true, falsey: false), query } + +extend type User { + @@AnnotationOnFieldDefinition(default: "Baz") + name: String +} diff --git a/src/language/__tests__/parser-test.js b/src/language/__tests__/parser-test.js index 7d7e968f87..eff61f04ca 100644 --- a/src/language/__tests__/parser-test.js +++ b/src/language/__tests__/parser-test.js @@ -27,6 +27,7 @@ describe('Parser', () => { operation: 'query', name: null, variableDefinitions: null, + annotations: [], directives: [], selectionSet: { kind: 'SelectionSet', @@ -40,6 +41,7 @@ describe('Parser', () => { loc: { start: 2, end: 7 }, value: 'field' }, arguments: [], + annotations: [], directives: [], selectionSet: null } ] } } ] }); @@ -238,6 +240,7 @@ fragment ${fragmentName} on Type { operation: 'query', name: null, variableDefinitions: null, + annotations: [], directives: [], selectionSet: { kind: Kind.SELECTION_SET, @@ -261,6 +264,7 @@ fragment ${fragmentName} on Type { loc: { start: 13, end: 14, source }, value: '4' }, loc: { start: 9, end: 14, source } } ], + annotations: [], directives: [], selectionSet: { kind: Kind.SELECTION_SET, @@ -274,6 +278,7 @@ fragment ${fragmentName} on Type { loc: { start: 22, end: 24, source }, value: 'id' }, arguments: [], + annotations: [], directives: [], selectionSet: null }, { kind: Kind.FIELD, @@ -284,6 +289,7 @@ fragment ${fragmentName} on Type { loc: { start: 30, end: 34, source }, value: 'name' }, arguments: [], + annotations: [], directives: [], selectionSet: null } ] } } ] } } ] } ); diff --git a/src/language/__tests__/printer-test.js b/src/language/__tests__/printer-test.js index 2f8a416618..21e475077c 100644 --- a/src/language/__tests__/printer-test.js +++ b/src/language/__tests__/printer-test.js @@ -106,6 +106,7 @@ describe('Printer', () => { } } +@@AnnotationOnOperationDefinition(value: "Foo") mutation likeStory { like(story: 123) @defer { story { @@ -115,6 +116,7 @@ mutation likeStory { } subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { + @@AnnotationOnField(default: "Qux") storyLikeSubscribe(input: $input) { story { likers { @@ -127,6 +129,7 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } } +@@AnnotationOnFragmentDefinition(value: "Bar") fragment frag on Friend { foo(size: $size, bar: $b, obj: {key: "value"}) } @@ -135,6 +138,11 @@ fragment frag on Friend { unnamed(truthy: true, falsey: false) query } + +extend type User { + @@AnnotationOnFieldDefinition(default: "Baz") + name: String +} `); }); diff --git a/src/language/__tests__/schema-kitchen-sink.graphql b/src/language/__tests__/schema-kitchen-sink.graphql index e623ec4052..8961b5b314 100644 --- a/src/language/__tests__/schema-kitchen-sink.graphql +++ b/src/language/__tests__/schema-kitchen-sink.graphql @@ -21,6 +21,7 @@ type Foo implements Bar { interface Bar { one: Type + @@AnnotationOnFieldDefinition(value: "Foo") four(argument: String = "string"): String } diff --git a/src/language/__tests__/schema-parser-test.js b/src/language/__tests__/schema-parser-test.js index dea11e574b..1474fe0e50 100644 --- a/src/language/__tests__/schema-parser-test.js +++ b/src/language/__tests__/schema-parser-test.js @@ -42,6 +42,15 @@ 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); } @@ -53,6 +62,18 @@ function fieldNodeWithArgs(name, type, args, loc) { arguments: args, type, loc, + annotations: [], + }; +} + +function fieldNodeWithArgsAndAnnotations(name, type, args, annotations, loc) { + return { + kind: 'FieldDefinition', + name, + arguments: args, + type, + loc, + annotations, }; } @@ -97,6 +118,7 @@ type Hello { ) ], loc: loc(1, 31), + annotations: [], } ], loc: loc(1, 31), @@ -104,6 +126,70 @@ type Hello { expect(printJson(doc)).to.equal(printJson(expected)); }); + it('Simple type with annotations', () => { + const body = ` +@@AnnotationOnTypeNoArgs +@@AnnotationOnType(a: 10, b: "foo") +type Hello { + world: String +}`; + const doc = parse(body); + const loc = createLocFn(body); + const expected = { + kind: 'Document', + definitions: [ + { + kind: 'ObjectTypeDefinition', + name: nameNode('Hello', loc(67, 72)), + interfaces: [], + fields: [ + fieldNode( + nameNode('world', loc(77, 82)), + typeNode('String', loc(84, 90)), + loc(77, 90) + ) + ], + loc: loc(62, 92), + annotations: [ + annotationNode( + nameNode('AnnotationOnTypeNoArgs', loc(3, 25)), + [], + loc(1, 25) + ), + annotationNode( + nameNode('AnnotationOnType', loc(28, 44)), + [ + { + kind: 'Argument', + name: nameNode('a', loc(45, 46)), + value: { + kind: 'IntValue', + value: '10', + loc: loc(48, 50) + }, + loc: loc(45, 50) + }, + { + kind: 'Argument', + name: nameNode('b', loc(52, 53)), + value: { + kind: 'StringValue', + value: 'foo', + loc: loc(55, 60) + }, + loc: loc(52, 60) + }, + ], + loc(26, 61) + ), + ], + } + ], + loc: loc(1, 92), + }; + expect(printJson(doc)).to.equal(printJson(expected)); + }); + it('Simple extension', () => { const body = ` extend type Hello { @@ -128,6 +214,7 @@ extend type Hello { ) ], loc: loc(8, 38), + annotations: [], }, loc: loc(1, 38), } @@ -137,6 +224,74 @@ extend type Hello { expect(printJson(doc)).to.equal(printJson(expected)); }); + it('Simple extension with annotatons', () => { + const body = ` +@@AnnotationOnTypeNoArgs +@@AnnotationOnType(a: 10, b: "foo") +extend type Hello { + world: String +}`; + const doc = parse(body); + const loc = createLocFn(body); + const expected = { + kind: 'Document', + definitions: [ + { + kind: 'TypeExtensionDefinition', + definition: { + kind: 'ObjectTypeDefinition', + name: nameNode('Hello', loc(74, 79)), + interfaces: [], + fields: [ + fieldNode( + nameNode('world', loc(84, 89)), + typeNode('String', loc(91, 97)), + loc(84, 97) + ) + ], + loc: loc(69, 99), + annotations: [ + annotationNode( + nameNode('AnnotationOnTypeNoArgs', loc(3, 25)), + [], + loc(1, 25) + ), + annotationNode( + nameNode('AnnotationOnType', loc(28, 44)), + [ + { + kind: 'Argument', + name: nameNode('a', loc(45, 46)), + value: { + kind: 'IntValue', + value: '10', + loc: loc(48, 50) + }, + loc: loc(45, 50) + }, + { + kind: 'Argument', + name: nameNode('b', loc(52, 53)), + value: { + kind: 'StringValue', + value: 'foo', + loc: loc(55, 60) + }, + loc: loc(52, 60) + }, + ], + loc(26, 61) + ), + ], + }, + loc: loc(62, 99), + } + ], + loc: loc(1, 99) + }; + expect(printJson(doc)).to.equal(printJson(expected)); + }); + it('Simple non-null type', () => { const body = ` type Hello { @@ -163,6 +318,7 @@ type Hello { ) ], loc: loc(1, 32), + annotations: [], } ], loc: loc(1, 32), @@ -184,6 +340,7 @@ type Hello { interfaces: [ typeNode('World', loc(22, 27)) ], fields: [], loc: loc(0, 31), + annotations: [], } ], loc: loc(0, 31), @@ -207,6 +364,7 @@ type Hello { ], fields: [], loc: loc(0, 33), + annotations: [], } ], loc: loc(0, 33), @@ -313,6 +471,7 @@ type Hello { ) ], loc: loc(1, 46), + annotations: [], } ], loc: loc(1, 46), @@ -354,6 +513,7 @@ type Hello { ) ], loc: loc(1, 53), + annotations: [], } ], loc: loc(1, 53), @@ -395,6 +555,7 @@ type Hello { ) ], loc: loc(1, 49), + annotations: [], } ], loc: loc(1, 49), @@ -438,6 +599,7 @@ type Hello { ) ], loc: loc(1, 61), + annotations: [], } ], loc: loc(1, 61), @@ -541,4 +703,86 @@ input Hello { expect(() => parse(body)).to.throw('Error'); }); + it('Simple fields with annotations', () => { + const body = ` +type Hello { + @@mock(value: "hello") + world: String + @@ignore + @@mock(value: 2) + hello: Int +}`; + const doc = parse(body); + const loc = createLocFn(body); + const expected = { + kind: 'Document', + definitions: [ + { + kind: 'ObjectTypeDefinition', + name: nameNode('Hello', loc(6, 11)), + interfaces: [], + fields: [ + fieldNodeWithArgsAndAnnotations( + nameNode('world', loc(41, 46)), + typeNode('String', loc(48, 54)), + [], + [ + annotationNode( + nameNode('mock', loc(18, 22)), + [ + { + kind: 'Argument', + name: nameNode('value', loc(23, 28)), + value: { + kind: 'StringValue', + value: 'hello', + loc: loc(30, 37), + }, + loc: loc(23, 37), + } + ], + loc(16, 38) + ), + ], + loc(16, 54) + ), + fieldNodeWithArgsAndAnnotations( + nameNode('hello', loc(87, 92)), + typeNode('Int', loc(94, 97)), + [], + [ + annotationNode( + nameNode('ignore', loc(59, 65)), + [], + loc(57, 65) + ), + annotationNode( + nameNode('mock', loc(70, 74)), + [ + { + kind: 'Argument', + name: nameNode('value', loc(75, 80)), + value: { + kind: 'IntValue', + value: '2', + loc: loc(82, 83), + }, + loc: loc(75, 83), + } + ], + loc(68, 84) + ), + ], + loc(57, 97) + ) + ], + loc: loc(1, 99), + annotations: [], + } + ], + loc: loc(1, 99), + }; + expect(printJson(doc)).to.equal(printJson(expected)); + }); + }); diff --git a/src/language/__tests__/schema-printer-test.js b/src/language/__tests__/schema-printer-test.js index 80b417071c..09b82aebe0 100644 --- a/src/language/__tests__/schema-printer-test.js +++ b/src/language/__tests__/schema-printer-test.js @@ -67,6 +67,7 @@ type Foo implements Bar { interface Bar { one: Type + @@AnnotationOnFieldDefinition(value: "Foo") four(argument: String = "string"): String } diff --git a/src/language/__tests__/visitor-test.js b/src/language/__tests__/visitor-test.js index 3c3b6ca0ad..7d5b2c94d5 100644 --- a/src/language/__tests__/visitor-test.js +++ b/src/language/__tests__/visitor-test.js @@ -468,6 +468,16 @@ describe('Visitor', () => { [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], [ 'leave', 'OperationDefinition', 0, undefined ], [ 'enter', 'OperationDefinition', 1, undefined ], + [ '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 ], [ 'enter', 'Name', 'name', 'OperationDefinition' ], [ 'leave', 'Name', 'name', 'OperationDefinition' ], [ 'enter', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], @@ -514,6 +524,16 @@ describe('Visitor', () => { [ 'leave', 'VariableDefinition', 0, undefined ], [ 'enter', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], [ 'enter', 'Field', 0, undefined ], + [ '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 ], [ 'enter', 'Name', 'name', 'Field' ], [ 'leave', 'Name', 'name', 'Field' ], [ 'enter', 'Argument', 0, undefined ], @@ -556,6 +576,16 @@ describe('Visitor', () => { [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], [ 'leave', 'OperationDefinition', 2, undefined ], [ 'enter', 'FragmentDefinition', 3, undefined ], + [ '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 ], [ 'enter', 'Name', 'name', 'FragmentDefinition' ], [ 'leave', 'Name', 'name', 'FragmentDefinition' ], [ 'enter', 'NamedType', 'typeCondition', 'FragmentDefinition' ], @@ -621,6 +651,36 @@ describe('Visitor', () => { [ 'leave', 'Field', 1, undefined ], [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], [ 'leave', 'OperationDefinition', 4, 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 ] ]); }); diff --git a/src/language/ast.js b/src/language/ast.js index feda211f40..792cd5a4ba 100644 --- a/src/language/ast.js +++ b/src/language/ast.js @@ -44,6 +44,7 @@ export type Node = Name | ObjectValue | ObjectField | Directive + | Annotation | NamedType | ListType | NonNullType @@ -89,6 +90,7 @@ export type OperationDefinition = { variableDefinitions?: ?Array; directives?: ?Array; selectionSet: SelectionSet; + annotations?: ?Array; } // Note: subscription is an experimental non-spec addition. @@ -160,6 +162,7 @@ export type FragmentDefinition = { typeCondition: NamedType; directives?: ?Array; selectionSet: SelectionSet; + annotations?: ?Array; } @@ -234,6 +237,16 @@ export type Directive = { } +// Annotations + +export type Annotation = { + kind: 'Annotation'; + loc?: ?Location; + name: Name; + arguments?: ?Array; +} + + // Type Reference export type Type = NamedType @@ -305,6 +318,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 372e2a51d7..02ac24c3de 100644 --- a/src/language/kinds.js +++ b/src/language/kinds.js @@ -42,6 +42,10 @@ export const OBJECT_FIELD = 'ObjectField'; export const DIRECTIVE = 'Directive'; +// Annotations + +export const ANNOTATION = 'Annotation'; + // Types export const NAMED_TYPE = 'NamedType'; diff --git a/src/language/lexer.js b/src/language/lexer.js index c3d783435e..bceb80bb42 100644 --- a/src/language/lexer.js +++ b/src/language/lexer.js @@ -60,15 +60,16 @@ export const TokenKind = { COLON: 7, EQUALS: 8, AT: 9, - BRACKET_L: 10, - BRACKET_R: 11, - BRACE_L: 12, - PIPE: 13, - BRACE_R: 14, - NAME: 15, - INT: 16, - FLOAT: 17, - STRING: 18, + ATAT: 10, + BRACKET_L: 11, + BRACKET_R: 12, + BRACE_L: 13, + PIPE: 14, + BRACE_R: 15, + NAME: 16, + INT: 17, + FLOAT: 18, + STRING: 19, }; /** @@ -97,6 +98,7 @@ tokenDescription[TokenKind.SPREAD] = '...'; tokenDescription[TokenKind.COLON] = ':'; tokenDescription[TokenKind.EQUALS] = '='; tokenDescription[TokenKind.AT] = '@'; +tokenDescription[TokenKind.ATAT] = '@@'; tokenDescription[TokenKind.BRACKET_L] = '['; tokenDescription[TokenKind.BRACKET_R] = ']'; tokenDescription[TokenKind.BRACE_L] = '{'; @@ -181,8 +183,12 @@ function readToken(source: Source, fromPosition: number): Token { case 58: return makeToken(TokenKind.COLON, position, position + 1); // = case 61: return makeToken(TokenKind.EQUALS, position, position + 1); - // @ - case 64: return makeToken(TokenKind.AT, position, position + 1); + // @@ or @ + case 64: + if (charCodeAt.call(body, position + 1) === 64) { + return makeToken(TokenKind.ATAT, position, position + 2); + } + return makeToken(TokenKind.AT, position, position + 1); // [ case 91: return makeToken(TokenKind.BRACKET_L, position, position + 1); // ] diff --git a/src/language/parser.js b/src/language/parser.js index 95820afdc7..7778ebac76 100644 --- a/src/language/parser.js +++ b/src/language/parser.js @@ -36,6 +36,7 @@ import type { ObjectField, Directive, + Annotation, Type, NamedType, @@ -87,6 +88,7 @@ import { OBJECT_FIELD, DIRECTIVE, + ANNOTATION, NAMED_TYPE, LIST_TYPE, @@ -193,41 +195,54 @@ function parseDocument(parser: Parser): Document { /** * Definition : - * - OperationDefinition - * - FragmentDefinition + * - Annotations? OperationDefinition + * - Annotations? FragmentDefinition * - TypeSystemDefinition */ function parseDefinition(parser: Parser): Definition { + // could be preceeded by annotations (save token in case it's unexpected) + let token; + let annotations = []; + if (peek(parser, TokenKind.ATAT)) { + token = parser.token; + annotations = parseAnnotations(parser); + } + // shortform query if (peek(parser, TokenKind.BRACE_L)) { - return parseOperationDefinition(parser); + return parseOperationDefinition(parser, annotations); } if (peek(parser, TokenKind.NAME)) { + // OperationDefinition and FragmentDefinition kinds can have annotations switch (parser.token.value) { case 'query': case 'mutation': // Note: subscription is an experimental non-spec addition. - case 'subscription': return parseOperationDefinition(parser); - - case 'fragment': return parseFragmentDefinition(parser); + case 'subscription': return parseOperationDefinition(parser, annotations); + case 'fragment': return parseFragmentDefinition(parser, annotations); - // Note: the Type System IDL is an experimental non-spec addition. - case 'schema': - case 'scalar': case 'type': - case 'interface': - case 'union': - case 'enum': - case 'input': - case 'extend': - case 'directive': return parseTypeSystemDefinition(parser); + case 'extend': return parseTypeSystemDefinition(parser, annotations); + } + // annotations are unexpected before TypeSystemDefinition + // except type and extend + if (!annotations.length) { + switch (parser.token.value) { + // Note: the Type System IDL is an experimental non-spec addition. + case 'schema': + case 'scalar': + case 'interface': + case 'union': + case 'enum': + case 'input': + case 'directive': return parseTypeSystemDefinition(parser); + } } } - throw unexpected(parser); + throw unexpected(parser, token); } - // Implements the parsing rules in the Operations section. /** @@ -235,7 +250,8 @@ function parseDefinition(parser: Parser): Definition { * - SelectionSet * - OperationType Name? VariableDefinitions? Directives? SelectionSet */ -function parseOperationDefinition(parser: Parser): OperationDefinition { +function parseOperationDefinition(parser: Parser, annotations): + OperationDefinition { const start = parser.token.start; if (peek(parser, TokenKind.BRACE_L)) { return { @@ -245,7 +261,8 @@ function parseOperationDefinition(parser: Parser): OperationDefinition { variableDefinitions: null, directives: [], selectionSet: parseSelectionSet(parser), - loc: loc(parser, start) + loc: loc(parser, start), + annotations, }; } const operation = parseOperationType(parser); @@ -260,7 +277,8 @@ function parseOperationDefinition(parser: Parser): OperationDefinition { variableDefinitions: parseVariableDefinitions(parser), directives: parseDirectives(parser), selectionSet: parseSelectionSet(parser), - loc: loc(parser, start) + loc: loc(parser, start), + annotations, }; } @@ -347,13 +365,14 @@ function parseSelection(parser: Parser): Selection { } /** - * Field : Alias? Name Arguments? Directives? SelectionSet? + * Field : Annotations? Alias? Name Arguments? Directives? SelectionSet? * * Alias : Name : */ function parseField(parser: Parser): Field { const start = parser.token.start; + const annotations = parseAnnotations(parser); const nameOrAlias = parseName(parser); let alias; let name; @@ -371,6 +390,7 @@ function parseField(parser: Parser): Field { name, arguments: parseArguments(parser), directives: parseDirectives(parser), + annotations, selectionSet: peek(parser, TokenKind.BRACE_L) ? parseSelectionSet(parser) : null, loc: loc(parser, start) @@ -436,11 +456,13 @@ function parseFragment(parser: Parser): FragmentSpread | InlineFragment { /** * FragmentDefinition : - * - fragment FragmentName on TypeCondition Directives? SelectionSet + * - Annotations? + * fragment FragmentName on TypeCondition Directives? SelectionSet * * TypeCondition : NamedType */ -function parseFragmentDefinition(parser: Parser): FragmentDefinition { +function parseFragmentDefinition(parser: Parser, annotations): + FragmentDefinition { const start = parser.token.start; expectKeyword(parser, 'fragment'); return { @@ -449,7 +471,8 @@ function parseFragmentDefinition(parser: Parser): FragmentDefinition { typeCondition: (expectKeyword(parser, 'on'), parseNamedType(parser)), directives: parseDirectives(parser), selectionSet: parseSelectionSet(parser), - loc: loc(parser, start) + loc: loc(parser, start), + annotations, }; } @@ -620,6 +643,34 @@ function parseDirective(parser: Parser): Directive { } +// Implements the parsing rules in the Annotations section. + +/** + * Annotations : Annotation+ + */ +function parseAnnotations(parser: Parser): Array { + const annotations = []; + while (peek(parser, TokenKind.ATAT)) { + annotations.push(parseAnnotation(parser)); + } + return annotations; +} + +/** + * Annotation : @@ Name Arguments? + */ +function parseAnnotation(parser: Parser): Annotation { + const start = parser.token.start; + expect(parser, TokenKind.ATAT); + return { + kind: ANNOTATION, + name: parseName(parser), + arguments: parseArguments(parser), + loc: loc(parser, start) + }; +} + + // Implements the parsing rules in the Types section. /** @@ -681,17 +732,20 @@ export function parseNamedType(parser: Parser): NamedType { * - EnumTypeDefinition * - InputObjectTypeDefinition */ -function parseTypeSystemDefinition(parser: Parser): TypeSystemDefinition { +function parseTypeSystemDefinition( + parser: Parser, + annotations +): TypeSystemDefinition { if (peek(parser, TokenKind.NAME)) { switch (parser.token.value) { case 'schema': return parseSchemaDefinition(parser); case 'scalar': return parseScalarTypeDefinition(parser); - case 'type': return parseObjectTypeDefinition(parser); + case 'type': return parseObjectTypeDefinition(parser, annotations); case 'interface': return parseInterfaceTypeDefinition(parser); case 'union': return parseUnionTypeDefinition(parser); case 'enum': return parseEnumTypeDefinition(parser); case 'input': return parseInputObjectTypeDefinition(parser); - case 'extend': return parseTypeExtensionDefinition(parser); + case 'extend': return parseTypeExtensionDefinition(parser, annotations); case 'directive': return parseDirectiveDefinition(parser); } } @@ -748,9 +802,13 @@ function parseScalarTypeDefinition(parser: Parser): ScalarTypeDefinition { } /** - * ObjectTypeDefinition : type Name ImplementsInterfaces? { FieldDefinition+ } + * ObjectTypeDefinition : + * Annotations? type Name ImplementsInterfaces? { FieldDefinition+ } */ -function parseObjectTypeDefinition(parser: Parser): ObjectTypeDefinition { +function parseObjectTypeDefinition( + parser: Parser, + annotations +): ObjectTypeDefinition { const start = parser.token.start; expectKeyword(parser, 'type'); const name = parseName(parser); @@ -767,6 +825,7 @@ function parseObjectTypeDefinition(parser: Parser): ObjectTypeDefinition { interfaces, fields, loc: loc(parser, start), + annotations, }; } @@ -785,10 +844,11 @@ function parseImplementsInterfaces(parser: Parser): Array { } /** - * FieldDefinition : Name ArgumentsDefinition? : Type + * FieldDefinition : Annotations? Name ArgumentsDefinition? : Type */ function parseFieldDefinition(parser: Parser): FieldDefinition { const start = parser.token.start; + const annotations = parseAnnotations(parser); const name = parseName(parser); const args = parseArgumentDefs(parser); expect(parser, TokenKind.COLON); @@ -799,6 +859,7 @@ function parseFieldDefinition(parser: Parser): FieldDefinition { arguments: args, type, loc: loc(parser, start), + annotations, }; } @@ -944,12 +1005,16 @@ function parseInputObjectTypeDefinition( } /** - * TypeExtensionDefinition : extend ObjectTypeDefinition + * TypeExtensionDefinition : + * Annotations? extend ObjectTypeDefinition */ -function parseTypeExtensionDefinition(parser: Parser): TypeExtensionDefinition { +function parseTypeExtensionDefinition( + parser: Parser, + annotations +): TypeExtensionDefinition { const start = parser.token.start; expectKeyword(parser, 'extend'); - const definition = parseObjectTypeDefinition(parser); + const definition = parseObjectTypeDefinition(parser, annotations); return { kind: TYPE_EXTENSION_DEFINITION, definition, diff --git a/src/language/printer.js b/src/language/printer.js index 143f4fdaeb..4876785af0 100644 --- a/src/language/printer.js +++ b/src/language/printer.js @@ -28,13 +28,16 @@ const printDocASTReducer = { OperationDefinition(node) { const op = node.operation; const name = node.name; + const annotations = wrap('', join(node.annotations, '\n'), '\n'); const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); const directives = join(node.directives, ' '); const selectionSet = node.selectionSet; // Anonymous queries with no directives or variable definitions can use // the query short form. + return !name && !directives && !varDefs && op === 'query' ? - selectionSet : + annotations + selectionSet : + annotations + join([ op, join([ name, varDefs ]), directives, selectionSet ], ' '); }, @@ -43,7 +46,9 @@ const printDocASTReducer = { SelectionSet: ({ selections }) => block(selections), - Field: ({ alias, name, arguments: args, directives, selectionSet }) => + Field: ({ alias, name, arguments: args, directives, annotations, + selectionSet }) => + wrap('', join(annotations, '\n'), '\n') + join([ wrap('', alias, ': ') + name + wrap('(', join(args, ', '), ')'), join(directives, ' '), @@ -65,10 +70,11 @@ const printDocASTReducer = { selectionSet ], ' '), - FragmentDefinition: ({ name, typeCondition, directives, selectionSet }) => - `fragment ${name} on ${typeCondition} ` + - wrap('', join(directives, ' '), ' ') + - selectionSet, + FragmentDefinition: node => + wrap('', join(node.annotations, '\n'), '\n') + + `fragment ${node.name} on ${node.typeCondition} ` + + wrap('', join(node.directives, ' '), ' ') + + node.selectionSet, // Value @@ -86,6 +92,11 @@ const printDocASTReducer = { Directive: ({ name, arguments: args }) => '@' + name + wrap('(', join(args, ', '), ')'), + // Annotation + + Annotation: ({ name, arguments: args }) => + '@@' + name + wrap('(', join(args, ', '), ')'), + // Type NamedType: ({ name }) => name, @@ -108,7 +119,8 @@ const printDocASTReducer = { wrap('implements ', join(interfaces, ', '), ' ') + block(fields), - FieldDefinition: ({ name, arguments: args, type }) => + FieldDefinition: ({ name, arguments: args, type, annotations }) => + wrap('', join(annotations, '\n'), '\n') + name + wrap('(', join(args, ', '), ')') + ': ' + type, InputValueDefinition: ({ name, type, defaultValue }) => diff --git a/src/language/visitor.js b/src/language/visitor.js index 34ae887b7e..5766aefad3 100644 --- a/src/language/visitor.js +++ b/src/language/visitor.js @@ -12,16 +12,19 @@ export const QueryDocumentKeys = { Document: [ 'definitions' ], OperationDefinition: - [ 'name', 'variableDefinitions', 'directives', 'selectionSet' ], + [ 'annotations', 'name', 'variableDefinitions', 'directives', + 'selectionSet' ], VariableDefinition: [ 'variable', 'type', 'defaultValue' ], Variable: [ 'name' ], SelectionSet: [ 'selections' ], - Field: [ 'alias', 'name', 'arguments', 'directives', 'selectionSet' ], + Field: [ 'annotations', 'alias', 'name', 'arguments', 'directives', + 'selectionSet' ], Argument: [ 'name', 'value' ], FragmentSpread: [ 'name', 'directives' ], InlineFragment: [ 'typeCondition', 'directives', 'selectionSet' ], - FragmentDefinition: [ 'name', 'typeCondition', 'directives', 'selectionSet' ], + FragmentDefinition: [ 'annotations', 'name', 'typeCondition', 'directives', + 'selectionSet' ], IntValue: [], FloatValue: [], @@ -33,6 +36,7 @@ export const QueryDocumentKeys = { ObjectField: [ 'name', 'value' ], Directive: [ 'name', 'arguments' ], + Annotation: [ 'name', 'arguments' ], NamedType: [ 'name' ], ListType: [ 'type' ], @@ -43,7 +47,7 @@ export const QueryDocumentKeys = { ScalarTypeDefinition: [ 'name' ], 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-test.js b/src/type/__tests__/introspection-test.js index 64aa6f7c4b..bd5fcad660 100644 --- a/src/type/__tests__/introspection-test.js +++ b/src/type/__tests__/introspection-test.js @@ -1350,4 +1350,150 @@ describe('Introspection', () => { }); }); + it('introspection of annotations on type', async () => { + const TestType = new GraphQLObjectType({ + name: 'TestType', + annotations: { + anAnnotation: { a: 10 }, + annotationWithTwoArguments: { arg1: 'a', arg2: 'b' } + }, + fields: { + testString: { type: GraphQLString }, + } + }); + + const schema = new GraphQLSchema({ query: TestType }); + const request = ` + { + __type(name: "TestType") { + name + annotations { + name + args { + name + value + } + } + fields { + name + } + } + } + `; + + return expect( + await graphql(schema, request) + ).to.deep.equal({ + data: { + __type: { + name: 'TestType', + annotations: [ + { + name: 'anAnnotation', + args: [ + { + name: 'a', + value: '10', + }, + ], + }, + { + name: 'annotationWithTwoArguments', + args: [ + { + name: 'arg1', + value: 'a', + }, + { + name: 'arg2', + value: 'b', + }, + ], + }, + ], + fields: [ { + name: 'testString', + } ] + } + } + }); + }); + + it('introspection of annotations on fields', async () => { + const TestType = new GraphQLObjectType({ + name: 'TestType', + fields: { + testString: { + type: GraphQLString, + annotations: { + anAnnotation: { a: '10' }, + annotationWithTwoArguments: { arg1: 'a', arg2: 'b' } + }, + }, + testStringNoAnnotations: { + type: GraphQLString, + }, + } + }); + + const schema = new GraphQLSchema({ query: TestType }); + const 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: 'anAnnotation', + args: [ + { + name: 'a', + value: '10', + }, + ], + }, + { + name: 'annotationWithTwoArguments', + args: [ + { + name: 'arg1', + value: 'a', + }, + { + name: 'arg2', + value: 'b', + }, + ], + }, + ] + }, { + name: 'testStringNoAnnotations', + annotations: [], + } ] + } + } + }); + }); + }); diff --git a/src/type/__tests__/validation-test.js b/src/type/__tests__/validation-test.js index c73bed3999..e78f7e4ad8 100644 --- a/src/type/__tests__/validation-test.js +++ b/src/type/__tests__/validation-test.js @@ -293,6 +293,177 @@ describe('Type System: A Schema must contain uniquely named types', () => { }); +describe('Type System: Objects can have annotations', () => { + + it('accepts an Object type with annotations', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { key: 'value' }, + }, + fields: { + f: { type: GraphQLString } + } + })) + ).not.to.throw(); + }); + + it('accepts an Object type with annotations with empty annotation args', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { key: 'value' }, + someAnnotationNullArgs: null, + someAnnotationUndefinedArgs: undefined, + }, + fields: { + f: { type: GraphQLString } + } + })) + ).not.to.throw(); + }); + + it('accepts an Object type with annotations with array annotation arg of scalar elements', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { key: [ 'foo', 2, true ] }, + }, + fields: { + f: { type: GraphQLString } + } + })) + ).not.to.throw(); + }); + + it('rejects an Object type with annotations with badly named annotation', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + 'bad-name-with-dashes': null, + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.' + ); + }); + + it('rejects an Object type with annotations with empty annotation map', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: {}, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations map must be an object with keys as annotation ' + + 'names.' + ); + }); + + it('rejects an Object type with annotations with annotation with no args', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: {} + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation must be an object with ' + + 'annotations names as keys.' + ); + }); + + it('rejects an Object type with annotations with annotation with no arg value', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { + key: undefined + } + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects an Object type with annotations with non-scalar arg values', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { + key: new Date() + } + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects an Object type with annotations with annotation with empty array', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { + key: [] + } + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects an Object type with annotations with annotation with non-scalar array elements', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + annotations: { + someAnnotation: { key: [ 'foo', 2, true, /regex/ ] }, + }, + fields: { + f: { type: GraphQLString } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + +}); + + describe('Type System: Objects must have fields', () => { it('accepts an Object type with fields object', () => { @@ -478,6 +649,197 @@ describe('Type System: Fields args must be objects', () => { }); +describe('Type System: Fields can have annotations', () => { + + it('accepts a Field with annotations', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { key: 'value' }, + }, + } + } + })) + ).not.to.throw(); + }); + + it('accepts a Field with annotations with empty annotation args', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { key: 'value' }, + someAnnotationNullArgs: null, + someAnnotationUndefinedArgs: undefined, + }, + } + } + })) + ).not.to.throw(); + }); + + it('accepts a Field with annotations with array annotation arg of scalar elements', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { key: [ 'foo', 2, true ] }, + }, + } + } + })) + ).not.to.throw(); + }); + + it('rejects a Field with annotations with badly named annotation', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + 'bad-name-with-dashes': null, + }, + } + } + })) + ).to.throw( + 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.' + ); + }); + + it('rejects a Field with annotations with empty annotation map', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: {}, + } + } + })) + ).to.throw( + 'SomeObject.annotations map must be an object with keys as annotation ' + + 'names.' + ); + }); + + it('rejects a Field with annotations with annotation with no args', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: {} + }, + } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation must be an object with ' + + 'annotations names as keys.' + ); + }); + + it('rejects a Field with annotations with annotation with no arg value', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { + key: undefined + } + }, + } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects a Field with annotations with non-scalar arg values', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { + key: new Date() + } + }, + } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects a Field with annotations with annotation with empty array', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { + key: [] + } + }, + } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + + it('rejects a Field with annotations with annotation with non-scalar array elements', () => { + expect( + () => schemaWithFieldType(new GraphQLObjectType({ + name: 'SomeObject', + fields: { + f: { + type: GraphQLString, + annotations: { + someAnnotation: { key: [ 'foo', 2, true, /regex/ ] }, + }, + } + } + })) + ).to.throw( + 'SomeObject.annotations.someAnnotation.key arg values must be a scalar ' + + 'type or a non-empty array of scalar elements.' + ); + }); + +}); + + describe('Type System: Object interfaces must be array', () => { it('accepts an Object type with array interfaces', () => { diff --git a/src/type/definition.js b/src/type/definition.js index 70f0bf2a6e..9f093c73f5 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -10,8 +10,12 @@ import invariant from '../jsutils/invariant'; import isNullish from '../jsutils/isNullish'; +import isScalarValue from '../jsutils/isScalarValue'; import { ENUM } from '../language/kinds'; import { assertValidName } from '../utilities/assertValidName'; +// import { +// assertValidAnnotationMap +// } from '../utilities/assertValidAnnotationMap'; import type { OperationDefinition, Field, @@ -301,6 +305,7 @@ export type GraphQLScalarTypeConfig = { export class GraphQLObjectType { name: string; description: ?string; + annotations: ?GraphQLAnnotationsMap; isTypeOf: ?GraphQLIsTypeOfFn; _typeConfig: GraphQLObjectTypeConfig; @@ -312,6 +317,12 @@ export class GraphQLObjectType { assertValidName(config.name); this.name = config.name; this.description = config.description; + // TODO: not sure how to fix flow here, for now conditionally validate + // even though there is a check for nullish in assertValidAnnotationMap + if (config.annotations) { + assertValidAnnotationMap(this, config.annotations); + } + this.annotations = config.annotations; if (config.isTypeOf) { invariant( typeof config.isTypeOf === 'function', @@ -339,6 +350,71 @@ export class GraphQLObjectType { } } +function assertValidAnnotationMap( + type: GraphQLNamedType, + annotationsMap: GraphQLAnnotationsMap +): void { + if (isNullish(annotationsMap)) { + return; + } + invariant( + isPlainObj(annotationsMap), + `${type}.annotations field must be an object type.` + ); + + const annotationKeys = Object.keys(annotationsMap); + invariant( + annotationKeys.length > 0, + `${type}.annotations map must be an object with keys as annotation names.` + ); + + annotationKeys.forEach(annotationKey => { + assertValidName(annotationKey); + const annotationValue = annotationsMap[annotationKey]; + // an annotation can be null or undefined (i.e. no arguments) + if (typeof annotationValue !== 'undefined' && annotationValue !== null) { + // if provided must be a plain object with key value pairs as args + invariant( + isPlainObj(annotationValue), + `${type}.annotations.${annotationKey} must be an object.` + ); + + const argNames = Object.keys(annotationValue); + invariant( + argNames.length > 0, + `${type}.annotations.${annotationKey} must be an object with ` + + 'annotations names as keys.' + ); + + argNames.forEach(name => { + // TODO: ideally restrict to scalars - not sure how to do that for now + // restricting Javascript scalar values + const value = annotationValue[name]; + if (Array.isArray(value)) { + invariant( + value.length > 0, + `${type}.annotations.${annotationKey}.${name} arg values must ` + + 'be a scalar type or a non-empty array of scalar elements.' + ); + value.forEach(element => { + invariant( + isScalarValue(element), + `${type}.annotations.${annotationKey}.${name} arg values must ` + + 'be a scalar type or a non-empty array of scalar elements.' + ); + }); + } else { + invariant( + isScalarValue(value), + `${type}.annotations.${annotationKey}.${name} arg values must be ` + + 'a scalar type or a non-empty array of scalar elements.' + ); + } + }); + } + }); +} + function resolveMaybeThunk(thingOrThunk: T | () => T): T { return typeof thingOrThunk === 'function' ? thingOrThunk() : thingOrThunk; } @@ -399,6 +475,7 @@ function defineFieldMap( ...fieldMap[fieldName], name: fieldName }; + assertValidAnnotationMap(type, field.annotations); invariant( !field.hasOwnProperty('isDeprecated'), `${type}.${fieldName} should provide "deprecationReason" instead ` + @@ -447,7 +524,8 @@ export type GraphQLObjectTypeConfig = { interfaces?: GraphQLInterfacesThunk | Array; fields: GraphQLFieldConfigMapThunk | GraphQLFieldConfigMap; isTypeOf?: GraphQLIsTypeOfFn; - description?: ?string + description?: ?string; + annotations?: GraphQLAnnotationsMap; } type GraphQLInterfacesThunk = () => Array; @@ -485,9 +563,24 @@ export type GraphQLResolveInfo = { variableValues: { [variableName: string]: mixed }, } +export type GraphQLAnnotationArgumentMap = { + // TODO: ideally restrict to scalars - not sure how to do that + [argName: string]: any +} + +export type GraphQLAnnotation = { + name: string, + args?: GraphQLAnnotationArgumentMap +} + +export type GraphQLAnnotationsMap = { + [annotationName: string]: GraphQLAnnotation +} + export type GraphQLFieldConfig = { type: GraphQLOutputType; args?: GraphQLFieldConfigArgumentMap; + annotations?: GraphQLAnnotationsMap; resolve?: GraphQLFieldResolveFn; deprecationReason?: ?string; description?: ?string; @@ -510,6 +603,7 @@ export type GraphQLFieldConfigMap = { export type GraphQLFieldDefinition = { name: string; description: ?string; + annotations?: GraphQLAnnotationsMap; type: GraphQLOutputType; args: Array; resolve?: GraphQLFieldResolveFn; diff --git a/src/type/introspection.js b/src/type/introspection.js index d760dac70f..f6a3bc0a1d 100644 --- a/src/type/introspection.js +++ b/src/type/introspection.js @@ -152,6 +152,40 @@ export const __DirectiveLocation = new GraphQLEnumType({ } }); + +export const __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) }, + }), +}); + +export const __Annotation = new GraphQLObjectType({ + name: '__Annotation', + description: + 'An Annotation provides a way to add metadata to operation definitions, ' + + 'fragment definitions and field definitions in the schema.', + fields: () => ({ + name: { type: new GraphQLNonNull(GraphQLString) }, + args: { + type: new GraphQLNonNull(new GraphQLList( + new GraphQLNonNull(__AnnotationArgument) + )), + resolve: annotation => annotation.args && + Object.keys(annotation.args).map( + name => ({name, value: annotation.args[name]}) + ) || [] + }, + }), +}); + export const __Type = new GraphQLObjectType({ name: '__Type', description: @@ -249,7 +283,14 @@ export const __Type = new GraphQLObjectType({ } } }, - ofType: { type: __Type } + ofType: { type: __Type }, + annotations: { + type: new GraphQLNonNull(new GraphQLList(__Annotation)), + resolve: field => field.annotations && + Object.keys(field.annotations).map( + name => ({name, args: field.annotations[name]}) + ) || [] + }, }) }); @@ -273,6 +314,13 @@ export const __Field = new GraphQLObjectType({ }, deprecationReason: { type: GraphQLString, + }, + annotations: { + type: new GraphQLNonNull(new GraphQLList(__Annotation)), + resolve: field => field.annotations && + Object.keys(field.annotations).map( + name => ({name, args: field.annotations[name]}) + ) || [] } }) }); diff --git a/src/utilities/__tests__/extendSchema-test.js b/src/utilities/__tests__/extendSchema-test.js index 385f410073..575cecaf72 100644 --- a/src/utilities/__tests__/extendSchema-test.js +++ b/src/utilities/__tests__/extendSchema-test.js @@ -514,6 +514,113 @@ interface SomeInterface { some: SomeInterface } +union SomeUnion = Foo | Biz +`); + }); + + it('extends objects by including new types with annotations', () => { + const ast = parse(` + @@AnnotationOnExtendType(a: 1, b: "foo") + extend type Foo { + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newEnum: NewEnum + newTree: [Foo]! + } + + @@AnnotationOnObjectType(a: 1, b: "foo") + type NewObject implements NewInterface { + baz: String + } + + type NewOtherObject { + fizz: Int + } + + interface NewInterface { + baz: String + } + + union NewUnion = NewObject | NewOtherObject + + scalar NewScalar + + enum NewEnum { + OPTION_A + OPTION_B + } + `); + const originalPrint = printSchema(testSchema); + const extendedSchema = extendSchema(testSchema, ast); + expect(extendedSchema).to.not.equal(testSchema); + expect(printSchema(testSchema)).to.equal(originalPrint); + expect(printSchema(extendedSchema)).to.equal( +`schema { + query: Query +} + +type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo +} + +type Biz { + fizz: String +} + +type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newEnum: NewEnum + newTree: [Foo]! +} + +enum NewEnum { + OPTION_A + OPTION_B +} + +interface NewInterface { + baz: String +} + +type NewObject implements NewInterface { + baz: String +} + +type NewOtherObject { + fizz: Int +} + +scalar NewScalar + +union NewUnion = NewObject | NewOtherObject + +type Query { + foo: Foo + someUnion: SomeUnion + someEnum: SomeEnum + someInterface(id: ID!): SomeInterface +} + +enum SomeEnum { + ONE + TWO +} + +interface SomeInterface { + name: String + some: SomeInterface +} + union SomeUnion = Foo | Biz `); }); @@ -718,6 +825,74 @@ type Subscription { `); }); + it('type extension\'s fields can have annotations', () => { + const ast = parse(` + extend type Foo { + @@AnnotationOnFieldDefinition(a: 10, b: "c") + newField: String + } + + type Unused { + @@AnnotationOnUnusedType(z: 42) + @@AnnotationWithNoArgsOnUnusedType + someField: 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( +`schema { + query: Query +} + +type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo +} + +type Biz { + fizz: String +} + +type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + @@AnnotationOnFieldDefinition(a: 10, b: "c") + newField: String +} + +type Query { + foo: Foo + someUnion: SomeUnion + someEnum: SomeEnum + someInterface(id: ID!): SomeInterface +} + +enum SomeEnum { + ONE + TWO +} + +interface SomeInterface { + name: String + some: SomeInterface +} + +union SomeUnion = Foo | Biz + +type Unused { + @@AnnotationOnUnusedType(z: 42) + @@AnnotationWithNoArgsOnUnusedType + someField: String +} +`); + }); + + it('does not allow replacing an existing type', () => { const ast = parse(` type Bar { diff --git a/src/utilities/__tests__/schemaPrinter-test.js b/src/utilities/__tests__/schemaPrinter-test.js index f9cb17c849..1497d73265 100644 --- a/src/utilities/__tests__/schemaPrinter-test.js +++ b/src/utilities/__tests__/schemaPrinter-test.js @@ -143,6 +143,28 @@ type Root { ); }); + it('Prints Field With Annotations', () => { + const output = printSingleFieldSchema({ + type: nonNull(listOf(nonNull(GraphQLString))), + annotations: { + fieldAnnotationNoArgs: undefined, + fieldAnnotation: {a: 'Foo', b: 'Bar'}, + } + }); + expect(output).to.equal(` +schema { + query: Root +} + +type Root { + @@fieldAnnotationNoArgs + @@fieldAnnotation(a: Foo, b: Bar) + singleField: [String!]! +} +` + ); + }); + it('Print Object Field', () => { const FooType = new GraphQLObjectType({ name: 'Foo', @@ -602,6 +624,16 @@ directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +type __Annotation { + name: String! + args: [__AnnotationArgument!]! +} + +type __AnnotationArgument { + name: String! + value: String! +} + type __Directive { name: String! description: String @@ -636,6 +668,7 @@ type __Field { type: __Type! isDeprecated: Boolean! deprecationReason: String + annotations: [__Annotation]! } type __InputValue { @@ -663,6 +696,7 @@ type __Type { enumValues(includeDeprecated: Boolean = false): [__EnumValue!] inputFields: [__InputValue!] ofType: __Type + annotations: [__Annotation]! } enum __TypeKind { diff --git a/src/utilities/buildASTSchema.js b/src/utilities/buildASTSchema.js index 5a1d04a5c5..ccdf530732 100644 --- a/src/utilities/buildASTSchema.js +++ b/src/utilities/buildASTSchema.js @@ -67,6 +67,8 @@ import { GraphQLDirective } from '../type/directives'; import { __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, @@ -210,6 +212,8 @@ export function buildASTSchema(ast: Document): GraphQLSchema { Boolean: GraphQLBoolean, ID: GraphQLID, __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, diff --git a/src/utilities/buildClientSchema.js b/src/utilities/buildClientSchema.js index 1161f1fea5..3dfa9c4aa8 100644 --- a/src/utilities/buildClientSchema.js +++ b/src/utilities/buildClientSchema.js @@ -30,6 +30,8 @@ import { import { __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, @@ -105,6 +107,8 @@ export function buildClientSchema( Boolean: GraphQLBoolean, ID: GraphQLID, __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index 2a7f57a75d..554abbfd68 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -28,6 +28,8 @@ import { import { __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, @@ -176,6 +178,8 @@ export function extendSchema( Boolean: GraphQLBoolean, ID: GraphQLID, __Schema, + __Annotation, + __AnnotationArgument, __Directive, __DirectiveLocation, __Type, @@ -343,6 +347,8 @@ export function extendSchema( deprecationReason: field.deprecationReason, type: extendFieldType(field.type), args: keyMap(field.args, arg => arg.name), + annotations: field.annotations && + keyMap(field.annotations, annotation => annotation.name), resolve: cannotExecuteClientSchema, }; }); @@ -363,6 +369,7 @@ export function extendSchema( newFieldMap[fieldName] = { type: buildFieldType(field.type), args: buildInputValues(field.arguments), + annotations: buildAnnotations(field.annotations), resolve: cannotExecuteClientSchema, }; }); @@ -453,6 +460,7 @@ export function extendSchema( typeAST.fields, field => field.name.value, field => ({ + annotations: buildAnnotations(field.annotations), type: buildFieldType(field.type), args: buildInputValues(field.arguments), resolve: cannotExecuteClientSchema, @@ -474,6 +482,35 @@ export function extendSchema( ); } + function buildAnnotations(annotations: Array) { + if (!annotations.length) { + return; + } + const wrap = function (left, str, right, condition) { + return condition ? `${left}${str}${right}` : str; + }; + const annotationArgs = function (args) { + if (!args.length) { + return; + } + return keyValMap( + args, + argument => argument.name.value, + argument => wrap( + '"', + argument.value.value, + '"', + argument.value.kind === 'StringValue' + ) + ); + }; + return keyValMap( + annotations, + annotation => annotation.name.value, + annotation => annotationArgs(annotation.arguments) + ); + } + 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 f977f9e4a8..15fd0e6896 100644 --- a/src/utilities/schemaPrinter.js +++ b/src/utilities/schemaPrinter.js @@ -151,7 +151,8 @@ function printFields(type) { const fieldMap = type.getFields(); const 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'); } @@ -172,5 +173,25 @@ function printInputValue(arg) { function printDirective(directive) { return 'directive @' + directive.name + printArgs(directive) + - ' on ' + directive.locations.join(' | '); + ' on ' + directive.locations.join(' | '); +} + +// NOTE: @clintwood (flow noob) not sure how to fix flow error on annotations: +// 'Computed property/element cannot be accessed on possibly undefined value' +// so using 'annotations: any' to skip flow checking here +function printAnnotations(annotations: any) { + if (!annotations || Object.keys(annotations).length === 0) { + return ''; + } + const printAnnotationArgs = function (args) { + if (!args) { + return ''; + } + return '(' + + Object.keys(args).map(name => `${name}: ${args[name]}`).join(', ') + + ')'; + }; + return Object.keys(annotations).map( + name => ` @@${name}${printAnnotationArgs(annotations[name])}` + ).join('\n') + '\n'; }