diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 884b51f6e..07d78c2ac 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -414,7 +414,8 @@ protected function p(?Node $node): string return BlockString::print($node->value); } - return \json_encode($node->value, JSON_THROW_ON_ERROR); + // Do not escape unicode or slashes in order to keep urls valid + return \json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); case $node instanceof UnionTypeDefinitionNode: $typesStr = $this->printList($node->types, ' | '); diff --git a/src/Type/Definition/CustomScalarType.php b/src/Type/Definition/CustomScalarType.php index 64cd887de..cd8af1067 100644 --- a/src/Type/Definition/CustomScalarType.php +++ b/src/Type/Definition/CustomScalarType.php @@ -18,6 +18,7 @@ * serialize?: callable(mixed): mixed, * parseValue: callable(mixed): mixed, * parseLiteral: callable(ValueNode&Node, array|null): mixed, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -27,6 +28,7 @@ * serialize: callable(mixed): mixed, * parseValue?: callable(mixed): mixed, * parseLiteral?: callable(ValueNode&Node, array|null): mixed, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 0a6db42e5..c79f52c3d 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -26,7 +26,9 @@ class Directive public const IF_ARGUMENT_NAME = 'if'; public const SKIP_NAME = 'skip'; public const DEPRECATED_NAME = 'deprecated'; + public const SPECIFIED_BY_NAME = 'specifiedBy'; public const REASON_ARGUMENT_NAME = 'reason'; + public const URL_ARGUMENT_NAME = 'url'; /** * Lazily initialized. @@ -138,6 +140,19 @@ public static function getInternalDirectives(): array ], ], ]), + 'specifiedBy' => new self([ + 'name' => self::SPECIFIED_BY_NAME, + 'description' => 'Exposes a URL that specifies the behaviour of this scalar.', + 'locations' => [ + DirectiveLocation::SCALAR, + ], + 'args' => [ + self::URL_ARGUMENT_NAME => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types.', + ], + ], + ]), ]; } @@ -157,6 +172,14 @@ public static function deprecatedDirective(): Directive return $internal['deprecated']; } + /** @throws InvariantViolation */ + public static function specifiedByDirective(): Directive + { + $internal = self::getInternalDirectives(); + + return $internal['specifiedBy']; + } + /** @throws InvariantViolation */ public static function isSpecifiedDirective(Directive $directive): bool { diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index 453671fe3..dcdeca0df 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -28,6 +28,7 @@ * @phpstan-type ScalarConfig array{ * name?: string|null, * description?: string|null, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -38,6 +39,8 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp public ?ScalarTypeDefinitionNode $astNode; + public ?string $specifiedByURL; + /** @var array */ public array $extensionASTNodes; @@ -55,6 +58,7 @@ public function __construct(array $config = []) $this->description = $config['description'] ?? $this->description ?? null; $this->astNode = $config['astNode'] ?? null; $this->extensionASTNodes = $config['extensionASTNodes'] ?? []; + $this->specifiedByURL = $config['specifiedByURL'] ?? null; $this->config = $config; } diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 1e5aee27c..5e5df682d 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -392,7 +392,6 @@ public function buildField(FieldDefinitionNode $field): array * @param EnumValueDefinitionNode|FieldDefinitionNode|InputValueDefinitionNode $node * * @throws \Exception - * @throws \ReflectionException * @throws InvariantViolation */ private function getDeprecationReason(Node $node): ?string @@ -405,6 +404,25 @@ private function getDeprecationReason(Node $node): ?string return $deprecated['reason'] ?? null; } + /** + * Given a collection of directives, returns the string value for the + * specifiedBy url. + * + * @param ScalarTypeDefinitionNode $node + * + * @throws \Exception + * @throws InvariantViolation + */ + private function getSpecifiedByURL(Node $node): ?string + { + $specifiedBy = Values::getDirectiveValues( + Directive::specifiedByDirective(), + $node + ); + + return $specifiedBy['url'] ?? null; + } + /** * @param array $nodes * @@ -509,7 +527,10 @@ private function makeUnionDef(UnionTypeDefinitionNode $def): UnionType ]); } - /** @throws InvariantViolation */ + /** + * @throws \Exception + * @throws InvariantViolation + */ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType { $name = $def->name->value; @@ -522,6 +543,7 @@ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType 'astNode' => $def, 'extensionASTNodes' => $extensionASTNodes, 'serialize' => static fn ($value) => $value, + 'specifiedByURL' => $this->getSpecifiedByURL($def), ]); } diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index dd1885891..df9aac5d9 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -223,6 +223,9 @@ static function (string $typeName): Type { if (! isset($directivesByName['deprecated'])) { $directives[] = Directive::deprecatedDirective(); } + if (! isset($directivesByName['specifiedBy'])) { + $directives[] = Directive::specifiedByDirective(); + } // Note: While this could make early assertions to get the correctly // typed values below, that would throw immediately while type system diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index d1bb1095d..3aa2378e8 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -228,6 +228,7 @@ protected function extendScalarType(ScalarType $type): CustomScalarType 'serialize' => [$type, 'serialize'], 'parseValue' => [$type, 'parseValue'], 'parseLiteral' => [$type, 'parseLiteral'], + 'specifiedByURL' => $type->specifiedByURL, 'astNode' => $type->astNode, 'extensionASTNodes' => $extensionASTNodes, ]); diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index a98485649..861361af2 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -361,11 +361,14 @@ protected static function printInputValue($arg): string * @phpstan-param Options $options * * @throws \JsonException + * @throws InvariantViolation + * @throws SerializationError */ protected static function printScalar(ScalarType $type, array $options): string { return static::printDescription($options, $type) - . "scalar {$type->name}"; + . "scalar {$type->name}" + . static::printSpecifiedBy($type); } /** @@ -452,6 +455,26 @@ protected static function printDeprecated($deprecation): string return " @deprecated(reason: {$reasonASTString})"; } + /** + * @throws \JsonException + * @throws InvariantViolation + * @throws SerializationError + */ + protected static function printSpecifiedBy(ScalarType $type): string + { + $url = $type->specifiedByURL; + if ($url === null) { + return ''; + } + + $urlAST = AST::astFromValue($url, Type::string()); + assert($urlAST instanceof StringValueNode); + + $urlASTString = Printer::doPrint($urlAST); + + return " @specifiedBy(url: {$urlASTString})"; + } + protected static function printImplementedInterfaces(ImplementingType $type): string { $interfaces = $type->getInterfaces(); diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 02736ea21..7b6dd17aa 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -173,6 +173,10 @@ protected function directiveExcludesField(FieldNode $node): bool return false; } + if ($directiveNode->name->value === Directive::SPECIFIED_BY_NAME) { + return false; + } + [$errors, $variableValues] = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index c484db3a5..d6e118563 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -962,6 +962,30 @@ public function testExecutesAnIntrospectionQuery(): void 3 => 'INPUT_FIELD_DEFINITION', ], ], + [ + 'name' => 'specifiedBy', + 'args' => [ + 0 => [ + 'name' => 'url', + 'type' => [ + 'kind' => 'NON_NULL', + 'name' => null, + 'ofType' => [ + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => null, + ], + ], + 'defaultValue' => null, + 'isDeprecated' => false, + 'deprecationReason' => null, + ], + ], + 'isRepeatable' => false, + 'locations' => [ + 0 => 'SCALAR', + ], + ], ], ], ], diff --git a/tests/Utils/BreakingChangesFinderTest.php b/tests/Utils/BreakingChangesFinderTest.php index fd263cab7..09125ef83 100644 --- a/tests/Utils/BreakingChangesFinderTest.php +++ b/tests/Utils/BreakingChangesFinderTest.php @@ -1329,7 +1329,7 @@ public function testShouldDetectIfADirectiveWasImplicitlyRemoved(): void $oldSchema = new Schema([]); $newSchema = new Schema([ - 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + 'directives' => [Directive::skipDirective(), Directive::includeDirective(), Directive::specifiedByDirective()], ]); $deprecatedDirective = Directive::deprecatedDirective(); diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 71933ce40..66ea0fad7 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -269,13 +269,10 @@ public function testMaintainsIncludeSkipAndSpecifiedBy(): void { $schema = BuildSchema::buildAST(Parser::parse('type Query')); - // TODO switch to 4 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(3, $schema->getDirectives()); + self::assertCount(4, $schema->getDirectives()); self::assertSame(Directive::skipDirective(), $schema->getDirective('skip')); self::assertSame(Directive::includeDirective(), $schema->getDirective('include')); self::assertSame(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertSame(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); } @@ -286,15 +283,13 @@ public function testOverridingDirectivesExcludesSpecified(): void directive @skip on FIELD directive @include on FIELD directive @deprecated on FIELD_DEFINITION - directive @specifiedBy on FIELD_DEFINITION + directive @specifiedBy on SCALAR ')); self::assertCount(4, $schema->getDirectives()); self::assertNotEquals(Directive::skipDirective(), $schema->getDirective('skip')); self::assertNotEquals(Directive::includeDirective(), $schema->getDirective('include')); self::assertNotEquals(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotEquals(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); } @@ -307,14 +302,11 @@ public function testAddingDirectivesMaintainsIncludeSkipAndSpecifiedBy(): void GRAPHQL; $schema = BuildSchema::buildAST(Parser::parse($sdl)); - // TODO switch to 5 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(4, $schema->getDirectives()); + self::assertCount(5, $schema->getDirectives()); self::assertNotNull($schema->getDirective('foo')); self::assertNotNull($schema->getDirective('skip')); self::assertNotNull($schema->getDirective('include')); self::assertNotNull($schema->getDirective('deprecated')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotNull($schema->getDirective('specifiedBy')); } @@ -815,7 +807,6 @@ enum: MyEnum /** @see it('Supports @specifiedBy') */ public function testSupportsSpecifiedBy(): void { - self::markTestSkipped('See https://github.com/webonyx/graphql-php/issues/1140'); $sdl = <<getType('Foo'); - self::assertSame('https://example.com/foo_spec', $schema->getType('Foo')->specifiedByURL); + self::assertInstanceOf(ScalarType::class, $type); + self::assertSame('https://example.com/foo_spec', $type->specifiedByURL); } /** @see it('Correctly extend scalar type') */ diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index 4e10d5bd1..4b4c12ae5 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -461,8 +461,10 @@ public function testExtendsScalarsByAddingSpecifiedByDirective(): void extend scalar Foo @specifiedBy(url: "https://example.com/foo_spec") GRAPHQL; + $extendedSchema = SchemaExtender::extend($schema, Parser::parse($extensionSDL)); $foo = $extendedSchema->getType('Foo'); + assert($foo instanceof ScalarType); self::assertSame('https://example.com/foo_spec', $foo->specifiedByURL); self::assertEmpty($extendedSchema->validate()); diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 797157539..2e0464138 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1010,6 +1010,12 @@ public function testPrintIntrospectionSchema(): void "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https:\/\/commonmark.org\/)." reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types." + url: String! + ) on SCALAR "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations." type __Schema { diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php index 679ccef2f..4cad58cc2 100644 --- a/tests/Validator/KnownDirectivesTest.php +++ b/tests/Validator/KnownDirectivesTest.php @@ -318,7 +318,7 @@ public function testWSLWithWellPlacedDirectives(): void extend type MyObj @onObject - scalar MyScalar @onScalar + scalar MyScalar @onScalar extend scalar MyScalar @onScalar diff --git a/tests/Validator/ValidatorTestCase.php b/tests/Validator/ValidatorTestCase.php index 28e48d2ec..22fe55e30 100644 --- a/tests/Validator/ValidatorTestCase.php +++ b/tests/Validator/ValidatorTestCase.php @@ -367,10 +367,7 @@ public static function getTestSchema(): Schema return new Schema([ 'query' => $queryRoot, 'subscription' => $subscriptionRoot, - 'directives' => [ - Directive::includeDirective(), - Directive::skipDirective(), - Directive::deprecatedDirective(), + 'directives' => array_merge(Directive::getInternalDirectives(), [ new Directive([ 'name' => 'directive', 'locations' => [DirectiveLocation::FIELD, DirectiveLocation::FRAGMENT_DEFINITION], @@ -420,7 +417,7 @@ public static function getTestSchema(): Schema 'name' => 'onVariableDefinition', 'locations' => [DirectiveLocation::VARIABLE_DEFINITION], ]), - ], + ]), ]); }