diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f576bf9..c5b09fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,6 @@ name: CI on: - push: pull_request: jobs: @@ -29,9 +28,6 @@ jobs: - name: Install Symfony matrix dependencies run: composer update --no-interaction --prefer-dist --with-all-dependencies symfony/*:${{ matrix.symfony-version }} - - name: Run PHPStan - run: vendor/bin/phpstan analyse -c phpstan.neon.dist - - name: Run PHPUnit run: vendor/bin/phpunit -c phpunit.xml.dist --coverage-text @@ -40,3 +36,6 @@ jobs: - name: Fixture app type registry generation run: php tests/fixture-app/bin/console flexible_graphql:generate-type-registry --env=test --no-interaction + + - name: Run PHPStan + run: vendor/bin/phpstan analyse -c phpstan.neon.dist diff --git a/README.md b/README.md index 80cc5cc..bca8a56 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,19 @@ Quick install [guide](docs/index.md) ```shell bin/console list flexible_graphql ``` + +## Tests + +Run tests + +``` +php vendor/bin/phpunit +``` + +## PHPStan + +Run PHPStan + +``` +php vendor/bin/phpstan analyse -c phpstan.neon.dist +``` \ No newline at end of file diff --git a/composer.json b/composer.json index f7a73b4..f9fc480 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ }, "require": { "php": "^8.3", - "axtiva/flexible-graphql-php": "^3.0", + "axtiva/flexible-graphql-php": "^3.0.1", "symfony/console": "^6.4 | ^7.0 | ^8.0", "symfony/config": "^6.4 | ^7.0 | ^8.0", "symfony/dependency-injection": "^6.4 | ^7.0 | ^8.0", diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..f1dcbff --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,5 @@ +codeGeneratorBuilder = $codeGeneratorBuilder; } - protected function configure() + protected function configure(): void { $this ->setDescription('generate executive directive resolver') diff --git a/src/Command/GenerateFieldResolverCommand.php b/src/Command/GenerateFieldResolverCommand.php index dafae51..8505578 100644 --- a/src/Command/GenerateFieldResolverCommand.php +++ b/src/Command/GenerateFieldResolverCommand.php @@ -17,7 +17,6 @@ #[AsCommand(name: 'flexible_graphql:generate-field-resolver')] class GenerateFieldResolverCommand extends Command { - protected static ?string $defaultName = 'flexible_graphql:generate-field-resolver'; private string $schemaFiles; private CodeGeneratorBuilderInterface $codeGeneratorBuilder; diff --git a/src/Command/GenerateScalarResolverCommand.php b/src/Command/GenerateScalarResolverCommand.php index 657b5a8..8606955 100644 --- a/src/Command/GenerateScalarResolverCommand.php +++ b/src/Command/GenerateScalarResolverCommand.php @@ -17,7 +17,6 @@ #[AsCommand(name: 'flexible_graphql:generate-scalar-resolver')] class GenerateScalarResolverCommand extends Command { - protected static ?string $defaultName = 'flexible_graphql:generate-scalar-resolver'; private string $schemaFiles; private CodeGeneratorBuilderInterface $codeGeneratorBuilder; diff --git a/src/Command/GenerateTypeRegistryCommand.php b/src/Command/GenerateTypeRegistryCommand.php index 0996985..a7c2f5c 100644 --- a/src/Command/GenerateTypeRegistryCommand.php +++ b/src/Command/GenerateTypeRegistryCommand.php @@ -16,7 +16,6 @@ #[AsCommand(name: 'flexible_graphql:generate-type-registry')] class GenerateTypeRegistryCommand extends Command { - protected static ?string $defaultName = 'flexible_graphql:generate-type-registry'; private string $schemaFiles; private TypeRegistryGeneratorBuilderInterface $typeRegistryGeneratorBuilder; private CodeGeneratorBuilderInterface $codeGeneratorBuilder; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2cd8c07..37bcee5 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,8 +4,10 @@ namespace Axtiva\FlexibleGraphqlBundle\DependencyInjection; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\BooleanNodeDefinition; use Symfony\Component\Config\Definition\Builder\EnumNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\ScalarNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -23,6 +25,7 @@ public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder(self::NAME, 'array'); + /** @var ArrayNodeDefinition $rootNode */ $rootNode = $treeBuilder->getRootNode(); $rootNode @@ -36,7 +39,7 @@ public function getConfigTreeBuilder(): TreeBuilder 'namespace', 'App\GraphQL', 'Root namespace for generated code' - )->isRequired()) + )) ->append($this->addScalar( 'template_language_version', '8.3', @@ -46,13 +49,16 @@ public function getConfigTreeBuilder(): TreeBuilder 'dir', '%kernel.project_dir%/src/GraphQL/', 'Root dir for generated code' - )->isRequired()) + )) ->end(); return $treeBuilder; } - private function schemaType(): ScalarNodeDefinition + /** + * @return EnumNodeDefinition + */ + private function schemaType(): EnumNodeDefinition { $treeBuilder = new TreeBuilder('schema_type', 'enum'); @@ -68,7 +74,10 @@ private function schemaType(): ScalarNodeDefinition return $node; } - private function operationType(): ScalarNodeDefinition + /** + * @return EnumNodeDefinition + */ + private function operationType(): EnumNodeDefinition { $treeBuilder = new TreeBuilder('executor', 'enum'); @@ -84,7 +93,10 @@ private function operationType(): ScalarNodeDefinition return $node; } - private function enablePreload(): ScalarNodeDefinition + /** + * @return BooleanNodeDefinition + */ + private function enablePreload(): BooleanNodeDefinition { $treeBuilder = new TreeBuilder('enable_preload', 'boolean'); @@ -109,7 +121,6 @@ private function schemaFile(): ScalarNodeDefinition $node ->info('Full path to schema sdl files like /path/to/schema.graphql or glob template') ->defaultValue('%kernel.project_dir%/config/graphql/*.graphql') - ->isRequired() ->end(); return $node; diff --git a/src/DependencyInjection/FlexibleGraphqlExtension.php b/src/DependencyInjection/FlexibleGraphqlExtension.php index 3471e87..9fbefaf 100644 --- a/src/DependencyInjection/FlexibleGraphqlExtension.php +++ b/src/DependencyInjection/FlexibleGraphqlExtension.php @@ -14,8 +14,13 @@ use Axtiva\FlexibleGraphql\Builder\TypeRegistryGeneratorBuilderInterface; use Axtiva\FlexibleGraphql\Generator\Config\CodeGeneratorConfigInterface; use Axtiva\FlexibleGraphql\Generator\Config\Foundation\Psr4\CodeGeneratorConfig; +use Axtiva\FlexibleGraphql\Resolver\CustomScalarResolverInterface; +use Axtiva\FlexibleGraphql\Resolver\DirectiveResolverInterface; +use Axtiva\FlexibleGraphql\Resolver\FederationRepresentationResolverInterface; use Axtiva\FlexibleGraphql\Resolver\_EntitiesResolverInterface; +use Axtiva\FlexibleGraphql\Resolver\ResolverInterface; use Axtiva\FlexibleGraphql\Resolver\_ServiceResolverInterface; +use Axtiva\FlexibleGraphql\Resolver\UnionResolveTypeInterface; use Axtiva\FlexibleGraphqlBundle\Builder\ScopedTypeRegistryGeneratorBuilder; use Axtiva\FlexibleGraphqlBundle\CacheWarmer\SchemaCacheWarmer; use Axtiva\FlexibleGraphqlBundle\Command\GenerateDirectiveResolverCommand; @@ -66,6 +71,9 @@ public function load(array $configs, ContainerBuilder $container): void { $yamlLoader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $yamlLoader->load('services.yaml'); + + $this->registerResolverAutoconfiguration($container); + $config = $this->processConfiguration($this->getConfiguration($configs, $container), $configs); $this->config = $config; $this->registerConfigGenerator($this->config, $container); @@ -92,6 +100,48 @@ public function getAlias(): string return Configuration::NAME; } + private function registerResolverAutoconfiguration(ContainerBuilder $container): void + { + $container + ->registerForAutoconfiguration(ResolverInterface::class) + ->setPublic(true) + ->addTag(self::RESOLVER_TAG) + ->addTag(self::TYPE_REGISTRY_SERVICE_TAG); + + $container + ->registerForAutoconfiguration(DirectiveResolverInterface::class) + ->setPublic(true) + ->addTag(self::DIRECTIVE_RESOLVER_TAG) + ->addTag(self::TYPE_REGISTRY_SERVICE_TAG); + + $container + ->registerForAutoconfiguration(CustomScalarResolverInterface::class) + ->setPublic(true) + ->addTag(self::SCALAR_RESOLVER_TAG) + ->addTag(self::TYPE_REGISTRY_SERVICE_TAG); + + $container + ->registerForAutoconfiguration(UnionResolveTypeInterface::class) + ->setPublic(true) + ->addTag(self::UNION_TYPE_RESOLVER_TAG) + ->addTag(self::TYPE_REGISTRY_SERVICE_TAG); + + $container + ->registerForAutoconfiguration(FederationRepresentationResolverInterface::class) + ->setPublic(true) + ->addTag(self::FEDERATION_REPRESENTATION_RESOLVER_TAG); + + $container + ->registerForAutoconfiguration(_ServiceResolverInterface::class) + ->setPublic(true) + ->addTag(self::_SERVICE_RESOLVER_TAG); + + $container + ->registerForAutoconfiguration(_EntitiesResolverInterface::class) + ->setPublic(true) + ->addTag(self::_ENTITIES_RESOLVER_TAG); + } + /** * @param array $config */ diff --git a/tests/DependencyInjection/FlexibleGraphqlExtensionTest.php b/tests/DependencyInjection/FlexibleGraphqlExtensionTest.php index cfe40ab..fdc0b8c 100644 --- a/tests/DependencyInjection/FlexibleGraphqlExtensionTest.php +++ b/tests/DependencyInjection/FlexibleGraphqlExtensionTest.php @@ -9,6 +9,7 @@ use Axtiva\FlexibleGraphql\Builder\Foundation\Psr\Container\TypeRegistryGeneratorBuilderAmphpV2; use Axtiva\FlexibleGraphql\Builder\Foundation\Psr\Container\TypeRegistryGeneratorBuilderFederated; use Axtiva\FlexibleGraphql\Builder\TypeRegistryGeneratorBuilderInterface; +use Axtiva\FlexibleGraphql\Resolver\ResolverInterface; use Axtiva\FlexibleGraphqlBundle\Builder\ScopedTypeRegistryGeneratorBuilder; use Axtiva\FlexibleGraphqlBundle\DependencyInjection\FlexibleGraphqlExtension; use Axtiva\FlexibleGraphqlBundle\Resolver\DefaultResolver; @@ -71,6 +72,18 @@ public function testTypeRegistryScopedLocatorWiring(): void self::assertArrayHasKey(FlexibleGraphqlExtension::TYPE_REGISTRY_SERVICE_TAG, $defaultResolverDefinition->getTags()); } + public function testResolverAutoconfigurationAddsTypeRegistryServiceTag(): void + { + $container = $this->loadExtension('graphql', 'sync'); + + $autoconfigurations = $container->getAutoconfiguredInstanceof(); + self::assertArrayHasKey(ResolverInterface::class, $autoconfigurations); + + $resolverAutoconfiguration = $autoconfigurations[ResolverInterface::class]; + self::assertArrayHasKey(FlexibleGraphqlExtension::TYPE_REGISTRY_SERVICE_TAG, $resolverAutoconfiguration->getTags()); + self::assertArrayHasKey(FlexibleGraphqlExtension::RESOLVER_TAG, $resolverAutoconfiguration->getTags()); + } + /** * @return iterable */ diff --git a/tests/E2e/FixtureAppHttpGraphqlTest.php b/tests/E2e/FixtureAppHttpGraphqlTest.php new file mode 100644 index 0000000..c4edf84 --- /dev/null +++ b/tests/E2e/FixtureAppHttpGraphqlTest.php @@ -0,0 +1,172 @@ +runConsoleCommand('cache:clear'); + } + + #[RunInSeparateProcess] + public function testGraphqlHttpHandlesQueryMutationAndUnionUsingScopedLocatorResolvers(): void + { + $kernel = $this->bootFixtureKernel(); + $container = $kernel->getContainer(); + $locator = $container->get('flexible_graphql.type_registry.service_locator'); + + self::assertInstanceOf(ContainerInterface::class, $locator); + self::assertTrue($locator->has('App\\GraphQL\\Resolver\\Query\\GreetResolver')); + self::assertTrue($locator->has('App\\GraphQL\\Resolver\\Query\\SearchResolver')); + self::assertTrue($locator->has('App\\GraphQL\\Resolver\\Mutation\\CreateGroupAccountResolver')); + self::assertTrue($locator->has('App\\GraphQL\\Directive\\UpperDirective')); + self::assertTrue($locator->has('App\\GraphQL\\UnionResolveType\\SearchResultTypeResolver')); + + $greetResponse = $this->graphqlRequest($kernel, <<<'GRAPHQL' +query($name: String!, $input: GreetingInput!) { + greet(name: $name, input: $input) +} +GRAPHQL, [ + 'name' => 'alice', + 'input' => [ + 'prefix' => 'hello', + 'suffix' => '!', + ], + ]); + + self::assertArrayNotHasKey('errors', $greetResponse, json_encode($greetResponse)); + self::assertSame('HELLO ALICE!', $greetResponse['data']['greet'] ?? null); + + $unionResponse = $this->graphqlRequest($kernel, <<<'GRAPHQL' +query($kind: String!) { + search(kind: $kind) { + __typename + ... on GroupSearchResult { + title + } + ... on UserSearchResult { + username + } + } +} +GRAPHQL, [ + 'kind' => 'group', + ]); + + self::assertArrayNotHasKey('errors', $unionResponse, json_encode($unionResponse)); + self::assertSame('GroupSearchResult', $unionResponse['data']['search']['__typename'] ?? null); + self::assertSame('Core Team', $unionResponse['data']['search']['title'] ?? null); + + $mutationResponse = $this->graphqlRequest($kernel, <<<'GRAPHQL' +mutation($input: CreateGroupAccountInput!) { + createGroupAccount(input: $input) { + id + title + } +} +GRAPHQL, [ + 'input' => [ + 'title' => 'Team Rocket', + ], + ]); + + self::assertArrayNotHasKey('errors', $mutationResponse, json_encode($mutationResponse)); + self::assertSame('grp-created-1', $mutationResponse['data']['createGroupAccount']['id'] ?? null); + self::assertSame('Team Rocket', $mutationResponse['data']['createGroupAccount']['title'] ?? null); + + $kernel->shutdown(); + } + + /** + * @param array $variables + * + * @return array + */ + private function graphqlRequest(Kernel $kernel, string $query, array $variables = []): array + { + $request = Request::create('/graphql', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'query' => $query, + 'variables' => $variables, + ])); + + $response = $kernel->handle($request); + self::assertSame(200, $response->getStatusCode(), $response->getContent()); + + $payload = json_decode((string) $response->getContent(), true); + self::assertIsArray($payload, $response->getContent()); + + return $payload; + } + + private function bootFixtureKernel(): Kernel + { + require_once self::fixtureAppDir() . '/autoload.php'; + require_once self::fixtureAppDir() . '/src/Kernel.php'; + + $kernel = new Kernel('test', false); + $kernel->boot(); + + return $kernel; + } + + private static function fixtureAppDir(): string + { + return dirname(__DIR__) . '/fixture-app'; + } + + private function runConsoleCommand(string $command): void + { + $fixtureAppDir = self::fixtureAppDir(); + self::removeDirectory($fixtureAppDir . '/var/cache/test'); + + $consolePath = $fixtureAppDir . '/bin/console'; + $execCommand = sprintf( + 'php %s --env=test --no-interaction %s 2>&1', + escapeshellarg($consolePath), + escapeshellarg($command) + ); + + $output = []; + $exitCode = 1; + exec($execCommand, $output, $exitCode); + + self::assertSame(0, $exitCode, implode(PHP_EOL, $output)); + } + + private static function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($path); + } +} diff --git a/tests/fixture-app/autoload.php b/tests/fixture-app/autoload.php new file mode 100644 index 0000000..0bde06c --- /dev/null +++ b/tests/fixture-app/autoload.php @@ -0,0 +1,25 @@ +services(); @@ -11,4 +12,10 @@ ->autoconfigure(); $services->load('App\\', '../src/'); + + $services + ->set(App\Controller\GraphqlController::class) + ->public() + ->tag('controller.service_arguments') + ->arg('$locator', service('flexible_graphql.type_registry.service_locator')); }; diff --git a/tests/fixture-app/src/Controller/GraphqlController.php b/tests/fixture-app/src/Controller/GraphqlController.php new file mode 100644 index 0000000..4dce557 --- /dev/null +++ b/tests/fixture-app/src/Controller/GraphqlController.php @@ -0,0 +1,78 @@ + $locator + */ + public function __construct( + private readonly ServiceCollectionInterface $locator, + ) { + } + + public function __invoke(Request $request): Response + { + if (!class_exists('App\\GraphQL\\TypeRegistry', false)) { + $registryFile = dirname(__DIR__, 2) . '/var/generated/TypeRegistry.php'; + if (is_file($registryFile)) { + require_once $registryFile; + } + } + + $registryClass = 'App\\GraphQL\\TypeRegistry'; + $typeRegistry = new $registryClass($this->locator); + $schema = new Schema([ + 'query' => $typeRegistry->getType('Query'), + 'mutation' => $typeRegistry->getType('Mutation'), + 'directives' => $typeRegistry->getDirectives(), + ]); + + $validationRules = array_merge( + GraphQL::getStandardValidationRules(), + [ + new Rules\QueryComplexity(PHP_INT_MAX), + ] + ); + + $debugFlag = DebugFlag::INCLUDE_DEBUG_MESSAGE + | DebugFlag::INCLUDE_TRACE + | DebugFlag::RETHROW_INTERNAL_EXCEPTIONS + | DebugFlag::RETHROW_UNSAFE_EXCEPTIONS; + + $rawBody = $request->getContent(); + $body = []; + if ($rawBody !== '') { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $body = $decoded; + } + } + + $executionResult = GraphQL::executeQuery( + $schema, + (string) ($body['query'] ?? ''), + null, + [], + (array) ($body['variables'] ?? []), + is_string($body['operationName'] ?? null) ? $body['operationName'] : null, + null, + $validationRules + ); + + return new JsonResponse($executionResult->toArray($debugFlag)); + } +} diff --git a/tests/fixture-app/src/GraphQL/Directive/UpperDirective.php b/tests/fixture-app/src/GraphQL/Directive/UpperDirective.php new file mode 100644 index 0000000..2d5fd9d --- /dev/null +++ b/tests/fixture-app/src/GraphQL/Directive/UpperDirective.php @@ -0,0 +1,22 @@ +readValue($args, 'input', []); + $title = (string) $this->readValue($input, 'title', 'Untitled'); + + $result = new stdClass(); + $result->id = 'grp-created-1'; + $result->title = $title; + + return $result; + } + + private function readValue(mixed $source, string $key, mixed $default): mixed + { + if ($source instanceof ArrayAccess && $source->offsetExists($key)) { + return $source[$key]; + } + + if (is_array($source) && array_key_exists($key, $source)) { + return $source[$key]; + } + + return $default; + } +} diff --git a/tests/fixture-app/src/GraphQL/Resolver/Query/GreetResolver.php b/tests/fixture-app/src/GraphQL/Resolver/Query/GreetResolver.php new file mode 100644 index 0000000..08dbb18 --- /dev/null +++ b/tests/fixture-app/src/GraphQL/Resolver/Query/GreetResolver.php @@ -0,0 +1,36 @@ +readValue($args, 'name', ''); + $input = $this->readValue($args, 'input', []); + + $prefix = (string) $this->readValue($input, 'prefix', ''); + $suffix = (string) $this->readValue($input, 'suffix', ''); + + return trim(sprintf('%s %s%s', $prefix, $name, $suffix)); + } + + private function readValue(mixed $source, string $key, mixed $default): mixed + { + if ($source instanceof ArrayAccess && $source->offsetExists($key)) { + return $source[$key]; + } + + if (is_array($source) && array_key_exists($key, $source)) { + return $source[$key]; + } + + return $default; + } +} diff --git a/tests/fixture-app/src/GraphQL/Resolver/Query/SearchResolver.php b/tests/fixture-app/src/GraphQL/Resolver/Query/SearchResolver.php new file mode 100644 index 0000000..9fdb042 --- /dev/null +++ b/tests/fixture-app/src/GraphQL/Resolver/Query/SearchResolver.php @@ -0,0 +1,46 @@ +readValue($args, 'kind', 'user'); + + $result = new stdClass(); + if ($kind === 'group') { + $result->__typename = 'GroupSearchResult'; + $result->id = 'group-1'; + $result->title = 'Core Team'; + + return $result; + } + + $result->__typename = 'UserSearchResult'; + $result->id = 'user-1'; + $result->username = 'alice'; + + return $result; + } + + private function readValue(mixed $source, string $key, mixed $default): mixed + { + if ($source instanceof ArrayAccess && $source->offsetExists($key)) { + return $source[$key]; + } + + if (is_array($source) && array_key_exists($key, $source)) { + return $source[$key]; + } + + return $default; + } +} diff --git a/tests/fixture-app/src/GraphQL/UnionResolveType/SearchResultTypeResolver.php b/tests/fixture-app/src/GraphQL/UnionResolveType/SearchResultTypeResolver.php new file mode 100644 index 0000000..5a0574b --- /dev/null +++ b/tests/fixture-app/src/GraphQL/UnionResolveType/SearchResultTypeResolver.php @@ -0,0 +1,20 @@ +__typename)) { + return $info->schema->getType((string) $model->__typename); + } + + return null; + } +} diff --git a/tests/fixture-app/src/Kernel.php b/tests/fixture-app/src/Kernel.php index 075405b..c325222 100644 --- a/tests/fixture-app/src/Kernel.php +++ b/tests/fixture-app/src/Kernel.php @@ -4,6 +4,7 @@ namespace App; +use App\Controller\GraphqlController; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Kernel as BaseKernel; @@ -37,5 +38,8 @@ protected function configureContainer(ContainerConfigurator $container): void protected function configureRoutes(RoutingConfigurator $routes): void { + $routes->add('app_graphql', '/graphql') + ->controller(GraphqlController::class) + ->methods(['POST']); } }