diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php index 95fa8c6..828918b 100644 --- a/src/Type/Definition/EnumType.php +++ b/src/Type/Definition/EnumType.php @@ -31,6 +31,8 @@ class EnumType extends Type implements InputType, OutputType, LeafType $config['name'] = $this->tryInferName(); } + Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); + Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, 'values' => Config::arrayOf([ diff --git a/src/Type/Definition/InputObjectType.php b/src/Type/Definition/InputObjectType.php index 088e542..e848cd1 100644 --- a/src/Type/Definition/InputObjectType.php +++ b/src/Type/Definition/InputObjectType.php @@ -29,7 +29,7 @@ class InputObjectType extends Type implements InputType $config['name'] = $this->tryInferName(); } - Utils::invariant(!empty($config['name']), 'Every type is expected to have name'); + Utils::assertValidName($config['name']); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index e44a18d..bd5d087 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -34,6 +34,8 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT $config['name'] = $this->tryInferName(); } + Utils::assertValidName($config['name']); + Config::validate($config, [ 'name' => Config::NAME, 'fields' => Config::arrayOf( diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 7a2fd65..00e076d 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -79,7 +79,7 @@ class ObjectType extends Type implements OutputType, CompositeType $config['name'] = $this->tryInferName(); } - Utils::invariant(!empty($config['name']), 'Every type is expected to have name'); + Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); // Note: this validation is disabled by default, because it is resource-consuming // TODO: add bin/validate script to check if schema is valid during development diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index afdd367..d072f89 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -32,6 +32,6 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp $this->name = $this->tryInferName(); } - Utils::invariant($this->name, 'Type must be named.'); + Utils::assertValidName($this->name); } } diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index ec83914..1671cf6 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -34,6 +34,8 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType $config['name'] = $this->tryInferName(); } + Utils::assertValidName($config['name']); + Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, 'types' => Config::arrayOf(Config::OBJECT_TYPE, Config::MAYBE_THUNK | Config::REQUIRED), diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index c8fa398..35c21ba 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -232,6 +232,7 @@ EOD; if (!isset(self::$map['__Schema'])) { self::$map['__Schema'] = new ObjectType([ 'name' => '__Schema', + 'isIntrospection' => true, 'description' => 'A GraphQL Schema defines the capabilities of a GraphQL ' . 'server. It exposes all available types and directives on ' . @@ -286,6 +287,7 @@ EOD; if (!isset(self::$map['__Directive'])) { self::$map['__Directive'] = new ObjectType([ 'name' => '__Directive', + 'isIntrospection' => true, 'description' => 'A Directive provides a way to describe alternate runtime execution and ' . 'type validation behavior in a GraphQL document.' . "\n\nIn some cases, you need to provide options to alter GraphQL's " . @@ -345,6 +347,7 @@ EOD; if (!isset(self::$map['__DirectiveLocation'])) { self::$map['__DirectiveLocation'] = new EnumType([ 'name' => '__DirectiveLocation', + 'isIntrospection' => true, 'description' => 'A Directive can be adjacent to many parts of the GraphQL language, a ' . '__DirectiveLocation describes one such possible adjacencies.', @@ -433,6 +436,7 @@ EOD; if (!isset(self::$map['__Type'])) { self::$map['__Type'] = new ObjectType([ 'name' => '__Type', + 'isIntrospection' => true, 'description' => 'The fundamental unit of any GraphQL Schema is the type. There are ' . 'many kinds of types in GraphQL as represented by the `__TypeKind` enum.' . @@ -560,6 +564,7 @@ EOD; self::$map['__Field'] = new ObjectType([ 'name' => '__Field', + 'isIntrospection' => true, 'description' => '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.', @@ -600,6 +605,7 @@ EOD; if (!isset(self::$map['__InputValue'])) { self::$map['__InputValue'] = new ObjectType([ 'name' => '__InputValue', + 'isIntrospection' => true, 'description' => 'Arguments provided to Fields or Directives and the input fields of an ' . 'InputObject are represented as Input Values which describe their type ' . @@ -637,6 +643,7 @@ EOD; if (!isset(self::$map['__EnumValue'])) { self::$map['__EnumValue'] = new ObjectType([ 'name' => '__EnumValue', + 'isIntrospection' => true, 'description' => '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 ' . @@ -664,6 +671,7 @@ EOD; if (!isset(self::$map['__TypeKind'])) { self::$map['__TypeKind'] = new EnumType([ 'name' => '__TypeKind', + 'isIntrospection' => true, 'description' => 'An enum describing what kind of type a given `__Type` is.', 'values' => [ 'SCALAR' => [ diff --git a/src/Utils.php b/src/Utils.php index 6a41f57..679e5d2 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -331,4 +331,33 @@ class Utils // Otherwise print the escaped form. : '"\\u' . dechex($code) . '"'; } + + /** + * @param $name + * @param bool $isIntrospection + * @throws Error + */ + public static function assertValidName($name, $isIntrospection = false) + { + $regex = '/^[_a-zA-Z][_a-zA-Z0-9]*$/'; + + if (!$name || !is_string($name)) { + throw new InvariantViolation( + "Must be named. Unexpected name: " . self::printSafe($name) + ); + } + + if (!$isIntrospection && isset($name[1]) && $name[0] === '_' && $name[1] === '_') { + throw new InvariantViolation( + 'Name "'.$name.'" must not begin with "__", which is reserved by ' . + 'GraphQL introspection.' + ); + } + + if (!preg_match($regex, $name)) { + throw new InvariantViolation( + 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "'.$name.'" does not.' + ); + } + } } diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php new file mode 100644 index 0000000..43a5653 --- /dev/null +++ b/tests/Type/ValidationTest.php @@ -0,0 +1,268 @@ +assertEachCallableThrows([ + function() { + return new ObjectType([]); + }, + function() { + return new EnumType([]); + }, + function() { + return new InputObjectType([]); + }, + function() { + return new UnionType([]); + }, + function() { + return new InterfaceType([]); + } + ], 'Must be named. Unexpected name: null'); + } + + public function testRejectsAnObjectTypeWithReservedName() + { + $this->assertEachCallableThrows([ + function() { + return new ObjectType([ + 'name' => '__ReservedName', + ]); + }, + function() { + return new EnumType([ + 'name' => '__ReservedName', + ]); + }, + function() { + return new InputObjectType([ + 'name' => '__ReservedName', + ]); + }, + function() { + return new UnionType([ + 'name' => '__ReservedName', + ]); + }, + function() { + return new InterfaceType([ + 'name' => '__ReservedName', + ]); + } + ], 'Name "__ReservedName" must not begin with "__", which is reserved by GraphQL introspection.'); + } + + public function testRejectsAnObjectTypeWithInvalidName() + { + $this->assertEachCallableThrows([ + function() { + return new ObjectType([ + 'name' => 'a-b-c', + ]); + }, + function() { + return new EnumType([ + 'name' => 'a-b-c', + ]); + }, + function() { + return new InputObjectType([ + 'name' => 'a-b-c', + ]); + }, + function() { + return new UnionType([ + 'name' => 'a-b-c', + ]); + }, + function() { + return new InterfaceType([ + 'name' => 'a-b-c', + ]); + } + ], 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "a-b-c" does not.'); + } + + // DESCRIBE: Type System: A Schema must have Object root types + // TODO: accepts a Schema whose query type is an object type + // TODO: accepts a Schema whose query and mutation types are object types + // TODO: accepts a Schema whose query and subscription types are object types + // TODO: rejects a Schema without a query type + // TODO: rejects a Schema whose query type is an input type + // TODO: rejects a Schema whose mutation type is an input type + // TODO: rejects a Schema whose subscription type is an input type + // TODO: rejects a Schema whose directives are incorrectly typed + + // DESCRIBE: Type System: A Schema must contain uniquely named types + // TODO: rejects a Schema which redefines a built-in type + // TODO: rejects a Schema which defines an object type twice + // TODO: rejects a Schema which have same named objects implementing an interface + + // DESCRIBE: Type System: Objects must have fields + + // TODO: accepts an Object type with fields object + // TODO: accepts an Object type with a field function + + // TODO: rejects an Object type with missing fields + // TODO: rejects an Object type with incorrectly named fields + // TODO: rejects an Object type with reserved named fields + // TODO: rejects an Object type with incorrectly typed fields + // TODO: rejects an Object type with empty fields + // TODO: rejects an Object type with a field function that returns nothing + // TODO: rejects an Object type with a field function that returns empty + + // DESCRIBE: Type System: Fields args must be properly named + // TODO: accepts field args with valid names + // TODO: rejects field arg with invalid names + + // DESCRIBE: Type System: Fields args must be objects + // TODO: accepts an Object type with field args + // TODO: rejects an Object type with incorrectly typed field args + + // DESCRIBE: Type System: Object interfaces must be array + // TODO: accepts an Object type with array interfaces + // TODO: accepts an Object type with interfaces as a function returning an array + // TODO: rejects an Object type with incorrectly typed interfaces + // TODO: rejects an Object type with interfaces as a function returning an incorrect type + + // DESCRIBE: Type System: Union types must be array + // TODO: accepts a Union type with array types + // TODO: accepts a Union type with function returning an array of types + // TODO: rejects a Union type without types + // TODO: rejects a Union type with empty types + // TODO: rejects a Union type with incorrectly typed types + + // DESCRIBE: Type System: Input Objects must have fields + // TODO: accepts an Input Object type with fields + // TODO: accepts an Input Object type with a field function + // TODO: rejects an Input Object type with missing fields + // TODO: rejects an Input Object type with incorrectly typed fields + // TODO: rejects an Input Object type with empty fields + // TODO: rejects an Input Object type with a field function that returns nothing + // TODO: rejects an Input Object type with a field function that returns empty + + // DESCRIBE: Type System: Object types must be assertable + // TODO: accepts an Object type with an isTypeOf function + // TODO: rejects an Object type with an incorrect type for isTypeOf + + // DESCRIBE: Type System: Interface types must be resolvable + // TODO: accepts an Interface type defining resolveType + // TODO: accepts an Interface with implementing type defining isTypeOf + // TODO: accepts an Interface type defining resolveType with implementing type defining isTypeOf + // TODO: rejects an Interface type with an incorrect type for resolveType + // TODO: rejects an Interface type not defining resolveType with implementing type not defining isTypeOf + + // DESCRIBE: Type System: Union types must be resolvable + // TODO: accepts a Union type defining resolveType + // TODO: accepts a Union of Object types defining isTypeOf + // TODO: accepts a Union type defining resolveType of Object types defining isTypeOf + // TODO: rejects an Interface type with an incorrect type for resolveType + // TODO: rejects a Union type not defining resolveType of Object types not defining isTypeOf + + // DESCRIBE: Type System: Scalar types must be serializable + // TODO: accepts a Scalar type defining serialize + // TODO: rejects a Scalar type not defining serialize + // TODO: rejects a Scalar type defining serialize with an incorrect type + // TODO: accepts a Scalar type defining parseValue and parseLiteral + // TODO: rejects a Scalar type defining parseValue but not parseLiteral + // TODO: rejects a Scalar type defining parseLiteral but not parseValue + // TODO: rejects a Scalar type defining parseValue and parseLiteral with an incorrect type + + // DESCRIBE: Type System: Enum types must be well defined + // TODO: accepts a well defined Enum type with empty value definition + // TODO: accepts a well defined Enum type with internal value definition + // TODO: rejects an Enum type without values + // TODO: rejects an Enum type with empty values + // TODO: rejects an Enum type with incorrectly typed values + // TODO: rejects an Enum type with missing value definition + // TODO: rejects an Enum type with incorrectly typed value definition + + // DESCRIBE: Type System: Object fields must have output types + // TODO: accepts an output type as an Object field type + // TODO: rejects an empty Object field type + // TODO: rejects a non-output type as an Object field type + + // DESCRIBE: Type System: Objects can only implement interfaces + // TODO: accepts an Object implementing an Interface + + // DESCRIBE: Type System: Unions must represent Object types + // TODO: accepts a Union of an Object Type + // TODO: rejects a Union of a non-Object type + + // DESCRIBE: Type System: Interface fields must have output types + // TODO: accepts an output type as an Interface field type + // TODO: rejects an empty Interface field type + // TODO: rejects a non-output type as an Interface field type + + // DESCRIBE: Type System: Field arguments must have input types + // TODO: accepts an input type as a field arg type + // TODO: rejects an empty field arg type + // TODO: rejects a non-input type as a field arg type + + // DESCRIBE: Type System: Input Object fields must have input types + // TODO: accepts an input type as an input field type + // TODO: rejects an empty input field type + // TODO: rejects a non-input type as an input field type + + // DESCRIBE: Type System: List must accept GraphQL types + // TODO: accepts an type as item type of list: ${type} + // TODO: rejects a non-type as item type of list: ${type} + + // DESCRIBE: Type System: NonNull must accept GraphQL types + // TODO: accepts an type as nullable type of non-null: ${type} + // TODO: rejects a non-type as nullable type of non-null: ${type} + + // DESCRIBE: Objects must adhere to Interface they implement + // TODO: accepts an Object which implements an Interface + // TODO: accepts an Object which implements an Interface along with more fields + // TODO: accepts an Object which implements an Interface field along with additional optional arguments + // TODO: rejects an Object which implements an Interface field along with additional required arguments + // TODO: rejects an Object missing an Interface field + // TODO: rejects an Object with an incorrectly typed Interface field + // TODO: rejects an Object with a differently typed Interface field + // TODO: accepts an Object with a subtyped Interface field (interface) + // TODO: accepts an Object with a subtyped Interface field (union) + // TODO: rejects an Object missing an Interface argument + // TODO: rejects an Object with an incorrectly typed Interface argument + // TODO: accepts an Object with an equivalently modified Interface field type + // TODO: rejects an Object with a non-list Interface field list type + // TODO: rejects an Object with a list Interface field non-list type + // TODO: accepts an Object with a subset non-null Interface field type + // TODO: rejects an Object with a superset nullable Interface field type + // TODO: does not allow isDeprecated without deprecationReason on field + // TODO: does not allow isDeprecated without deprecationReason on enum + + private function assertEachCallableThrows($closures, $expectedError) + { + foreach ($closures as $index => $factory) { + try { + $factory(); + $this->fail('Expected exception not thrown for entry ' . $index); + } catch (InvariantViolation $e) { + $this->assertEquals($expectedError, $e->getMessage(), 'Error in callable #' . $index); + } + } + } + + private function schemaWithFieldType($type) + { + return new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ 'f' => $type ] + ]), + 'types' => [ $type ] + ]); + } +}