Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aee90de
Add @specifiedBy directive support, address review comments
Copilot May 13, 2026
1f67690
Enable testExtendsDifferentTypesMultipleTimes with specifiedBy directive
Copilot May 13, 2026
b47914d
Fix directiveExcludesField and tighten getSpecifiedByURL parameter type
Copilot May 13, 2026
507db56
Remove unnecessary @deprecated and @specifiedBy guards from directive…
Copilot May 13, 2026
ccb5788
Fix directive ordering and trailing newline in test heredoc
Copilot May 13, 2026
b23ea61
Fix directive ordering in BuildSchema.php: append oneOf before specif…
Copilot May 13, 2026
80b3804
Align @specifiedBy with graphql-js: ordering (specifiedBy before oneO…
Copilot May 13, 2026
e11c9a9
Add changelog entry and docs for @specifiedBy directive support
Copilot May 13, 2026
9f2f581
Re-fix description in scalars doc to say behavior instead of serializ…
Copilot May 13, 2026
22c0ab1
Address review comments: introspection specifiedByURL, custom directi…
Copilot May 13, 2026
b9f6ff7
Fix code style issues found by autofix.ci: import ordering and docblo…
Copilot May 13, 2026
d615104
Address review comments: lazy variable coercion, extension node @spec…
Copilot May 14, 2026
e3b7d7f
Add tests for @specifiedBy extension nodes, introspection specifiedBy…
Copilot May 14, 2026
033fc1b
Fix review comments on tests: improve comment clarity and remove unne…
Copilot May 14, 2026
9d39aca
Autofix
autofix-ci[bot] May 14, 2026
97268c1
Use proper imports in BuildClientSchemaTest instead of FQCNs
Copilot May 14, 2026
e9457cd
Align tests with graphql-js: @see annotations, missing tests, fix bro…
Copilot May 14, 2026
b864241
Address latest 3 review comments: ScalarType subclass default, docs g…
Copilot May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Language/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,8 @@ protected static function p(?Node $node): string
return BlockString::print($node->value);
}

return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
// Do not escape unicode or slashes in order to keep URLs valid
Comment thread
spawnia marked this conversation as resolved.
Outdated
return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

case $node instanceof UnionTypeDefinitionNode:
$typesStr = static::printList($node->types, ' | ');
Expand Down
2 changes: 2 additions & 0 deletions src/Type/Definition/CustomScalarType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* serialize?: callable(mixed): mixed,
* parseValue: callable(mixed): mixed,
* parseLiteral: callable(ValueNode&Node, array<string, mixed>|null): mixed,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand All @@ -27,6 +28,7 @@
* serialize: callable(mixed): mixed,
* parseValue?: callable(mixed): mixed,
* parseLiteral?: callable(ValueNode&Node, array<string, mixed>|null): mixed,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand Down
25 changes: 24 additions & 1 deletion src/Type/Definition/Directive.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ class Directive
public const DEFAULT_DEPRECATION_REASON = 'No longer supported';

public const INCLUDE_NAME = 'include';
public const IF_ARGUMENT_NAME = 'if';
public const SKIP_NAME = 'skip';
public const IF_ARGUMENT_NAME = 'if';

public const DEPRECATED_NAME = 'deprecated';
public const REASON_ARGUMENT_NAME = 'reason';

public const SPECIFIED_BY_NAME = 'specifiedBy';
public const URL_ARGUMENT_NAME = 'url';

public const ONE_OF_NAME = 'oneOf';

/**
Expand Down Expand Up @@ -82,6 +87,7 @@ public static function builtInDirectives(): array
self::INCLUDE_NAME => self::includeDirective(),
self::SKIP_NAME => self::skipDirective(),
self::DEPRECATED_NAME => self::deprecatedDirective(),
self::SPECIFIED_BY_NAME => self::specifiedByDirective(),
self::ONE_OF_NAME => self::oneOfDirective(),
];
}
Expand Down Expand Up @@ -167,6 +173,23 @@ public static function oneOfDirective(): Directive
]);
}

public static function specifiedByDirective(): Directive
{
return self::$internalDirectives[self::SPECIFIED_BY_NAME] ??= 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.',
],
],
]);
}

public static function isBuiltInDirective(self $directive): bool
{
return array_key_exists($directive->name, self::builtInDirectives());
Expand Down
4 changes: 4 additions & 0 deletions src/Type/Definition/ScalarType.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* @phpstan-type ScalarConfig array{
* name?: string|null,
* description?: string|null,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand All @@ -38,6 +39,8 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp

public ?ScalarTypeDefinitionNode $astNode;

public ?string $specifiedByURL;
Comment thread
spawnia marked this conversation as resolved.

/** @var array<ScalarTypeExtensionNode> */
public array $extensionASTNodes;

Expand All @@ -53,6 +56,7 @@ public function __construct(array $config = [])
{
$this->name = $config['name'] ?? $this->inferName();
$this->description = $config['description'] ?? $this->description ?? null;
$this->specifiedByURL = $config['specifiedByURL'] ?? null;
Comment thread
spawnia marked this conversation as resolved.
Outdated
$this->astNode = $config['astNode'] ?? null;
$this->extensionASTNodes = $config['extensionASTNodes'] ?? [];

Expand Down
25 changes: 23 additions & 2 deletions src/Utils/ASTDefinitionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,6 @@ public function buildField(FieldDefinitionNode $field, object $node): array
* @param EnumValueDefinitionNode|FieldDefinitionNode|InputValueDefinitionNode $node
*
* @throws \Exception
* @throws \ReflectionException
* @throws InvariantViolation
*/
private function getDeprecationReason(Node $node): ?string
Expand All @@ -416,6 +415,24 @@ 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
Comment thread
spawnia marked this conversation as resolved.
Outdated
{
Comment thread
spawnia marked this conversation as resolved.
$specifiedBy = Values::getDirectiveValues(
Directive::specifiedByDirective(),
$node
);

return $specifiedBy['url'] ?? null;
}

/**
* @param array<ObjectTypeDefinitionNode|ObjectTypeExtensionNode|InterfaceTypeDefinitionNode|InterfaceTypeExtensionNode> $nodes
*
Expand Down Expand Up @@ -520,7 +537,10 @@ private function makeUnionDef(UnionTypeDefinitionNode $def): UnionType
]);
}

/** @throws InvariantViolation */
/**
* @throws \Exception
* @throws InvariantViolation
*/
private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType
{
$name = $def->name->value;
Expand All @@ -533,6 +553,7 @@ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType
'serialize' => static fn ($value) => $value,
'astNode' => $def,
'extensionASTNodes' => $extensionASTNodes,
'specifiedByURL' => $this->getSpecifiedByURL($def),
Comment thread
spawnia marked this conversation as resolved.
Outdated
]);
}

Expand Down
3 changes: 3 additions & 0 deletions src/Utils/BuildSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ static function (string $typeName): Type {
if (! isset($directivesByName['deprecated'])) {
$directives[] = Directive::deprecatedDirective();
}
if (! isset($directivesByName['specifiedBy'])) {
$directives[] = Directive::specifiedByDirective();
}
if (! isset($directivesByName['oneOf'])) {
$directives[] = Directive::oneOfDirective();
}
Expand Down
13 changes: 13 additions & 0 deletions src/Utils/SchemaExtender.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Executor\Values;
use GraphQL\Language\AST\DirectiveDefinitionNode;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\AST\EnumTypeExtensionNode;
Expand Down Expand Up @@ -226,12 +227,24 @@ protected function extendScalarType(ScalarType $type): CustomScalarType
/** @var array<ScalarTypeExtensionNode> $extensionASTNodes */
$extensionASTNodes = $this->extensionASTNodes($type);

$specifiedByURL = $type->specifiedByURL;
if ($specifiedByURL === null) {
foreach ($extensionASTNodes as $extensionNode) {
$specifiedBy = Values::getDirectiveValues(Directive::specifiedByDirective(), $extensionNode);
if ($specifiedBy !== null) {
$specifiedByURL = $specifiedBy['url'] ?? null;
Comment thread
spawnia marked this conversation as resolved.
Outdated
break;
}
}
}

return new CustomScalarType([
'name' => $type->name,
'description' => $type->description,
'serialize' => [$type, 'serialize'],
'parseValue' => [$type, 'parseValue'],
'parseLiteral' => [$type, 'parseLiteral'],
'specifiedByURL' => $specifiedByURL,
'astNode' => $type->astNode,
'extensionASTNodes' => $extensionASTNodes,
]);
Expand Down
25 changes: 24 additions & 1 deletion src/Utils/SchemaPrinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,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);
}

/**
Expand Down Expand Up @@ -455,6 +458,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();
Expand Down
4 changes: 4 additions & 0 deletions src/Validator/Rules/QueryComplexity.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ protected function directiveExcludesField(FieldNode $node): bool
return false;
}

if ($directiveNode->name->value === Directive::SPECIFIED_BY_NAME) {
return false;
Comment thread
spawnia marked this conversation as resolved.
Outdated
}

[$errors, $variableValues] = Values::getVariableValues(
$this->context->getSchema(),
$this->variableDefs,
Expand Down
24 changes: 24 additions & 0 deletions tests/Type/IntrospectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,30 @@ public function testExecutesAnIntrospectionQuery(): void
0 => 'INPUT_OBJECT',
],
],
[
'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',
],
],
],
],
],
Expand Down
2 changes: 1 addition & 1 deletion tests/Utils/BreakingChangesFinderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 6 additions & 13 deletions tests/Utils/BuildSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,11 @@ public function testMaintainsIncludeSkipAndSpecifiedBy(): void
{
$schema = BuildSchema::buildAST(Parser::parse('type Query'));

// 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::assertSame(Directive::skipDirective(), $schema->getDirective('skip'));
self::assertSame(Directive::includeDirective(), $schema->getDirective('include'));
self::assertSame(Directive::deprecatedDirective(), $schema->getDirective('deprecated'));
self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf'));

self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140');
self::assertSame(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy'));
}

Expand All @@ -295,16 +292,14 @@ 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(5, $schema->getDirectives());
self::assertNotEquals(Directive::skipDirective(), $schema->getDirective('skip'));
self::assertNotEquals(Directive::includeDirective(), $schema->getDirective('include'));
self::assertNotEquals(Directive::deprecatedDirective(), $schema->getDirective('deprecated'));
self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf'));

self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140');
self::assertNotEquals(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy'));
}

Expand All @@ -317,15 +312,12 @@ public function testAddingDirectivesMaintainsIncludeSkipAndSpecifiedBy(): void
GRAPHQL;
$schema = BuildSchema::buildAST(Parser::parse($sdl));

// TODO switch to 6 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140
self::assertCount(5, $schema->getDirectives());
self::assertCount(6, $schema->getDirectives());
self::assertNotNull($schema->getDirective('foo'));
self::assertNotNull($schema->getDirective('skip'));
self::assertNotNull($schema->getDirective('include'));
self::assertNotNull($schema->getDirective('deprecated'));
self::assertNotNull($schema->getDirective('oneOf'));

self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140');
self::assertNotNull($schema->getDirective('specifiedBy'));
}

Expand Down Expand Up @@ -826,7 +818,6 @@ enum: MyEnum
/** @see it('Supports @specifiedBy') */
public function testSupportsSpecifiedBy(): void
{
self::markTestSkipped('See https://github.com/webonyx/graphql-php/issues/1140');
$sdl = <<<GRAPHQL
scalar Foo @specifiedBy(url: "https://example.com/foo_spec")

Expand All @@ -839,8 +830,10 @@ public function testSupportsSpecifiedBy(): void
self::assertCycle($sdl);

$schema = BuildSchema::build($sdl);
$type = $schema->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') */
Expand Down
Loading