diff --git a/.gitignore b/.gitignore index 4e0b161..584c5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ +.idea +.vscode composer.phar /vendor/ /node_modules/ *.cache cov.xml -.idea -.vscode \ No newline at end of file +phpunit.xml \ No newline at end of file diff --git a/README.md b/README.md index cb391f7..7f6143a 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,28 @@ composer require skillshare/apollo-federation-php ### Entities -An entity is an object type that you define canonically in one subgraph and can then reference and extend in other subgraphs. It can be defined via the `EntityObjectType` which takes the same configuration as the default `ObjectType` plus a `keyFields` and `__resolveReference` properties. +An entity is an object type that you define canonically in one subgraph and can then reference and extend in other subgraphs. It can be defined via the `EntityObjectType` which takes the same configuration as the default `ObjectType` plus a `keys` and `__resolveReference` properties. ```php use Apollo\Federation\Types\EntityObjectType; +use GraphQL\Type\Definition\Type; $userType = new EntityObjectType([ 'name' => 'User', - 'keyFields' => ['id', 'email'], + 'keys' => [['fields' => 'id'], ['fields' => 'email']], 'fields' => [ 'id' => ['type' => Type::int()], 'email' => ['type' => Type::string()], 'firstName' => ['type' => Type::string()], 'lastName' => ['type' => Type::string()] ], - '__resolveReference' => static function ($ref) { - // .. fetch from a data source. + '__resolveReference' => function ($ref) { + // ... fetch from a data source. } ]); ``` -* `keyFields` — defines the entity's primary key, which consists of one or more of the type's. An entity's key cannot include fields that return a union or interface. +* `keys` — defines the entity's unique keys, which consists of one or more of the fields. An entity's key cannot include fields that return a union or interface. * `__resolveReference` — resolves the representation of the entity from the provided reference. Subgraphs use representations to reference entities from other subgraphs. A representation requires only an explicit __typename definition and values for the entity's primary key fields. @@ -49,7 +50,7 @@ use Apollo\Federation\Types\EntityRefObjectType; $userType = new EntityRefObjectType([ 'name' => 'User', - 'keyFields' => ['id', 'email'], + 'keys' => [['fields' => 'id', 'resolvable' => false]], 'fields' => [ 'id' => ['type' => Type::int()], 'email' => ['type' => Type::string()] @@ -68,16 +69,12 @@ use Apollo\Federation\Types\EntityRefObjectType; $userType = new EntityRefObjectType([ 'name' => 'User', - 'keyFields' => ['id', 'email'], + 'keys' => [['fields' => 'id', 'resolvable' => false]], 'fields' => [ 'id' => [ 'type' => Type::int(), 'isExternal' => true ], - 'email' => [ - 'type' => Type::string(), - 'isExternal' => true - ] ] ]); ``` diff --git a/composer.json b/composer.json index b7f737f..c01542d 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "license": "MIT", "require": { "php": "^7.1||^8.0", + "ext-json": "*", "webonyx/graphql-php": "^0.13.8 || ^14.0" }, "scripts": { diff --git a/phpunit.xml b/phpunit.xml.dist similarity index 78% rename from phpunit.xml rename to phpunit.xml.dist index 75731ed..55389c4 100644 --- a/phpunit.xml +++ b/phpunit.xml.dist @@ -1,5 +1,8 @@ - + ./src diff --git a/src/Directives.php b/src/Directives.php index 8e71aa6..db87a81 100644 --- a/src/Directives.php +++ b/src/Directives.php @@ -4,62 +4,125 @@ namespace Apollo\Federation; -use Apollo\Federation\Directives\KeyDirective; use Apollo\Federation\Directives\ExternalDirective; +use Apollo\Federation\Directives\InaccessibleDirective; +use Apollo\Federation\Directives\KeyDirective; +use Apollo\Federation\Directives\LinkDirective; +use Apollo\Federation\Directives\OverrideDirective; use Apollo\Federation\Directives\ProvidesDirective; use Apollo\Federation\Directives\RequiresDirective; +use Apollo\Federation\Directives\ShareableDirective; +use Apollo\Federation\Enum\DirectiveEnum; /** * Helper class to get directives for annotating federated entity types. */ class Directives { - /** @var array */ + /** + * @var array{ + * external: ExternalDirective, + * inaccessible: InaccessibleDirective, + * key: KeyDirective, + * link: LinkDirective, + * override: OverrideDirective, + * requires: RequiresDirective, + * provides: ProvidesDirective, + * shareable: ShareableDirective, + * }|null + */ private static $directives; /** - * Gets the @key directive + * Gets the @key directive. */ public static function key(): KeyDirective { - return self::getDirectives()['key']; + return self::getDirectives()[DirectiveEnum::KEY]; } /** - * Gets the @external directive + * Gets the @external directive. */ public static function external(): ExternalDirective { - return self::getDirectives()['external']; + return self::getDirectives()[DirectiveEnum::EXTERNAL]; + } + + /** + * Gets the @inaccessible directive. + */ + public static function inaccessible(): InaccessibleDirective + { + return self::getDirectives()[DirectiveEnum::INACCESSIBLE]; + } + + /** + * Gets the `link` directive. + */ + public static function link(): LinkDirective + { + return self::getDirectives()[DirectiveEnum::LINK]; + } + + /** + * Gets the @override directive. + */ + public static function override(): OverrideDirective + { + return self::getDirectives()[DirectiveEnum::OVERRIDE]; } /** - * Gets the @requires directive + * Gets the @requires directive. */ public static function requires(): RequiresDirective { - return self::getDirectives()['requires']; + return self::getDirectives()[DirectiveEnum::REQUIRES]; } /** - * Gets the @provides directive + * Gets the @provides directive. */ public static function provides(): ProvidesDirective { - return self::getDirectives()['provides']; + return self::getDirectives()[DirectiveEnum::PROVIDES]; + } + + /** + * Gets the @shareable directive. + */ + public static function shareable(): ShareableDirective + { + return self::getDirectives()[DirectiveEnum::SHAREABLE]; } /** - * Gets the directives that can be used on federated entity types + * Gets the directives that can be used on federated entity types. + * + * @return array{ + * external: ExternalDirective, + * inaccessible: InaccessibleDirective, + * key: KeyDirective, + * link: LinkDirective, + * override: OverrideDirective, + * requires: RequiresDirective, + * provides: ProvidesDirective, + * shareable: ShareableDirective, + * } */ public static function getDirectives(): array { if (!self::$directives) { self::$directives = [ - 'key' => new KeyDirective(), - 'external' => new ExternalDirective(), - 'requires' => new RequiresDirective(), - 'provides' => new ProvidesDirective() + DirectiveEnum::EXTERNAL => new ExternalDirective(), + DirectiveEnum::INACCESSIBLE => new InaccessibleDirective(), + DirectiveEnum::KEY => new KeyDirective(), + DirectiveEnum::LINK => new LinkDirective(), + DirectiveEnum::OVERRIDE => new OverrideDirective(), + DirectiveEnum::REQUIRES => new RequiresDirective(), + DirectiveEnum::PROVIDES => new ProvidesDirective(), + DirectiveEnum::SHAREABLE => new ShareableDirective(), ]; } diff --git a/src/Directives/ExternalDirective.php b/src/Directives/ExternalDirective.php index afd7181..4b15f6a 100644 --- a/src/Directives/ExternalDirective.php +++ b/src/Directives/ExternalDirective.php @@ -2,23 +2,24 @@ namespace Apollo\Federation\Directives; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\Directive; -use GraphQL\Type\Definition\FieldArgument; +use Apollo\Federation\Enum\DirectiveEnum; use GraphQL\Language\DirectiveLocation; +use GraphQL\Type\Definition\Directive; /** * The `@external` directive is used to mark a field as owned by another service. This * allows service A to use fields from service B while also knowing at runtime the * types of that field. + * + * @see https://www.apollographql.com/docs/federation/federated-types/federated-directives/#external */ class ExternalDirective extends Directive { public function __construct() { parent::__construct([ - 'name' => 'external', - 'locations' => [DirectiveLocation::FIELD_DEFINITION] + 'name' => DirectiveEnum::EXTERNAL, + 'locations' => [DirectiveLocation::FIELD_DEFINITION], ]); } } diff --git a/src/Directives/InaccessibleDirective.php b/src/Directives/InaccessibleDirective.php new file mode 100644 index 0000000..aa43dbc --- /dev/null +++ b/src/Directives/InaccessibleDirective.php @@ -0,0 +1,31 @@ + DirectiveEnum::INACCESSIBLE, + 'locations' => [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::IFACE, + DirectiveLocation::OBJECT, + DirectiveLocation::UNION, + ], + ]); + } +} diff --git a/src/Directives/KeyDirective.php b/src/Directives/KeyDirective.php index 72469aa..c74f814 100644 --- a/src/Directives/KeyDirective.php +++ b/src/Directives/KeyDirective.php @@ -2,28 +2,38 @@ namespace Apollo\Federation\Directives; -use GraphQL\Type\Definition\Type; +use Apollo\Federation\Enum\DirectiveEnum; +use GraphQL\Language\DirectiveLocation; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Language\DirectiveLocation; +use GraphQL\Type\Definition\Type; /** * The `@key` directive is used to indicate a combination of fields that can be used to uniquely * identify and fetch an object or interface. + * + * @see https://www.apollographql.com/docs/federation/federated-types/federated-directives/#key */ class KeyDirective extends Directive { + public const ARGUMENT_FIELDS = 'fields'; + public const ARGUMENT_RESOLVABLE = 'resolvable'; + public function __construct() { parent::__construct([ - 'name' => 'key', + 'name' => DirectiveEnum::KEY, 'locations' => [DirectiveLocation::OBJECT, DirectiveLocation::IFACE], 'args' => [ new FieldArgument([ - 'name' => 'fields', - 'type' => Type::nonNull(Type::string()) - ]) - ] + 'name' => self::ARGUMENT_FIELDS, + 'type' => Type::nonNull(Type::string()), + ]), + new FieldArgument([ + 'name' => self::ARGUMENT_RESOLVABLE, + 'type' => Type::boolean(), + ]), + ], ]); } } diff --git a/src/Directives/LinkDirective.php b/src/Directives/LinkDirective.php new file mode 100644 index 0000000..93d2c4a --- /dev/null +++ b/src/Directives/LinkDirective.php @@ -0,0 +1,85 @@ + 'link_Import', + 'serialize' => static function ($value) { + $data = json_encode($value); + if (json_last_error()) { + throw new \RuntimeException(json_last_error_msg()); + } + + return $data; + }, + 'parseValue' => static function ($value) { + if (\is_string($value)) { + return $value; + } + + if (!\is_array($value)) { + throw new InvariantViolation('"link_Import" must be a string or an array'); + } + $permittedKeys = ['name', 'as']; + $keys = array_keys($value); + if ($permittedKeys !== array_intersect($permittedKeys, $keys) || array_diff($keys, $permittedKeys)) { + throw new InvariantViolation('"link_Import" must contain only keys "name" and "as" and they are required'); + } + + if (2 !== \count(array_filter($value))) { + throw new InvariantViolation('The "name" and "as" part of "link_Import" be not empty'); + } + + $prefixes = array_unique([$value['name'][0], $value['as'][0]]); + if (\in_array('@', $prefixes, true) && 2 !== \count($prefixes)) { + // https://specs.apollo.dev/link/v1.0/#Import + throw new InvariantViolation('The "name" and "as" part of "link_Import" be of the same type'); + } + + return $value; + }, + ]); + + parent::__construct([ + 'name' => DirectiveEnum::LINK, + 'locations' => [DirectiveLocation::SCHEMA], + 'args' => [ + new FieldArgument([ + 'name' => 'url', + 'type' => Type::nonNull(Type::string()), + ]), + new FieldArgument([ + 'name' => 'as', + 'type' => Type::string(), + ]), + new FieldArgument([ + 'name' => 'for', + // TODO use union type (enum & string) and declare required enum + 'type' => Type::string(), + ]), + new FieldArgument([ + 'name' => 'import', + 'type' => Type::listOf(Type::nonNull($linkImport)), + ]), + ], + 'isRepeatable' => true, + ]); + } +} diff --git a/src/Directives/OverrideDirective.php b/src/Directives/OverrideDirective.php new file mode 100644 index 0000000..61e62e3 --- /dev/null +++ b/src/Directives/OverrideDirective.php @@ -0,0 +1,34 @@ + DirectiveEnum::OVERRIDE, + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => [ + new FieldArgument([ + 'name' => 'from', + 'type' => Type::nonNull(Type::string()), + ]), + ], + ]); + } +} diff --git a/src/Directives/ProvidesDirective.php b/src/Directives/ProvidesDirective.php index f33b603..63d70c3 100644 --- a/src/Directives/ProvidesDirective.php +++ b/src/Directives/ProvidesDirective.php @@ -2,28 +2,31 @@ namespace Apollo\Federation\Directives; -use GraphQL\Type\Definition\Type; +use Apollo\Federation\Enum\DirectiveEnum; +use GraphQL\Language\DirectiveLocation; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Language\DirectiveLocation; +use GraphQL\Type\Definition\Type; /** * The `@provides` directive is used to annotate the expected returned fieldset from a field * on a base type that is guaranteed to be selectable by the gateway. + * + * @see https://www.apollographql.com/docs/federation/federated-types/federated-directives/#provides */ class ProvidesDirective extends Directive { public function __construct() { parent::__construct([ - 'name' => 'provides', + 'name' => DirectiveEnum::PROVIDES, 'locations' => [DirectiveLocation::FIELD_DEFINITION], 'args' => [ new FieldArgument([ 'name' => 'fields', - 'type' => Type::nonNull(Type::string()) - ]) - ] + 'type' => Type::nonNull(Type::string()), + ]), + ], ]); } } diff --git a/src/Directives/RequiresDirective.php b/src/Directives/RequiresDirective.php index 6583407..42853ad 100644 --- a/src/Directives/RequiresDirective.php +++ b/src/Directives/RequiresDirective.php @@ -2,29 +2,32 @@ namespace Apollo\Federation\Directives; -use GraphQL\Type\Definition\Type; +use Apollo\Federation\Enum\DirectiveEnum; +use GraphQL\Language\DirectiveLocation; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Language\DirectiveLocation; +use GraphQL\Type\Definition\Type; /** * The `@requires` directive is used to annotate the required input fieldset from a base type * for a resolver. It is used to develop a query plan where the required fields may not be * needed by the client, but the service may need additional information from other services. + * + * @see https://www.apollographql.com/docs/federation/federated-types/federated-directives/#requires */ class RequiresDirective extends Directive { public function __construct() { parent::__construct([ - 'name' => 'requires', + 'name' => DirectiveEnum::REQUIRES, 'locations' => [DirectiveLocation::FIELD_DEFINITION], 'args' => [ new FieldArgument([ 'name' => 'fields', - 'type' => Type::nonNull(Type::string()) - ]) - ] + 'type' => Type::nonNull(Type::string()), + ]), + ], ]); } } diff --git a/src/Directives/ShareableDirective.php b/src/Directives/ShareableDirective.php new file mode 100644 index 0000000..fd0d524 --- /dev/null +++ b/src/Directives/ShareableDirective.php @@ -0,0 +1,26 @@ + DirectiveEnum::SHAREABLE, + 'locations' => [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::OBJECT], + ]); + } +} diff --git a/src/Enum/DirectiveEnum.php b/src/Enum/DirectiveEnum.php new file mode 100644 index 0000000..30d466a --- /dev/null +++ b/src/Enum/DirectiveEnum.php @@ -0,0 +1,39 @@ +getConstants(); + } + + return static::$constants; + } + + protected function __construct() + { + // forbid creation of an object + } +} diff --git a/src/FederatedSchema.php b/src/FederatedSchema.php index af3682b..d0ae2b3 100644 --- a/src/FederatedSchema.php +++ b/src/FederatedSchema.php @@ -5,19 +5,9 @@ namespace Apollo\Federation; use GraphQL\Type\Schema; -use GraphQL\Type\Definition\CustomScalarType; -use GraphQL\Type\Definition\Directive; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\UnionType; -use GraphQL\Type\Definition\Type; -use GraphQL\Utils\TypeInfo; -use GraphQL\Utils\Utils; - -use Apollo\Federation\Types\EntityObjectType; -use Apollo\Federation\Utils\FederatedSchemaPrinter; /** - * A federated GraphQL schema definition (see [related docs](https://www.apollographql.com/docs/apollo-server/federation/introduction)) + * A federated GraphQL schema definition see related docs {@see https://www.apollographql.com/docs/apollo-server/federation/introduction }. * * A federated schema defines a self-contained GraphQL service that can be merged with * other services by the [Apollo Gateway](https://www.apollographql.com/docs/intro/platform/#gateway) @@ -27,7 +17,7 @@ * directives to hint the gateway on how entity types and references should be resolved. * * Usage example: - * + * * $userType = new Apollo\Federation\Types\EntityObjectType([ * 'name' => 'User', * 'fields' => [ @@ -36,7 +26,7 @@ * 'firstName' => [...], * 'lastName' => [...], * ], - * 'keyFields' => ['id', 'email'] + * 'keys' => [['fields' => 'id'], ['fields' => 'email']] * ]); * * $queryType = new GraphQL\Type\Definition\ObjectType([ @@ -52,178 +42,39 @@ * $schema = new Apollo\Federation\FederatedSchema([ * 'query' => $queryType * ]); + * */ class FederatedSchema extends Schema { - /** @var EntityObjectType[] */ - protected $entityTypes; - - /** @var Directive[] */ - protected $entityDirectives; - - public function __construct($config) - { - $this->entityTypes = $this->extractEntityTypes($config); - $this->entityDirectives = array_merge(Directives::getDirectives(), Directive::getInternalDirectives()); - - $config = array_merge($config, $this->getEntityDirectivesConfig($config), $this->getQueryTypeConfig($config)); - - parent::__construct($config); - } - - /** - * Returns all the resolved entity types in the schema - * - * @return EntityObjectType[] - */ - public function getEntityTypes(): array - { - return $this->entityTypes; - } - - /** - * Indicates whether the schema has entity types resolved - * - * @return bool - */ - public function hasEntityTypes(): bool - { - return !empty($this->getEntityTypes()); - } - - /** - * @return Directive[] - */ - private function getEntityDirectivesConfig(array $config): array - { - $directives = isset($config['directives']) ? $config['directives'] : []; - $config['directives'] = array_merge($directives, $this->entityDirectives); - - return $config; - } - - /** @var array */ - private function getQueryTypeConfig(array $config): array - { - $queryTypeConfig = $config['query']->config; - if (is_callable($queryTypeConfig['fields'])) { - $queryTypeConfig['fields'] = $queryTypeConfig['fields'](); - } + use FederatedSchemaTrait; - $queryTypeConfig['fields'] = array_merge( - $queryTypeConfig['fields'], - $this->getQueryTypeServiceFieldConfig(), - $this->getQueryTypeEntitiesFieldConfig($config) - ); + public const RESERVED_TYPE_ANY = '_Any'; + public const RESERVED_TYPE_ENTITY = '_Entity'; + public const RESERVED_TYPE_SERVICE = '_Service'; + public const RESERVED_TYPE_MUTATION = 'Mutation'; + public const RESERVED_TYPE_QUERY = 'Query'; - return [ - 'query' => new ObjectType($queryTypeConfig) - ]; - } - - /** @var array */ - private function getQueryTypeServiceFieldConfig(): array - { - $serviceType = new ObjectType([ - 'name' => '_Service', - 'fields' => [ - 'sdl' => [ - 'type' => Type::string(), - 'resolve' => function () { - return FederatedSchemaPrinter::doPrint($this); - } - ] - ] - ]); - - return [ - '_service' => [ - 'type' => Type::nonNull($serviceType), - 'resolve' => function () { - return []; - } - ] - ]; - } + public const RESERVED_FIELD_ENTITIES = '_entities'; + public const RESERVED_FIELD_REPRESENTATIONS = 'representations'; + public const RESERVED_FIELD_SDL = 'sdl'; + public const RESERVED_FIELD_SERVICE = '_service'; + public const RESERVED_FIELD_TYPE_NAME = '__typename'; - /** @var array */ - private function getQueryTypeEntitiesFieldConfig(?array $config): array + public static function isReservedRootType(string $name): bool { - if (!$this->hasEntityTypes()) { - return []; - } - - $entityType = new UnionType([ - 'name' => '_Entity', - 'types' => array_values($this->getEntityTypes()) - ]); - - $anyType = new CustomScalarType([ - 'name' => '_Any', - 'serialize' => function ($value) { - return $value; - } - ]); - - return [ - '_entities' => [ - 'type' => Type::listOf($entityType), - 'args' => [ - 'representations' => [ - 'type' => Type::nonNull(Type::listOf(Type::nonNull($anyType))) - ] - ], - 'resolve' => function ($root, $args, $context, $info) use ($config) { - if (isset($config) && isset($config['resolve']) && is_callable($config['resolve'])) { - return $config['resolve']($root, $args, $context, $info);; - } else { - return $this->resolve($root, $args, $context, $info); - } - } - ] - ]; + return \in_array($name, [self::RESERVED_TYPE_QUERY, self::RESERVED_TYPE_MUTATION], true); } - private function resolve($root, $args, $context, $info) - { - return array_map(function ($ref) use ($context, $info) { - Utils::invariant(isset($ref['__typename']), 'Type name must be provided in the reference.'); - - $typeName = $ref['__typename']; - $type = $info->schema->getType($typeName); - - Utils::invariant( - $type && $type instanceof EntityObjectType, - sprintf( - 'The _entities resolver tried to load an entity for type "%s", but no object type of that name was found in the schema', - $type->name - ) - ); - - if (!$type->hasReferenceResolver()) { - return $ref; - } - - $r = $type->resolveReference($ref, $context, $info); - return $r; - }, $args['representations']); - } /** - * @param array $config - * - * @return EntityObjectType[] + * @param array $config */ - private function extractEntityTypes(array $config): array + public function __construct(array $config) { - $resolvedTypes = TypeInfo::extractTypes($config['query']); - $entityTypes = []; + $this->entityTypes = $this->extractEntityTypes($config); + $this->schemaExtensionTypes = $this->extractSchemaExtensionTypes($config); - foreach ($resolvedTypes as $type) { - if ($type instanceof EntityObjectType) { - $entityTypes[$type->name] = $type; - } - } + $config = array_merge($config, $this->getQueryTypeConfig($config)); - return $entityTypes; + parent::__construct($config); } } diff --git a/src/FederatedSchemaTrait.php b/src/FederatedSchemaTrait.php new file mode 100644 index 0000000..b41ce64 --- /dev/null +++ b/src/FederatedSchemaTrait.php @@ -0,0 +1,227 @@ +entityTypes; + } + + /** + * Indicates whether the schema has entity types resolved. + */ + public function hasEntityTypes(): bool + { + return !empty($this->entityTypes); + } + + /** + * @return SchemaExtensionType[] + */ + public function getSchemaExtensionTypes(): array + { + return $this->schemaExtensionTypes; + } + + /** + * @param array{ query: ObjectType } $config + * + * @return array{ query: ObjectType } + */ + protected function getQueryTypeConfig(array $config): array + { + $queryTypeConfig = $config['query']->config; + $fields = $queryTypeConfig['fields']; + $queryTypeConfig['fields'] = function () use ($config, $fields) { + if (\is_callable($fields)) { + $fields = $fields(); + } + + return array_merge( + $fields, + $this->getQueryTypeServiceFieldConfig(), + $this->getQueryTypeEntitiesFieldConfig($config) + ); + }; + + return [ + 'query' => new ObjectType($queryTypeConfig), + ]; + } + + /** + * @return array{ _service: array } + */ + protected function getQueryTypeServiceFieldConfig(): array + { + $serviceType = new ObjectType([ + 'name' => FederatedSchema::RESERVED_TYPE_SERVICE, + 'fields' => [ + FederatedSchema::RESERVED_FIELD_SDL => [ + 'type' => Type::string(), + 'resolve' => function (): string { + return FederatedSchemaPrinter::doPrint($this); + }, + ], + ], + ]); + + return [ + FederatedSchema::RESERVED_FIELD_SERVICE => [ + 'type' => Type::nonNull($serviceType), + 'resolve' => static function (): array { + return []; + }, + ], + ]; + } + + /** + * @param array|null $config + * + * @return array>, resolve: callable }> + */ + protected function getQueryTypeEntitiesFieldConfig(?array $config): array + { + if (!$this->entityTypes) { + return []; + } + + $entityType = new UnionType([ + 'name' => FederatedSchema::RESERVED_TYPE_ENTITY, + 'types' => array_values($this->getEntityTypes()), + ]); + + $anyType = new CustomScalarType([ + 'name' => FederatedSchema::RESERVED_TYPE_ANY, + 'serialize' => static function ($value) { + return $value; + }, + ]); + + return [ + FederatedSchema::RESERVED_FIELD_ENTITIES => [ + 'type' => Type::listOf($entityType), + 'args' => [ + FederatedSchema::RESERVED_FIELD_REPRESENTATIONS => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull($anyType))), + ], + ], + 'resolve' => function ($root, $args, $context, $info) use ($config) { + if ($config && isset($config['resolve']) && \is_callable($config['resolve'])) { + return $config['resolve']($root, $args, $context, $info); + } + + return $this->resolve($root, $args, $context, $info); + }, + ], + ]; + } + + protected function resolve($root, $args, $context, $info): array + { + return array_map(static function ($ref) use ($context, $info) { + Utils::invariant(isset($ref[FederatedSchema::RESERVED_FIELD_TYPE_NAME]), 'Type name must be provided in the reference.'); + + $typeName = $ref[FederatedSchema::RESERVED_FIELD_TYPE_NAME]; + $type = $info->schema->getType($typeName); + + Utils::invariant( + $type instanceof EntityObjectType, + 'The _entities resolver tried to load an entity for type "%s", but no object type of that name was found in the schema', + $type->name + ); + + /** @var EntityObjectType $type */ + if (!$type->hasReferenceResolver()) { + return $ref; + } + + return $type->resolveReference($ref, $context, $info); + }, $args[FederatedSchema::RESERVED_FIELD_REPRESENTATIONS]); + } + + /** + * @param array $config + * + * @return array + */ + protected function extractExtraTypes(array $config): array + { + $typeMap = []; + $configTypes = $config['types'] ?? []; + if (\is_array($configTypes)) { + $typeMap = $configTypes; + } elseif (\is_callable($configTypes)) { + $typeMap = $configTypes(); + } + + return $typeMap; + } + + /** + * @param array $config + * + * @return EntityObjectType[] + */ + protected function extractEntityTypes(array $config): array + { + $resolvedTypes = TypeInfo::extractTypes($config['query']); + $entityTypes = []; + + foreach ($resolvedTypes as $type) { + if ($type instanceof EntityObjectType) { + $entityTypes[$type->name] = $type; + } + } + + return $entityTypes; + } + + /** + * @param array $config + * + * @return SchemaExtensionType[] + */ + protected function extractSchemaExtensionTypes(array $config): array + { + $types = []; + foreach ($this->extractExtraTypes($config) as $type) { + if ($type instanceof SchemaExtensionType) { + $types[$type->name] = $type; + } + } + + return $types; + } +} diff --git a/src/SchemaBuilder.php b/src/SchemaBuilder.php new file mode 100644 index 0000000..cda327a --- /dev/null +++ b/src/SchemaBuilder.php @@ -0,0 +1,39 @@ + $schemaConfig + * @param array $builderConfig + */ + public function build(array $schemaConfig, array $builderConfig = []): FederatedSchema + { + $builderConfig += ['directives' => ['link']]; + $schemaConfig = array_merge($schemaConfig, $this->getEntityDirectivesConfig($schemaConfig, $builderConfig)); + + return new FederatedSchema($schemaConfig); + } + + /** + * @param array $schemaConfig + * @param array{ directives: array } $builderConfig + * + * @return array + */ + protected function getEntityDirectivesConfig(array $schemaConfig, array $builderConfig): array + { + $directives = array_intersect_key(Directives::getDirectives(), array_flip($builderConfig['directives'])); + if (array_intersect_key($directives, Directive::getInternalDirectives())) { + throw new \LogicException('Some Apollo directives override internals.'); + } + $schemaConfig['directives'] = array_merge($schemaConfig['directives'] ?? [], $directives); + + return $schemaConfig; + } +} diff --git a/src/Types/EntityObjectType.php b/src/Types/EntityObjectType.php index f2e70ad..f675f25 100644 --- a/src/Types/EntityObjectType.php +++ b/src/Types/EntityObjectType.php @@ -4,34 +4,34 @@ namespace Apollo\Federation\Types; -use GraphQL\Utils\Utils; +use Apollo\Federation\FederatedSchema; use GraphQL\Type\Definition\ObjectType; - -use array_key_exists; +use GraphQL\Utils\Utils; /** * An entity is a type that can be referenced by another service. Entities create * connection points between services and form the basic building blocks of a federated * graph. Entities have a primary key whose value uniquely identifies a specific instance * of the type, similar to the function of a primary key in a SQL table - * (see [related docs](https://www.apollographql.com/docs/apollo-server/federation/core-concepts/#entities-and-keys)). + * see related docs {@see https://www.apollographql.com/docs/apollo-server/federation/core-concepts/#entities-and-keys }. * - * The `keyFields` property is required in the configuration, indicating the fields that + * The `keys` property is required in the configuration, indicating the fields that * serve as the unique keys or identifiers of the entity. * * Sample usage: - * + * * $userType = new Apollo\Federation\Types\EntityObjectType([ * 'name' => 'User', - * 'keyFields' => ['id', 'email'], + * 'keys' => [['fields' => 'id'], ['fields' => 'email']], * 'fields' => [...] * ]); + * * * Entity types can also set attributes to its fields to hint the gateway on how to resolve them. - * + * * $userType = new Apollo\Federation\Types\EntityObjectType([ * 'name' => 'User', - * 'keyFields' => ['id', 'email'], + * 'keys' => [['fields' => 'id', 'resolvable': false ]], * 'fields' => [ * 'id' => [ * 'type' => Types::int(), @@ -39,26 +39,42 @@ * ] * ] * ]); - * + * */ class EntityObjectType extends ObjectType { - /** @var array */ - private $keyFields; + public const FIELD_KEYS = 'keys'; + public const FIELD_REFERENCE_RESOLVER = '__resolveReference'; - /** @var callable */ - public $referenceResolver; + public const FIELD_DIRECTIVE_IS_EXTERNAL = 'isExternal'; + public const FIELD_DIRECTIVE_PROVIDES = 'provides'; + public const FIELD_DIRECTIVE_REQUIRES = 'requires'; + + /** @var callable|null */ + public $referenceResolver = null; + + /** + * @var array|array>, resolvable: bool }> + */ + private $keys; /** - * @param mixed[] $config + * @param array $config */ public function __construct(array $config) { - $this->keyFields = $config['keyFields']; + Utils::invariant( + !(\array_key_exists(self::FIELD_KEYS, $config) && \array_key_exists('keyFields', $config)), + 'Use only one way to define directives @key.' + ); + + $this->keys = $config[self::FIELD_KEYS] ?? array_map(static function ($x): array { + return ['fields' => $x]; + }, $config['keyFields']); - if (isset($config['__resolveReference'])) { + if (isset($config[self::FIELD_REFERENCE_RESOLVER])) { self::validateResolveReference($config); - $this->referenceResolver = $config['__resolveReference']; + $this->referenceResolver = $config[self::FIELD_REFERENCE_RESOLVER]; } parent::__construct($config); @@ -67,17 +83,38 @@ public function __construct(array $config) /** * Gets the fields that serve as the unique key or identifier of the entity. * - * @return array + * @deprecated Use {@see getKeys()} + * + * @return array|array> + * + * @codeCoverageIgnore */ public function getKeyFields(): array { - return $this->keyFields; + @trigger_error( + 'Since skillshare/apollo-federation-php 2.0.0: ' + . 'Method \Apollo\Federation\Types\EntityObjectType::getKeyFields() is deprecated. ' + . 'Use \Apollo\Federation\Types\EntityObjectType::getKeys() instead of it.', + \E_USER_DEPRECATED + ); + + return array_merge(...array_map(static function (array $x): array { + return (array) $x['fields']; + }, $this->keys)); } /** - * Gets whether this entity has a resolver set + * Gets the fields that serve as the unique key or identifier of the entity. * - * @return bool + * @return array|array>, resolvable: bool }> + */ + public function getKeys(): array + { + return $this->keys; + } + + /** + * Gets whether this entity has a resolver set. */ public function hasReferenceResolver(): bool { @@ -85,34 +122,44 @@ public function hasReferenceResolver(): bool } /** - * Resolves an entity from a reference + * Resolves an entity from a reference. + * + * @param mixed|null $ref + * @param mixed|null $context + * @param mixed|null $info * - * @param mixed $ref - * @param mixed $context - * @param mixed $info + * @retrun mixed|null */ public function resolveReference($ref, $context = null, $info = null) { $this->validateReferenceResolver(); $this->validateReferenceKeys($ref); - $entity = ($this->referenceResolver)($ref, $context, $info); - - return $entity; + return ($this->referenceResolver)($ref, $context, $info); } - private function validateReferenceResolver() + private function validateReferenceResolver(): void { - Utils::invariant(isset($this->referenceResolver), 'No reference resolver was set in the configuration.'); + Utils::invariant(\is_callable($this->referenceResolver), 'Invalid reference resolver configuration.'); } - private function validateReferenceKeys($ref) + /** + * @param array{ __typename: mixed } $ref + */ + private function validateReferenceKeys(array $ref): void { - Utils::invariant(isset($ref['__typename']), 'Type name must be provided in the reference.'); + Utils::invariant( + isset($ref[FederatedSchema::RESERVED_FIELD_TYPE_NAME]) + && $ref[FederatedSchema::RESERVED_FIELD_TYPE_NAME] === $this->config['name'], + 'Type name must be provided in the reference.' + ); } - public static function validateResolveReference(array $config) + /** + * @param array{ __resolveReference: mixed } $config + */ + public static function validateResolveReference(array $config): void { - Utils::invariant(is_callable($config['__resolveReference']), 'Reference resolver has to be callable.'); + Utils::invariant(\is_callable($config[self::FIELD_REFERENCE_RESOLVER]), 'Reference resolver has to be callable.'); } } diff --git a/src/Types/EntityRefObjectType.php b/src/Types/EntityRefObjectType.php index a798ec4..98dcf0f 100644 --- a/src/Types/EntityRefObjectType.php +++ b/src/Types/EntityRefObjectType.php @@ -4,22 +4,36 @@ namespace Apollo\Federation\Types; +use GraphQL\Utils\Utils; + /** * An entity reference is a type referencing an entity owned by another service. Usually, * entity references are stub types containing only the key fields necessary for the - * [Apollo Gateway](https://www.apollographql.com/docs/intro/platform/#gateway) to + * Apollo Gateway {@see https://www.apollographql.com/docs/intro/platform/#gateway } to * resolve the entity during query execution. + * + * @see https://www.apollographql.com/docs/federation/v1/entities#referencing-entities + * @see https://www.apollographql.com/docs/federation/entities#referencing-an-entity-without-contributing-fields */ class EntityRefObjectType extends EntityObjectType { - /** @var array */ - private $keyFields; - - /** - * @param mixed[] $config - */ public function __construct(array $config) { parent::__construct($config); + + $keys = $this->getKeys(); + Utils::invariant( + 1 === \count($keys), + 'There is invalid config of %s. Referenced entity must have exactly one directive @key.', + $this->name + ); + + /** @var array $key */ + $key = reset($keys); + Utils::invariant( + \array_key_exists('resolvable', $key) && false === $key['resolvable'], + 'There is invalid config of %s. Referenced entity directive @key must have argument "resolvable" with value `false`.', + $this->name + ); } } diff --git a/src/Types/SchemaExtensionType.php b/src/Types/SchemaExtensionType.php new file mode 100644 index 0000000..b19843a --- /dev/null +++ b/src/Types/SchemaExtensionType.php @@ -0,0 +1,23 @@ + $config + */ + public function __construct(array $config) + { + $this->config = $config; + } +} diff --git a/src/Utils/FederatedSchemaPrinter.php b/src/Utils/FederatedSchemaPrinter.php index 8eab202..281584d 100644 --- a/src/Utils/FederatedSchemaPrinter.php +++ b/src/Utils/FederatedSchemaPrinter.php @@ -1,7 +1,6 @@ $options * * @api */ public static function doPrint(Schema $schema, array $options = []): string { - return self::printFilteredSchema( + return static::printFilteredSchema( $schema, - static function ($type) { - return !Directive::isSpecifiedDirective($type) && !self::isFederatedDirective($type); + static function (Directive $type): bool { + return !Directive::isSpecifiedDirective($type) && !static::isFederatedDirective($type); }, - static function ($type) { + static function (Type $type): bool { return !Type::isBuiltInType($type); }, $options ); } - public static function isFederatedDirective($type): bool + public static function isFederatedDirective(Directive $type): bool { - return in_array($type->name, ['key', 'provides', 'requires', 'external']); + return \in_array($type->name, DirectiveEnum::getAll(), true); } /** - * @param bool[] $options - */ - private static function printFilteredSchema(Schema $schema, $directiveFilter, $typeFilter, $options): string - { - $directives = array_filter($schema->getDirectives(), static function ($directive) use ($directiveFilter) { - return $directiveFilter($directive); - }); - - $types = $schema->getTypeMap(); - ksort($types); - $types = array_filter($types, $typeFilter); - - return sprintf( - "%s\n", - implode( - "\n\n", - array_filter( - array_merge( - array_map(static function ($directive) use ($options) { - return self::printDirective($directive, $options); - }, $directives), - array_map(static function ($type) use ($options) { - return self::printType($type, $options); - }, $types) - ) - ) - ) - ); - } - - private static function printDirective($directive, $options): string - { - return self::printDescription($options, $directive) . - 'directive @' . - $directive->name . - self::printArgs($options, $directive->args) . - ' on ' . - implode(' | ', $directive->locations); - } - - private static function printDescription($options, $def, $indentation = '', $firstInBlock = true): string - { - if (!$def->description) { - return ''; - } - - $lines = self::descriptionLines($def->description, 120 - strlen($indentation)); - - if (isset($options['commentDescriptions'])) { - return self::printDescriptionWithComments($lines, $indentation, $firstInBlock); - } - - $description = $indentation && !$firstInBlock ? "\n" . $indentation . '"""' : $indentation . '"""'; - - // In some circumstances, a single line can be used for the description. - if (count($lines) === 1 && mb_strlen($lines[0]) < 70 && substr($lines[0], -1) !== '"') { - return $description . self::escapeQuote($lines[0]) . "\"\"\"\n"; - } - - // Format a multi-line block quote to account for leading space. - $hasLeadingSpace = isset($lines[0]) && (substr($lines[0], 0, 1) === ' ' || substr($lines[0], 0, 1) === '\t'); - - if (!$hasLeadingSpace) { - $description .= "\n"; - } - - $lineLength = count($lines); - - for ($i = 0; $i < $lineLength; $i++) { - if ($i !== 0 || !$hasLeadingSpace) { - $description .= $indentation; - } - $description .= self::escapeQuote($lines[$i]) . "\n"; - } - - $description .= $indentation . "\"\"\"\n"; - - return $description; - } - - /** - * @return string[] - */ - private static function descriptionLines(string $description, int $maxLen): array - { - $lines = []; - $rawLines = explode("\n", $description); - - foreach ($rawLines as $line) { - if ($line === '') { - $lines[] = $line; - } else { - // For > 120 character long lines, cut at space boundaries into sublines - // of ~80 chars. - $sublines = self::breakLine($line, $maxLen); - - foreach ($sublines as $subline) { - $lines[] = $subline; - } - } - } - - return $lines; - } - - /** - * @return string[] - */ - private static function breakLine(string $line, int $maxLen): array - { - if (strlen($line) < $maxLen + 5) { - return [$line]; - } - - preg_match_all('/((?: |^).{15,' . ($maxLen - 40) . '}(?= |$))/', $line, $parts); - - $parts = $parts[0]; - - return array_map('trim', $parts); - } - - private static function printDescriptionWithComments($lines, $indentation, $firstInBlock): string - { - $description = $indentation && !$firstInBlock ? "\n" : ''; - - foreach ($lines as $line) { - if ($line === '') { - $description .= $indentation . "#\n"; - } else { - $description .= $indentation . '# ' . $line . "\n"; - } - } - - return $description; - } - - private static function escapeQuote($line): string - { - return str_replace('"""', '\\"""', $line); - } - - private static function printArgs($options, $args, $indentation = ''): string - { - if (!$args) { - return ''; - } - - // If every arg does not have a description, print them on one line. - if ( - Utils::every($args, static function ($arg) { - return empty($arg->description); - }) - ) { - return '(' . implode(', ', array_map('self::printInputValue', $args)) . ')'; - } - - return sprintf( - "(\n%s\n%s)", - implode( - "\n", - array_map( - static function ($arg, $i) use ($indentation, $options) { - return self::printDescription($options, $arg, ' ' . $indentation, !$i) . - ' ' . - $indentation . - self::printInputValue($arg); - }, - $args, - array_keys($args) - ) - ), - $indentation - ); - } - - private static function printInputValue($arg): string - { - $argDecl = $arg->name . ': ' . (string) $arg->getType(); - - if ($arg->defaultValueExists()) { - $argDecl .= ' = ' . Printer::doPrint(AST::astFromValue($arg->defaultValue, $arg->getType())); - } - - return $argDecl; - } - - /** - * @param bool[] $options + * @param array $options */ public static function printType(Type $type, array $options = []): string { - if ($type instanceof ScalarType) { - if ($type->name !== '_Any') { - return self::printScalar($type, $options); - } else { - return ''; - } - } - - if ($type instanceof EntityObjectType || $type instanceof EntityRefObjectType) { - return self::printEntityObject($type, $options); - } - - if ($type instanceof ObjectType) { - if ($type->name !== '_Service') { - return self::printObject($type, $options); - } else { - return ''; - } - } - - if ($type instanceof InterfaceType) { - return self::printInterface($type, $options); + if ($type instanceof EntityObjectType /* || $type instanceof EntityRefObjectType */) { + return static::printEntityObject($type, $options); } - if ($type instanceof UnionType) { - if ($type->name !== '_Entity') { - return self::printUnion($type, $options); - } else { - return ''; - } - } - - if ($type instanceof EnumType) { - return self::printEnum($type, $options); - } - - if ($type instanceof InputObjectType) { - return self::printInputObject($type, $options); + if (($type instanceof ScalarType && FederatedSchema::RESERVED_TYPE_ANY === $type->name) + || ($type instanceof ObjectType && FederatedSchema::RESERVED_TYPE_SERVICE === $type->name) + || ($type instanceof UnionType && FederatedSchema::RESERVED_TYPE_ENTITY === $type->name) + || ($type instanceof SchemaExtensionType)) { + return ''; } - throw new Error(sprintf('Unknown type: %s.', Utils::printSafe($type))); + return parent::printType($type, $options); } /** - * @param bool[] $options + * @param array $options */ - private static function printScalar(ScalarType $type, array $options): string + protected static function printEntityObject(EntityObjectType $type, array $options): string { - return sprintf('%sscalar %s', self::printDescription($options, $type), $type->name); - } + $implementedInterfaces = static::printImplementedInterfaces($type); + $keyDirective = static::printKeyDirective($type); + $extends = $type instanceof EntityRefObjectType ? 'extend ' : ''; - /** - * @param bool[] $options - */ - private static function printObject(ObjectType $type, array $options): string - { - if (empty($type->getFields())) { - return ''; - } - - $interfaces = $type->getInterfaces(); - $implementedInterfaces = !empty($interfaces) - ? ' implements ' . - implode( - ' & ', - array_map(static function ($i) { - return $i->name; - }, $interfaces) - ) - : ''; - - $queryExtends = $type->name === 'Query' || $type->name === 'Mutation' ? 'extend ' : ''; - - return self::printDescription($options, $type) . + return static::printDescription($options, $type) . sprintf( - "%stype %s%s {\n%s\n}", - $queryExtends, + "%stype %s%s%s {\n%s\n}", + $extends, $type->name, $implementedInterfaces, - self::printFields($options, $type) + $keyDirective, + static::printFields($options, $type) ); } - /** - * @param bool[] $options - */ - private static function printEntityObject(EntityObjectType $type, array $options): string + protected static function printFieldFederatedDirectives(FieldDefinition $field): string { - $interfaces = $type->getInterfaces(); - $implementedInterfaces = !empty($interfaces) - ? ' implements ' . - implode( - ' & ', - array_map(static function ($i) { - return $i->name; - }, $interfaces) - ) - : ''; + $directives = []; - $keyDirective = ''; + if (isset($field->config[EntityObjectType::FIELD_DIRECTIVE_IS_EXTERNAL]) + && true === $field->config[EntityObjectType::FIELD_DIRECTIVE_IS_EXTERNAL] + ) { + $directives[] = '@external'; + } - foreach ($type->getKeyFields() as $keyField) { - $keyDirective = $keyDirective . sprintf(' @key(fields: "%s")', $keyField); + if (isset($field->config[EntityObjectType::FIELD_DIRECTIVE_PROVIDES])) { + $directives[] = sprintf('@provides(fields: "%s")', static::printKeyFields($field->config[EntityObjectType::FIELD_DIRECTIVE_PROVIDES])); } - $isEntityRef = $type instanceof EntityRefObjectType; - $extends = $isEntityRef ? 'extend ' : ''; + if (isset($field->config[EntityObjectType::FIELD_DIRECTIVE_REQUIRES])) { + $directives[] = sprintf('@requires(fields: "%s")', static::printKeyFields($field->config[EntityObjectType::FIELD_DIRECTIVE_REQUIRES])); + } - return self::printDescription($options, $type) . - sprintf( - "%stype %s%s%s {\n%s\n}", - $extends, - $type->name, - $implementedInterfaces, - $keyDirective, - self::printFields($options, $type) - ); + return implode(' ', $directives); } /** - * @param bool[] $options + * @param array $options + * @param EntityObjectType|InterfaceType|ObjectType|TypeWithFields $type */ - private static function printFields($options, $type): string + protected static function printFields(array $options, $type): string { $fields = array_values($type->getFields()); - if ($type->name === 'Query') { - $fields = array_filter($fields, function ($field) { - return $field->name !== '_service' && $field->name !== '_entities'; + if (FederatedSchema::RESERVED_TYPE_QUERY === $type->name) { + $fields = array_filter($fields, static function (FieldDefinition $field): bool { + $excludedFields = [FederatedSchema::RESERVED_FIELD_SERVICE, FederatedSchema::RESERVED_FIELD_ENTITIES]; + + return !\in_array($field->name, $excludedFields, true); }); } return implode( "\n", array_map( - static function ($f, $i) use ($options) { - return self::printDescription($options, $f, ' ', !$i) . + static function (FieldDefinition $f, $i) use ($options) { + return static::printDescription($options, $f, ' ', !$i) . ' ' . $f->name . - self::printArgs($options, $f->args, ' ') . + static::printArgs($options, $f->args, ' ') . ': ' . (string) $f->getType() . - self::printDeprecated($f) . + static::printDeprecated($f) . ' ' . - self::printFieldFederatedDirectives($f); + static::printFieldFederatedDirectives($f); }, $fields, array_keys($fields) @@ -444,121 +184,137 @@ static function ($f, $i) use ($options) { ); } - private static function printDeprecated($fieldOrEnumVal): string + protected static function printImplementedInterfaces(ObjectType $type): string { - $reason = $fieldOrEnumVal->deprecationReason; - if (empty($reason)) { - return ''; - } - if ($reason === '' || $reason === Directive::DEFAULT_DEPRECATION_REASON) { - return ' @deprecated'; - } + $interfaces = $type->getInterfaces(); - return ' @deprecated(reason: ' . Printer::doPrint(AST::astFromValue($reason, Type::string())) . ')'; + return !empty($interfaces) + ? ' implements ' . implode(' & ', array_map(static function (InterfaceType $i): string { + return $i->name; + }, $interfaces)) + : ''; } - private static function printFieldFederatedDirectives($field) + protected static function printKeyDirective(EntityObjectType $type): string { - $directives = []; - - if (isset($field->config['isExternal']) && $field->config['isExternal'] === true) { - array_push($directives, '@external'); - } - - if (isset($field->config['provides'])) { - array_push($directives, sprintf('@provides(fields: "%s")', $field->config['provides'])); - } + $keyDirective = ''; - if (isset($field->config['requires'])) { - array_push($directives, sprintf('@requires(fields: "%s")', $field->config['requires'])); + foreach ($type->getKeys() as $keyField) { + $arguments = [sprintf('%s: "%s"', KeyDirective::ARGUMENT_FIELDS, static::printKeyFields($keyField['fields']))]; + if (\array_key_exists('resolvable', $keyField)) { + $arguments[] = sprintf('%s: %s', KeyDirective::ARGUMENT_RESOLVABLE, $keyField['resolvable'] ? 'true' : 'false'); + } + $keyDirective .= sprintf(' @key(%s)', implode(', ', $arguments)); } - return implode(' ', $directives); + return $keyDirective; } /** - * @param bool[] $options + * Print simple and compound primary key fields + * {@see https://www.apollographql.com/docs/federation/v1/entities#compound-primary-keys }. + * + * @param string|array $keyFields */ - private static function printInterface(InterfaceType $type, array $options): string + protected static function printKeyFields($keyFields): string { - return self::printDescription($options, $type) . - sprintf("interface %s {\n%s\n}", $type->name, self::printFields($options, $type)); - } + $parts = []; + foreach (((array) $keyFields) as $index => $keyField) { + if (\is_string($keyField)) { + $parts[] = $keyField; + } elseif (\is_array($keyField)) { + $parts[] = sprintf('%s { %s }', $index, static::printKeyFields($keyField)); + } else { + throw new \InvalidArgumentException('Invalid keyField config'); + } + } - /** - * @param bool[] $options - */ - private static function printUnion(UnionType $type, array $options): string - { - return self::printDescription($options, $type) . - sprintf('union %s = %s', $type->name, implode(' | ', $type->getTypes())); + return implode(' ', $parts); } /** - * @param bool[] $options + * @param array $linkConfig */ - private static function printEnum(EnumType $type, array $options): string + protected static function printLinkDirectiveConfig(array $linkConfig): string { - return self::printDescription($options, $type) . - sprintf("enum %s {\n%s\n}", $type->name, self::printEnumValues($type->getValues(), $options)); + $arguments = []; + foreach ($linkConfig as $name => $value) { + if (null === $value) { + continue; + } + $arguments[] = sprintf('%s: %s', $name, static::printLinkDirectiveArgumentValue($value, $name)); + } + + return sprintf('@link(%s)', implode(', ', $arguments)); } /** - * @param bool[] $options + * @param string|array> $argument */ - private static function printEnumValues($values, $options): string + protected static function printLinkDirectiveArgumentValue($argument, string $name): string { - return implode( - "\n", - array_map( - static function ($value, $i) use ($options) { - return self::printDescription($options, $value, ' ', !$i) . - ' ' . - $value->name . - self::printDeprecated($value); - }, - $values, - array_keys($values) - ) - ); + if (\is_string($argument)) { + return '"' . $argument . '"'; + } + if ('import' !== $name) { + throw new \InvalidArgumentException(sprintf('Value of %s must be a string', $name)); + } + if (!\is_array($argument)) { + throw new \InvalidArgumentException('Invalid type of "import" argument value'); + } + + $data = json_encode($argument); + if (json_last_error()) { + throw new \RuntimeException(json_last_error_msg()); + } + + return $data; } /** - * @param bool[] $options + * @param array $options */ - private static function printInputObject(InputObjectType $type, array $options): string + protected static function printObject(ObjectType $type, array $options): string { - $fields = array_values($type->getFields()); + if (empty($type->getFields())) { + return ''; + } - return self::printDescription($options, $type) . + $implementedInterfaces = static::printImplementedInterfaces($type); + $extends = FederatedSchema::isReservedRootType($type->name) ? 'extend ' : ''; + + return static::printDescription($options, $type) . sprintf( - "input %s {\n%s\n}", + "%stype %s%s {\n%s\n}", + $extends, $type->name, - implode( - "\n", - array_map( - static function ($f, $i) use ($options) { - return self::printDescription($options, $f, ' ', !$i) . ' ' . self::printInputValue($f); - }, - $fields, - array_keys($fields) - ) - ) + $implementedInterfaces, + static::printFields($options, $type) ); } /** - * @param bool[] $options - * - * @api + * @param FederatedSchema $schema */ - public static function printIntrospectionSchema(Schema $schema, array $options = []): string + protected static function printSchemaDefinition(Schema $schema): string { - return self::printFilteredSchema( - $schema, - [Directive::class, 'isSpecifiedDirective'], - [Introspection::class, 'isIntrospectionType'], - $options + $parts = [parent::printSchemaDefinition($schema)]; + foreach ($schema->getSchemaExtensionTypes() as $schemaExtensionType) { + $parts[] = self::printSchemaExtensionType($schemaExtensionType); + } + + return implode("\n\n", array_filter($parts)); + } + + protected static function printSchemaExtensionType(SchemaExtensionType $schemaExtensionType): string + { + $links = $schemaExtensionType->config[SchemaExtensionType::FIELD_KEY_LINKS] ?? []; + + return sprintf( + 'extend schema %s\n', + implode("\n", array_map(static function (array $x): string { + return static::printLinkDirectiveConfig($x); + }, $links)) ); } } diff --git a/test/DirectivesTest.php b/test/DirectivesTest.php index 67c4871..fe51d53 100644 --- a/test/DirectivesTest.php +++ b/test/DirectivesTest.php @@ -4,71 +4,105 @@ namespace Apollo\Federation\Tests; -use PHPUnit\Framework\TestCase; -use Spatie\Snapshots\MatchesSnapshots; - -use GraphQL\Type\Schema; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\ObjectType; +use Apollo\Federation\Directives; use GraphQL\Language\DirectiveLocation; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; +use GraphQL\Type\Schema; use GraphQL\Utils\SchemaPrinter; +use PHPUnit\Framework\TestCase; +use Spatie\Snapshots\MatchesSnapshots; -use Apollo\Federation\Directives; - -class DirectivesTest extends TestCase +final class DirectivesTest extends TestCase { use MatchesSnapshots; - public function testKeyDirective() + public function testKeyDirective(): void { $config = Directives::key()->config; $expectedLocations = [DirectiveLocation::OBJECT, DirectiveLocation::IFACE]; - $this->assertEquals($config['name'], 'key'); - $this->assertEqualsCanonicalizing($config['locations'], $expectedLocations); + $this->assertEquals('key', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); } - public function testExternalDirective() + public function testExternalDirective(): void { $config = Directives::external()->config; $expectedLocations = [DirectiveLocation::FIELD_DEFINITION]; - $this->assertEquals($config['name'], 'external'); - $this->assertEqualsCanonicalizing($config['locations'], $expectedLocations); + $this->assertEquals('external', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); } - public function testRequiresDirective() + public function testInaccessibleDirective(): void + { + $config = Directives::inaccessible()->config; + + $expectedLocations = [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::IFACE, + DirectiveLocation::OBJECT, + DirectiveLocation::UNION, + ]; + + $this->assertEquals('inaccessible', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); + } + + public function testLinkDirective(): void + { + $config = Directives::link()->config; + + $expectedLocations = [DirectiveLocation::SCHEMA]; + + $this->assertEquals('link', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); + $this->assertTrue($config['isRepeatable']); + } + + public function testRequiresDirective(): void { $config = Directives::requires()->config; $expectedLocations = [DirectiveLocation::FIELD_DEFINITION]; - $this->assertEquals($config['name'], 'requires'); - $this->assertEqualsCanonicalizing($config['locations'], $expectedLocations); + $this->assertEquals('requires', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); } - public function testProvidesDirective() + public function testProvidesDirective(): void { $config = Directives::provides()->config; $expectedLocations = [DirectiveLocation::FIELD_DEFINITION]; - $this->assertEquals($config['name'], 'provides'); - $this->assertEqualsCanonicalizing($config['locations'], $expectedLocations); + $this->assertEquals('provides', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); + } + + public function testShareableDirective(): void + { + $config = Directives::shareable()->config; + + $expectedLocations = [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::OBJECT]; + + $this->assertEquals('shareable', $config['name']); + $this->assertEqualsCanonicalizing($expectedLocations, $config['locations']); } - public function testItAddsDirectivesToSchema() + public function testItAddsDirectivesToSchema(): void { $schema = new Schema([ 'query' => new ObjectType([ 'name' => 'Query', 'fields' => [ - '_' => ['type' => Type::string()] - ] + '_' => ['type' => Type::string()], + ], ]), - 'directives' => Directives::getDirectives() + 'directives' => Directives::getDirectives(), ]); $schemaSdl = SchemaPrinter::doPrint($schema); diff --git a/test/EntitiesTest.php b/test/EntitiesTest.php index de61813..1e480f6 100644 --- a/test/EntitiesTest.php +++ b/test/EntitiesTest.php @@ -4,81 +4,79 @@ namespace Apollo\Federation\Tests; -use PHPUnit\Framework\TestCase; -use Spatie\Snapshots\MatchesSnapshots; - -use GraphQL\Type\Definition\Type; -use GraphQL\Error\InvariantViolation; - use Apollo\Federation\Types\EntityObjectType; use Apollo\Federation\Types\EntityRefObjectType; +use GraphQL\Error\InvariantViolation; +use GraphQL\Type\Definition\Type; +use PHPUnit\Framework\TestCase; +use Spatie\Snapshots\MatchesSnapshots; class EntitiesTest extends TestCase { use MatchesSnapshots; - public function testCreatingEntityType() + public function testCreatingEntityType(): void { - $userTypeKeyFields = ['id', 'email']; + $expectedKeys = [['fields' => 'id'], ['fields' => 'email']]; $userType = new EntityObjectType([ 'name' => 'User', - 'keyFields' => $userTypeKeyFields, + 'keys' => $expectedKeys, 'fields' => [ 'id' => ['type' => Type::int()], 'email' => ['type' => Type::string()], 'firstName' => ['type' => Type::string()], - 'lastName' => ['type' => Type::string()] - ] + 'lastName' => ['type' => Type::string()], + ], ]); - $this->assertEqualsCanonicalizing($userType->getKeyFields(), $userTypeKeyFields); + $this->assertEqualsCanonicalizing($expectedKeys, $userType->getKeys()); $this->assertMatchesSnapshot($userType->config); } - public function testCreatingEntityTypeWithCallable() + public function testCreatingEntityTypeWithCallable(): void { - $userTypeKeyFields = ['id', 'email']; + $expectedKeys = [['fields' => 'id'], ['fields' => 'email']]; $userType = new EntityObjectType([ 'name' => 'User', - 'keyFields' => $userTypeKeyFields, + 'keys' => $expectedKeys, 'fields' => function () { return [ 'id' => ['type' => Type::int()], 'email' => ['type' => Type::string()], 'firstName' => ['type' => Type::string()], - 'lastName' => ['type' => Type::string()] + 'lastName' => ['type' => Type::string()], ]; - } + }, ]); - $this->assertEqualsCanonicalizing($userType->getKeyFields(), $userTypeKeyFields); + $this->assertEqualsCanonicalizing($expectedKeys, $userType->getKeys()); $this->assertMatchesSnapshot($userType->config); } - public function testResolvingEntityReference() + public function testResolvingEntityReference(): void { $expectedRef = [ 'id' => 1, 'email' => 'luke@skywalker.com', 'firstName' => 'Luke', 'lastName' => 'Skywalker', - '__typename' => 'User' + '__typename' => 'User', ]; $userType = new EntityObjectType([ 'name' => 'User', - 'keyFields' => ['id', 'email'], + 'keys' => [['fields' => 'id'], ['fields' => 'email']], 'fields' => [ 'id' => ['type' => Type::int()], 'email' => ['type' => Type::string()], 'firstName' => ['type' => Type::string()], - 'lastName' => ['type' => Type::string()] + 'lastName' => ['type' => Type::string()], ], '__resolveReference' => function () use ($expectedRef) { return $expectedRef; - } + }, ]); $actualRef = $userType->resolveReference(['id' => 1, 'email' => 'luke@skywalker.com', '__typename' => 'User']); @@ -86,20 +84,76 @@ public function testResolvingEntityReference() $this->assertEquals($expectedRef, $actualRef); } - public function testCreatingEntityRefType() + public function testCreatingEntityRefType(): void { - $userTypeKeyFields = ['id', 'email']; + $expectedKeys = [['fields' => 'id', 'resolvable' => false]]; $userType = new EntityRefObjectType([ 'name' => 'User', - 'keyFields' => $userTypeKeyFields, + 'keys' => $expectedKeys, 'fields' => [ 'id' => ['type' => Type::int()], - 'email' => ['type' => Type::string()] - ] + 'email' => ['type' => Type::string()], + ], ]); - $this->assertEqualsCanonicalizing($userType->getKeyFields(), $userTypeKeyFields); + $this->assertEqualsCanonicalizing($expectedKeys, $userType->getKeys()); $this->assertMatchesSnapshot($userType->config); } + + /** + * @doesNotPerformAssertions + * @dataProvider getDataEntityRefObjectTypeWithSingleKeys + * + * @param array $config + */ + public function testEntityRefObjectTypeDoesNotThrowsErrorOnSingleKeys(array $config): void + { + new EntityRefObjectType($config); + } + + /** + * @dataProvider getDataEntityRefObjectTypeWithMultipleKeys + * + * @param array $config + */ + public function testEntityRefObjectTypeThrowsErrorOnMultipleKeys(array $config): void + { + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('There is invalid config of EntityRefObject. Referenced entity must have exactly one directive @key.'); + new EntityRefObjectType($config); + } + + /** + * @dataProvider getDataWithInvalidResolvableArgument + * + * @param array $config + */ + public function testEntityRefObjectTypeThrowsErrorOnInvalidResolvableArgument(array $config): void + { + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('There is invalid config of EntityRefObject. Referenced entity directive @key must have argument "resolvable" with value `false`.'); + new EntityRefObjectType($config); + } + + public function getDataEntityRefObjectTypeWithMultipleKeys(): \Generator + { + yield [['keys' => [['fields' => 'id'], ['fields' => 'id2']]]]; + yield [['keys' => [['fields' => 'id'], ['fields' => 'id2'], ['fields' => 'id2']]]]; + } + + public function getDataEntityRefObjectTypeWithSingleKeys(): \Generator + { + yield [['keys' => [['fields' => 'id', 'resolvable' => false]]]]; + yield [['keys' => [['fields' => ['obj' => 'id'], 'resolvable' => false]]]]; + } + + public function getDataWithInvalidResolvableArgument(): \Generator + { + yield [['keys' => [['fields' => 'id']]]]; + yield [['keys' => [['fields' => 'id', 'resolvable' => true]]]]; + yield [['keys' => [['fields' => 'id', 'resolvable' => 0]]]]; + yield [['keys' => [['fields' => 'id', 'resolvable' => 'no']]]]; + yield [['keys' => [['fields' => 'id', 'resolvable' => 'false']]]]; + } } diff --git a/test/EntityTest.php b/test/EntityTest.php new file mode 100644 index 0000000..1fd308c --- /dev/null +++ b/test/EntityTest.php @@ -0,0 +1,79 @@ +expectException(InvariantViolation::class); + $config = ['keys' => [], 'keyFields' => [], 'name' => '*']; + new EntityObjectType($config); + } + + /** + * @dataProvider getDataForTestMethodGetKeyFields + * + * @param string[] $expected + * @param array $config + */ + public function testMethodGetKeyFields(array $expected, array $config): void + { + self::assertSame($expected, (new EntityObjectType($config))->getKeyFields()); + } + + public function testMethodGetKeyFieldsTriggersDeprecation(): void + { + $isCaught = false; + set_error_handler(static function (int $n, string $s, string $f = '', int $l = 0, array $c = []) + use (&$isCaught): bool { + $isCaught = true; + + return true; + }); + $config = ['name' => '*', 'keys' => [['fields' => ['id']]]]; + (new EntityObjectType($config))->getKeyFields(); + + self::assertTrue($isCaught, 'It does not trigger deprecation error. But it should!'); + + restore_error_handler(); + } + + public function getDataForTestMethodGetKeyFields(): \Generator + { + yield [ + ['id'], + ['name' => '*', 'keyFields' => ['id']], + ]; + yield [ + ['id', 'email'], + ['name' => '*', 'keyFields' => ['id', 'email']], + ]; + yield [ + ['id'], + ['name' => '*', 'keys' => [['fields' => 'id']]], + ]; + yield [ + ['id'], + ['name' => '*', 'keys' => [['fields' => ['id']]]], + ]; + yield [ + ['id', 'email'], + ['name' => '*', 'keys' => [['fields' => ['id', 'email']]]], + ]; + yield [ + ['id', 'email'], + ['name' => '*', 'keys' => [['fields' => ['id']], ['fields' => ['email']]]], + ]; + yield [ + ['id', 'email'], + ['name' => '*', 'keys' => [['fields' => 'id'], ['fields' => 'email']]], + ]; + } +} \ No newline at end of file diff --git a/test/SchemaTest.php b/test/SchemaTest.php index f41fefc..0a86ee0 100644 --- a/test/SchemaTest.php +++ b/test/SchemaTest.php @@ -4,21 +4,16 @@ namespace Apollo\Federation\Tests; -use PHPUnit\Framework\TestCase; -use Spatie\Snapshots\MatchesSnapshots; - use GraphQL\GraphQL; -use GraphQL\Type\Definition\Type; use GraphQL\Utils\SchemaPrinter; - -use Apollo\Federation\Tests\StarWarsSchema; -use Apollo\Federation\Tests\DungeonsAndDragonsSchema; +use PHPUnit\Framework\TestCase; +use Spatie\Snapshots\MatchesSnapshots; class SchemaTest extends TestCase { use MatchesSnapshots; - public function testRunningQueries() + public function testRunningQueries(): void { $schema = StarWarsSchema::getEpisodesSchema(); $query = 'query GetEpisodes { episodes { id title characters { id name } } }'; @@ -28,7 +23,7 @@ public function testRunningQueries() $this->assertMatchesSnapshot($result->toArray()); } - public function testEntityTypes() + public function testEntityTypes(): void { $schema = StarWarsSchema::getEpisodesSchema(); @@ -41,7 +36,7 @@ public function testEntityTypes() $this->assertArrayHasKey('Location', $entityTypes); } - public function testMetaTypes() + public function testMetaTypes(): void { $schema = StarWarsSchema::getEpisodesSchema(); @@ -53,7 +48,7 @@ public function testMetaTypes() $this->assertEqualsCanonicalizing($entitiesType->getTypes(), array_values($schema->getEntityTypes())); } - public function testDirectives() + public function testDirectives(): void { $schema = StarWarsSchema::getEpisodesSchema(); $directives = $schema->getDirectives(); @@ -70,7 +65,7 @@ public function testDirectives() $this->assertArrayHasKey('deprecated', $directives); } - public function testServiceSdl() + public function testServiceSdl(): void { $schema = StarWarsSchema::getEpisodesSchema(); $query = 'query GetServiceSdl { _service { sdl } }'; @@ -80,7 +75,7 @@ public function testServiceSdl() $this->assertMatchesSnapshot($result->toArray()); } - public function testSchemaSdl() + public function testSchemaSdl(): void { $schema = StarWarsSchema::getEpisodesSchema(); $schemaSdl = SchemaPrinter::doPrint($schema); @@ -88,11 +83,10 @@ public function testSchemaSdl() $this->assertMatchesSnapshot($schemaSdl); } - public function testResolvingEntityReferences() + public function testResolvingEntityReferences(): void { $schema = StarWarsSchema::getEpisodesSchema(); - $query = ' query GetEpisodes($representations: [_Any!]!) { _entities(representations: $representations) { @@ -109,8 +103,8 @@ public function testResolvingEntityReferences() [ '__typename' => 'Episode', 'id' => 1, - ] - ] + ], + ], ]; $result = GraphQL::executeQuery($schema, $query, null, null, $variables); @@ -118,7 +112,7 @@ public function testResolvingEntityReferences() $this->assertMatchesSnapshot($result->toArray()); } - public function testOverrideSchemaResolver() + public function testOverrideSchemaResolver(): void { $schema = StarWarsSchema::getEpisodesSchemaCustomResolver(); @@ -137,16 +131,15 @@ public function testOverrideSchemaResolver() 'representations' => [ [ '__typename' => 'Episode', - 'id' => 1 - ] - ] + 'id' => 1, + ], + ], ]; $result = GraphQL::executeQuery($schema, $query, null, null, $variables); // The custom resolver for this schema, always adds 1 to the id and gets the next // episode for the sake of testing the ability to change the resolver in the configuration - $this->assertEquals("The Empire Strikes Back", $result->data['_entities'][0]["title"]); + $this->assertEquals('The Empire Strikes Back', $result->data['_entities'][0]['title']); $this->assertMatchesSnapshot($result->toArray()); } } - \ No newline at end of file diff --git a/test/StarWarsData.php b/test/StarWarsData.php index c1cefa2..b2b9098 100644 --- a/test/StarWarsData.php +++ b/test/StarWarsData.php @@ -6,100 +6,132 @@ class StarWarsData { + /** + * @var array>|null + */ private static $episodes; + /** + * @var array>|null + */ private static $characters; + /** + * @var array>|null + */ private static $locations; - public static function getEpisodeById($id) + /** + * @return array|null + */ + public static function getEpisodeById(int $id): ?array { - $matches = array_filter(self::getEpisodes(), function ($episode) use ($id) { + $matches = array_filter(self::getEpisodes(), static function (array $episode) use ($id): bool { return $episode['id'] === $id; }); - return reset($matches); + + return reset($matches) ?: null; } - public static function getEpisodes() + /** + * @return array> + */ + public static function getEpisodes(): array { if (!self::$episodes) { self::$episodes = [ [ 'id' => 1, 'title' => 'A New Hope', - 'characters' => [1, 2, 3] + 'characters' => [1, 2, 3], ], [ 'id' => 2, 'title' => 'The Empire Strikes Back', - 'characters' => [1, 2, 3] + 'characters' => [1, 2, 3], ], [ 'id' => 3, 'title' => 'Return of the Jedi', - 'characters' => [1, 2, 3] - ] + 'characters' => [1, 2, 3], + ], ]; } return self::$episodes; } - public static function getCharactersByIds($ids) + /** + * @param int[] $ids + * + * @return array> + */ + public static function getCharactersByIds(array $ids): array { - return array_filter(self::getCharacters(), function ($character) use ($ids) { - return in_array($character['id'], $ids); + return array_filter(self::getCharacters(), static function ($item) use ($ids): bool { + return \in_array($item['id'], $ids, true); }); } - public static function getCharacters() + /** + * @return array> + */ + public static function getCharacters(): array { if (!self::$characters) { self::$characters = [ [ 'id' => 1, 'name' => 'Luke Skywalker', - 'locations' => [1, 2, 3] + 'locations' => [1, 2, 3], ], [ 'id' => 2, 'name' => 'Han Solo', - 'locations' => [1, 2] + 'locations' => [1, 2], ], [ 'id' => 3, 'name' => 'Leia Skywalker', - 'locations' => [3] - ] + 'locations' => [3], + ], ]; } return self::$characters; } - public static function getLocationsByIds($ids) + /** + * @param int[] $ids + * + * @return array> + */ + public static function getLocationsByIds(array $ids): array { - return array_filter(self::getLocations(), function ($location) use ($ids) { - return in_array($location['id'], $ids); + return array_filter(self::getLocations(), static function ($item) use ($ids): bool { + return \in_array($item['id'], $ids, true); }); } - public static function getLocations() + /** + * @return array> + */ + public static function getLocations(): array { if (!self::$locations) { self::$locations = [ [ 'id' => 1, - 'name' => 'Tatooine' + 'name' => 'Tatooine', ], [ 'id' => 2, - 'name' => 'Endor' + 'name' => 'Endor', ], [ 'id' => 3, - 'name' => 'Hoth' - ] + 'name' => 'Hoth', + ], ]; } diff --git a/test/StarWarsSchema.php b/test/StarWarsSchema.php index 5594896..bec8a2b 100644 --- a/test/StarWarsSchema.php +++ b/test/StarWarsSchema.php @@ -4,66 +4,83 @@ namespace Apollo\Federation\Tests; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\ObjectType; - +use Apollo\Federation\Enum\DirectiveEnum; use Apollo\Federation\FederatedSchema; +use Apollo\Federation\SchemaBuilder; use Apollo\Federation\Types\EntityObjectType; use Apollo\Federation\Types\EntityRefObjectType; +use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; class StarWarsSchema { + /** + * @var FederatedSchema|null + */ public static $episodesSchema; - public static $overRiddedEpisodesSchema; + + /** + * @var FederatedSchema|null + */ + public static $overriddenEpisodesSchema; public static function getEpisodesSchema(): FederatedSchema { if (!self::$episodesSchema) { - self::$episodesSchema = new FederatedSchema([ - 'query' => self::getQueryType() + self::$episodesSchema = (new SchemaBuilder())->build([ + 'directives' => Directive::getInternalDirectives(), + 'query' => self::getQueryType(), + ], [ + 'directives' => DirectiveEnum::getAll(), ]); } + return self::$episodesSchema; } public static function getEpisodesSchemaCustomResolver(): FederatedSchema { - if (!self::$overRiddedEpisodesSchema) { - self::$overRiddedEpisodesSchema = new FederatedSchema([ + if (!self::$overriddenEpisodesSchema) { + self::$overriddenEpisodesSchema = (new SchemaBuilder())->build([ + 'directives' => Directive::getInternalDirectives(), 'query' => self::getQueryType(), - 'resolve' => function ($root, $args, $context, $info) { - return array_map(function ($ref) use ($info) { + 'resolve' => function ($root, $args, $context, $info): array { + return array_map(static function (array $ref) use ($info) { $typeName = $ref['__typename']; $type = $info->schema->getType($typeName); - $ref["id"] = $ref["id"] + 1; + ++$ref['id']; + return $type->resolveReference($ref); - }, $args['representations']); - } + }, $args[FederatedSchema::RESERVED_FIELD_REPRESENTATIONS]); + }, + ], [ + 'directives' => DirectiveEnum::getAll(), ]); } - return self::$overRiddedEpisodesSchema; + + return self::$overriddenEpisodesSchema; } private static function getQueryType(): ObjectType { $episodeType = self::getEpisodeType(); - $queryType = new ObjectType([ - 'name' => 'Query', + return new ObjectType([ + 'name' => FederatedSchema::RESERVED_TYPE_QUERY, 'fields' => [ 'episodes' => [ 'type' => Type::nonNull(Type::listOf(Type::nonNull($episodeType))), - 'resolve' => function () { + 'resolve' => static function (): array { return StarWarsData::getEpisodes(); - } + }, ], 'deprecatedEpisodes' => [ 'type' => Type::nonNull(Type::listOf(Type::nonNull($episodeType))), - 'deprecationReason' => 'Because you should use the other one.' - ] - ] + 'deprecationReason' => 'Because you should use the other one.', + ], + ], ]); - return $queryType; } private static function getEpisodeType(): EntityObjectType @@ -73,26 +90,26 @@ private static function getEpisodeType(): EntityObjectType 'description' => 'A film in the Star Wars Trilogy', 'fields' => [ 'id' => [ - 'type' => Type::nonNull(Type::int()) + 'type' => Type::nonNull(Type::int()), ], 'title' => [ - 'type' => Type::nonNull(Type::string()) + 'type' => Type::nonNull(Type::string()), ], 'characters' => [ 'type' => Type::nonNull(Type::listOf(Type::nonNull(self::getCharacterType()))), - 'resolve' => function ($root) { + 'resolve' => static function ($root): array { return StarWarsData::getCharactersByIds($root['characters']); }, - 'provides' => 'name' - ] + 'provides' => 'name', + ], ], - 'keyFields' => ['id'], - '__resolveReference' => function ($ref) { - // print_r($ref); + EntityObjectType::FIELD_KEYS => [['fields' => 'id']], + EntityObjectType::FIELD_REFERENCE_RESOLVER => static function (array $ref): array { $entity = StarWarsData::getEpisodeById($ref['id']); - $entity["__typename"] = "Episode"; + $entity['__typename'] = 'Episode'; + return $entity; - } + }, ]); } @@ -104,21 +121,21 @@ private static function getCharacterType(): EntityRefObjectType 'fields' => [ 'id' => [ 'type' => Type::nonNull(Type::int()), - 'isExternal' => true + 'isExternal' => true, ], 'name' => [ 'type' => Type::nonNull(Type::string()), - 'isExternal' => true + 'isExternal' => true, ], 'locations' => [ 'type' => Type::nonNull(Type::listOf(self::getLocationType())), - 'resolve' => function ($root) { + 'resolve' => static function ($root): array { return StarWarsData::getLocationsByIds($root['locations']); }, - 'requires' => 'name' - ] + 'requires' => 'name', + ], ], - 'keyFields' => ['id'] + EntityObjectType::FIELD_KEYS => [['fields' => 'id', 'resolvable' => false]], ]); } @@ -130,14 +147,14 @@ private static function getLocationType(): EntityRefObjectType 'fields' => [ 'id' => [ 'type' => Type::nonNull(Type::int()), - 'isExternal' => true + 'isExternal' => true, ], 'name' => [ 'type' => Type::nonNull(Type::string()), - 'isExternal' => true - ] + 'isExternal' => true, + ], ], - 'keyFields' => ['id'] + EntityObjectType::FIELD_KEYS => [['fields' => 'id', 'resolvable' => false]], ]); } } diff --git a/test/__snapshots__/DirectivesTest__testItAddsDirectivesToSchema__1.txt b/test/__snapshots__/DirectivesTest__testItAddsDirectivesToSchema__1.txt index fe9e678..6444549 100644 --- a/test/__snapshots__/DirectivesTest__testItAddsDirectivesToSchema__1.txt +++ b/test/__snapshots__/DirectivesTest__testItAddsDirectivesToSchema__1.txt @@ -1,11 +1,21 @@ -directive @key(fields: String!) on OBJECT | INTERFACE - directive @external on FIELD_DEFINITION +directive @inaccessible on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + +directive @key(fields: String!, resolvable: Boolean) on OBJECT | INTERFACE + +directive @link(url: String!, as: String, for: String, import: [link_Import!]) repeatable on SCHEMA + +directive @override(from: String!) on FIELD_DEFINITION + directive @requires(fields: String!) on FIELD_DEFINITION directive @provides(fields: String!) on FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT + type Query { _: String } + +scalar link_Import diff --git a/test/__snapshots__/EntitiesTest__testCreatingEntityRefType__1.yml b/test/__snapshots__/EntitiesTest__testCreatingEntityRefType__1.yml index 3bc1453..036ee5e 100644 --- a/test/__snapshots__/EntitiesTest__testCreatingEntityRefType__1.yml +++ b/test/__snapshots__/EntitiesTest__testCreatingEntityRefType__1.yml @@ -1,7 +1,6 @@ name: User -keyFields: - - id - - email +keys: + - { fields: id, resolvable: false } fields: id: { type: { name: Int, description: "The `Int` scalar type represents non-fractional signed whole numeric\nvalues. Int can represent values between -(2^31) and 2^31 - 1. ", astNode: null, extensionASTNodes: null, config: { } } } email: { type: { name: String, description: "The `String` scalar type represents textual data, represented as UTF-8\ncharacter sequences. The String type is most often used by GraphQL to\nrepresent free-form human-readable text.", astNode: null, extensionASTNodes: null, config: { } } } diff --git a/test/__snapshots__/EntitiesTest__testCreatingEntityTypeWithCallable__1.yml b/test/__snapshots__/EntitiesTest__testCreatingEntityTypeWithCallable__1.yml index 550a52f..525bd68 100644 --- a/test/__snapshots__/EntitiesTest__testCreatingEntityTypeWithCallable__1.yml +++ b/test/__snapshots__/EntitiesTest__testCreatingEntityTypeWithCallable__1.yml @@ -1,5 +1,5 @@ name: User -keyFields: - - id - - email +keys: + - { fields: id } + - { fields: email } fields: { } diff --git a/test/__snapshots__/EntitiesTest__testCreatingEntityType__1.yml b/test/__snapshots__/EntitiesTest__testCreatingEntityType__1.yml index e073da4..2b18be2 100644 --- a/test/__snapshots__/EntitiesTest__testCreatingEntityType__1.yml +++ b/test/__snapshots__/EntitiesTest__testCreatingEntityType__1.yml @@ -1,7 +1,7 @@ name: User -keyFields: - - id - - email +keys: + - { fields: id } + - { fields: email } fields: id: { type: { name: Int, description: "The `Int` scalar type represents non-fractional signed whole numeric\nvalues. Int can represent values between -(2^31) and 2^31 - 1. ", astNode: null, extensionASTNodes: null, config: { } } } email: { type: { name: String, description: "The `String` scalar type represents textual data, represented as UTF-8\ncharacter sequences. The String type is most often used by GraphQL to\nrepresent free-form human-readable text.", astNode: null, extensionASTNodes: null, config: { } } } diff --git a/test/__snapshots__/SchemaTest__testSchemaSdl__1.txt b/test/__snapshots__/SchemaTest__testSchemaSdl__1.txt index fd4a936..2af0a85 100644 --- a/test/__snapshots__/SchemaTest__testSchemaSdl__1.txt +++ b/test/__snapshots__/SchemaTest__testSchemaSdl__1.txt @@ -1,11 +1,19 @@ -directive @key(fields: String!) on OBJECT | INTERFACE - directive @external on FIELD_DEFINITION +directive @inaccessible on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + +directive @key(fields: String!, resolvable: Boolean) on OBJECT | INTERFACE + +directive @link(url: String!, as: String, for: String, import: [link_Import!]) repeatable on SCHEMA + +directive @override(from: String!) on FIELD_DEFINITION + directive @requires(fields: String!) on FIELD_DEFINITION directive @provides(fields: String!) on FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT + """A character in the Star Wars Trilogy""" type Character { id: Int! @@ -40,3 +48,5 @@ union _Entity = Episode | Character | Location type _Service { sdl: String } + +scalar link_Import diff --git a/test/__snapshots__/SchemaTest__testServiceSdl__1.yml b/test/__snapshots__/SchemaTest__testServiceSdl__1.yml index 2cf44a2..588e21c 100644 --- a/test/__snapshots__/SchemaTest__testServiceSdl__1.yml +++ b/test/__snapshots__/SchemaTest__testServiceSdl__1.yml @@ -1,2 +1,2 @@ data: - _service: { sdl: "\"\"\"A character in the Star Wars Trilogy\"\"\"\nextend type Character @key(fields: \"id\") {\n id: Int! @external\n name: String! @external\n locations: [Location]! @requires(fields: \"name\")\n}\n\n\"\"\"A film in the Star Wars Trilogy\"\"\"\ntype Episode @key(fields: \"id\") {\n id: Int! \n title: String! \n characters: [Character!]! @provides(fields: \"name\")\n}\n\n\"\"\"A location in the Star Wars Trilogy\"\"\"\nextend type Location @key(fields: \"id\") {\n id: Int! @external\n name: String! @external\n}\n\nextend type Query {\n episodes: [Episode!]! \n deprecatedEpisodes: [Episode!]! @deprecated(reason: \"Because you should use the other one.\") \n}\n" } + _service: { sdl: "\"\"\"A character in the Star Wars Trilogy\"\"\"\nextend type Character @key(fields: \"id\", resolvable: false) {\n id: Int! @external\n name: String! @external\n locations: [Location]! @requires(fields: \"name\")\n}\n\n\"\"\"A film in the Star Wars Trilogy\"\"\"\ntype Episode @key(fields: \"id\") {\n id: Int! \n title: String! \n characters: [Character!]! @provides(fields: \"name\")\n}\n\n\"\"\"A location in the Star Wars Trilogy\"\"\"\nextend type Location @key(fields: \"id\", resolvable: false) {\n id: Int! @external\n name: String! @external\n}\n\nextend type Query {\n episodes: [Episode!]! \n deprecatedEpisodes: [Episode!]! @deprecated(reason: \"Because you should use the other one.\") \n}\n\nscalar link_Import\n" }