diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ea363ff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.{php,php}] +indent_style = tab +indent_size = 4 + +[*.neon] +indent_style = tab +indent_size = 4 + +[composer.json] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8a0053 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +log/* +vendor/* +!.gitignore +composer.lock diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..c1bc898 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,6 @@ +tools: + external_code_coverage: true + +checks: + php: + code_rating: true \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..58c0795 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,34 @@ +language: php + +sudo: false + +php: + - 7.1 + +matrix: + include: + - php: 7.1 + env: PHPUNIT_FLAGS="--coverage-clover=coverage.xml" CHECK_CS=false + +install: + # install composer dependencies + - composer install --prefer-source + +script: + # run tests + - vendor/bin/phpunit $PHPUNIT_FLAGS + # check coding standard (defined in composer.json "scripts" section) + #- if [[ "$CHECK_CS" != "" ]]; then composer check-cs; fi + +after_script: + # upload coverage.xml file to Scrutinizer to analyze it + - | + if [[ "$PHPUNIT_FLAGS" != "" ]]; then + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover coverage.xml + fi + +# do not send success notifications, they have no value +notifications: + email: + on_success: never diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0f7761a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2018 Tomáš Pilař + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e72ec0 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# GraphQL + +[![Build Status](https://img.shields.io/travis/portiny/graphql.svg?style=flat-square)](https://travis-ci.org/portiny/graphql) +[![Quality Score](https://img.shields.io/scrutinizer/g/portiny/graphql.svg?style=flat-square)](https://scrutinizer-ci.com/g/portiny/graphql) +[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/portiny/graphql.svg?style=flat-square)](https://scrutinizer-ci.com/g/portiny/graphql) +[![Downloads this Month](https://img.shields.io/packagist/dt/portiny/graphql.svg?style=flat-square)](https://packagist.org/packages/portiny/graphql) +[![Latest stable](https://img.shields.io/github/release/portiny/graphql.svg?style=flat-square)](https://packagist.org/packages/portiny/graphql) + +Lightweight GraphQL integration extension for Nette framework. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dbcb2a8 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "portiny/graphql", + "description": "Lightweight GraphQL integration extension for Nette framework.", + "license": ["MIT"], + "require": { + "php": "~7.1", + "nette/http": "^2.4", + "nette/utils": "^2.4", + "tracy/tracy": "^2.4", + "webonyx/graphql-php": "^0.11" + }, + "require-dev": { + "nette/di": "^2.4", + "nette/bootstrap": "^2.4", + "phpunit/phpunit": "^6.5" + }, + "autoload": { + "psr-4": { + "Portiny\\GraphQL\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Portiny\\GraphQL\\Tests\\": "tests" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b3804e6 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + tests + + + + + src + + + diff --git a/src/Adapter/Nette/DI/GraphQLExtension.php b/src/Adapter/Nette/DI/GraphQLExtension.php new file mode 100644 index 0000000..8de183f --- /dev/null +++ b/src/Adapter/Nette/DI/GraphQLExtension.php @@ -0,0 +1,85 @@ +getContainerBuilder(); + $config = $this->loadFromFile(__DIR__ . '/../config/config.neon'); + Compiler::loadDefinitions($builder, $config['services'] ?: []); + } + + + /** + * {@inheritdoc} + */ + public function beforeCompile() + { + $this->setupMutationFieldProvider(); + $this->setupQueryFieldProvider(); + $this->setupGraphQLProcessor(); + } + + + private function setupMutationFieldProvider() + { + $containerBuilder = $this->getContainerBuilder(); + + $mutationFieldProvider = $containerBuilder->addDefinition($this->prefix('mutationFieldsProvider')) + ->setFactory(MutationFieldsProvider::class) + ->setType(MutationFieldsProviderInterface::class) + ->setInject(FALSE); + + $mutationFieldDefinitions = $containerBuilder->findByType(MutationFieldInterface::class); + foreach ($mutationFieldDefinitions as $mutationFieldDefinition) { + $mutationFieldProvider->addSetup('addField', ['@' . $mutationFieldDefinition->getType()]); + } + } + + + private function setupQueryFieldProvider() + { + $containerBuilder = $this->getContainerBuilder(); + + $queryFieldProvider = $containerBuilder->addDefinition($this->prefix('queryFieldsProvider')) + ->setFactory(QueryFieldsProvider::class) + ->setType(QueryFieldsProviderInterface::class) + ->setInject(FALSE); + + $queryFieldDefinitions = $containerBuilder->findByType(QueryFieldInterface::class); + foreach ($queryFieldDefinitions as $queryFieldDefinition) { + $queryFieldProvider->addSetup('addField', ['@' . $queryFieldDefinition->getType()]); + } + } + + + private function setupGraphQLProcessor() + { + $containerBuilder = $this->getContainerBuilder(); + + $containerBuilder->addDefinition($this->prefix('graphQLProcessor')) + ->setFactory(GraphQLProcessor::class) + ->addSetup('setMutationFieldsProvider', ['@' . MutationFieldsProviderInterface::class]) + ->addSetup('setQueryFieldsProvider', ['@' . QueryFieldsProviderInterface::class]); + } + +} diff --git a/src/Adapter/Nette/config/config.neon b/src/Adapter/Nette/config/config.neon new file mode 100644 index 0000000..657a3c3 --- /dev/null +++ b/src/Adapter/Nette/config/config.neon @@ -0,0 +1,2 @@ +services: + - Portiny\GraphQL\Http\Request\JsonRequestParser diff --git a/src/Contract/Field/QueryFieldInterface.php b/src/Contract/Field/QueryFieldInterface.php new file mode 100644 index 0000000..8e4f139 --- /dev/null +++ b/src/Contract/Field/QueryFieldInterface.php @@ -0,0 +1,39 @@ +getName() => [ + 'type' => $mutationField->getType(), + 'description' => $mutationField->getDescription(), + 'args' => $mutationField->getArgs(), + 'resolve' => function ($root, $args, $context) use ($mutationField) { + return call_user_func_array([$mutationField, 'resolve'], [$root, $args, $context]); + } + ] + ]; + } + + + public static function toObject(array $data): MutationFieldInterface + { + // TODO + } + +} diff --git a/src/Converter/QueryFieldConverter.php b/src/Converter/QueryFieldConverter.php new file mode 100644 index 0000000..3be4ff9 --- /dev/null +++ b/src/Converter/QueryFieldConverter.php @@ -0,0 +1,33 @@ +getName() => [ + 'type' => $queryField->getType(), + 'description' => $queryField->getDescription(), + 'args' => $queryField->getArgs(), + 'resolve' => function ($root, $args, $context) use ($queryField) { + return call_user_func_array([$queryField, 'resolve'], [$root, $args, $context]); + } + ] + ]; + } + + + public static function toObject(array $data): QueryFieldInterface + { + // TODO + } + +} diff --git a/src/Exception/GraphQLException.php b/src/Exception/GraphQLException.php new file mode 100644 index 0000000..f722f04 --- /dev/null +++ b/src/Exception/GraphQLException.php @@ -0,0 +1,13 @@ +kind, [$valueNode]); + } + if ( ! filter_var($valueNode->value, FILTER_VALIDATE_EMAIL)) { + throw new Error('Not a valid email', [$valueNode]); + } + + return $valueNode->value; + } + +} diff --git a/src/GraphQL/Type/Scalar/UrlType.php b/src/GraphQL/Type/Scalar/UrlType.php new file mode 100644 index 0000000..44e2d0d --- /dev/null +++ b/src/GraphQL/Type/Scalar/UrlType.php @@ -0,0 +1,54 @@ +kind, [$ast]); + } + if ( ! is_string($ast->value) || ! filter_var($ast->value, FILTER_VALIDATE_URL)) { + throw new Error('Query error: Not a valid URL', [$ast]); + } + + return $ast->value; + } + +} diff --git a/src/GraphQL/Type/Types.php b/src/GraphQL/Type/Types.php new file mode 100644 index 0000000..329b55c --- /dev/null +++ b/src/GraphQL/Type/Types.php @@ -0,0 +1,28 @@ +requestParser = $requestParser; + } + + + public function setMutationFieldsProvider(MutationFieldsProviderInterface $mutationFieldsProvider) + { + $this->mutationFieldsProvider = $mutationFieldsProvider; + } + + + public function setQueryFieldsProvider(QueryFieldsProviderInterface $queryFieldsProvider) + { + $this->queryFieldsProvider = $queryFieldsProvider; + } + + + /** + * @param array $rootValue + * @param mixed|NULL $context + * @param array|null $allowedQueries + * @param array|null $allowedMutations + */ + public function process( + array $rootValue = [], + $context = NULL, + array $allowedQueries = NULL, + array $allowedMutations = NULL + ): array { + try { + $result = GraphQL::executeQuery( + $this->createSchema($allowedQueries, $allowedMutations), + $this->requestParser->getQuery(), + $rootValue, + $context, + $this->requestParser->getVariables() + ); + + $output = $result->toArray($this->isDebug()); + + } catch (Exception $exception) { + $output = [ + 'error' => [ + 'message' => $exception->getMessage() + ] + ]; + } + + return $output; + } + + + private function createSchema(array $allowedQueries = NULL, array $allowedMutations = NULL): Schema + { + return new Schema([ + 'query' => $this->createQueryObject($allowedQueries), + 'mutation' => $this->createMutationObject($allowedMutations), + ]); + } + + + private function createQueryObject(array $allowedQueries = NULL): ObjectType + { + return new ObjectType([ + 'name' => 'Query', + 'fields' => $this->queryFieldsProvider->convertFieldsToArray($allowedQueries) + ]); + } + + + private function createMutationObject(array $allowedMutations = NULL): ObjectType + { + return new ObjectType([ + 'name' => 'Mutation', + 'fields' => $this->mutationFieldsProvider->convertFieldsToArray($allowedMutations) + ]); + } + + + private function isDebug(): int + { + return ! Debugger::$productionMode ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : 0; + } + +} diff --git a/src/Http/Request/JsonRequestParser.php b/src/Http/Request/JsonRequestParser.php new file mode 100644 index 0000000..7016796 --- /dev/null +++ b/src/Http/Request/JsonRequestParser.php @@ -0,0 +1,45 @@ +getRawBody(); + $this->data = Json::decode($rawData ?: '{}', Json::FORCE_ARRAY); + } + + + /** + * {@inheritdoc} + */ + public function getQuery(): string + { + return $this->data['query'] ?? ''; + } + + + /** + * {@inheritdoc} + */ + public function getVariables(): array + { + return $this->data['variables'] ?? []; + } + +} diff --git a/src/Provider/MutationFieldsProvider.php b/src/Provider/MutationFieldsProvider.php new file mode 100644 index 0000000..2306122 --- /dev/null +++ b/src/Provider/MutationFieldsProvider.php @@ -0,0 +1,75 @@ +ensureFieldIsNotRegistered($mutationField); + + $this->fields[$mutationField->getName()] = $mutationField; + } + + + /** + * {@inheritdoc} + */ + public function getFields(): array + { + return $this->fields; + } + + + /** + * {@inheritdoc} + */ + public function convertFieldsToArray(array $allowedMutations = NULL): array + { + $fields = []; + foreach ($this->getFields() as $field) { + if (is_array($allowedMutations) && ! in_array(get_class($field), $allowedMutations)) { + continue; + } + + $fields += MutationFieldConverter::toArray($field); + } + + return $fields; + } + + + /** + * @throws ExistingMutationFieldException + */ + private function ensureFieldIsNotRegistered(MutationFieldInterface $mutationField) + { + if (isset($this->fields[$mutationField->getName()])) { + throw new ExistingMutationFieldException( + sprintf( + 'Mutation field with name "%s" is already registered.', + $mutationField->getName() + ) + ); + } + } + +} diff --git a/src/Provider/QueryFieldsProvider.php b/src/Provider/QueryFieldsProvider.php new file mode 100644 index 0000000..01b1124 --- /dev/null +++ b/src/Provider/QueryFieldsProvider.php @@ -0,0 +1,75 @@ +ensureFieldIsNotRegistered($queryField); + + $this->fields[$queryField->getName()] = $queryField; + } + + + /** + * {@inheritdoc} + */ + public function getFields(): array + { + return $this->fields; + } + + + /** + * {@inheritdoc} + */ + public function convertFieldsToArray(array $allowedQueries = NULL): array + { + $fields = []; + foreach ($this->getFields() as $field) { + if (is_array($allowedQueries) && ! in_array(get_class($field), $allowedQueries)) { + continue; + } + + $fields += QueryFieldConverter::toArray($field); + } + + return $fields; + } + + + /** + * @throws ExistingQueryFieldException + */ + private function ensureFieldIsNotRegistered(QueryFieldInterface $queryField) + { + if (isset($this->fields[$queryField->getName()])) { + throw new ExistingQueryFieldException( + sprintf( + 'Query field with name "%s" is already registered.', + $queryField->getName() + ) + ); + } + } + +} diff --git a/tests/AbstractContainerTestCase.php b/tests/AbstractContainerTestCase.php new file mode 100644 index 0000000..83451b7 --- /dev/null +++ b/tests/AbstractContainerTestCase.php @@ -0,0 +1,26 @@ +container = ContainerFactory::create(); + } + +} diff --git a/tests/Adapter/Nette/GraphQLExtensionTest.php b/tests/Adapter/Nette/GraphQLExtensionTest.php new file mode 100644 index 0000000..c34cf39 --- /dev/null +++ b/tests/Adapter/Nette/GraphQLExtensionTest.php @@ -0,0 +1,18 @@ +container->getByType(GraphQLProcessor::class); + $this->assertInstanceOf(GraphQLProcessor::class, $graphQLProcessor); + } + +} diff --git a/tests/ContainerFactory.php b/tests/ContainerFactory.php new file mode 100644 index 0000000..9378d29 --- /dev/null +++ b/tests/ContainerFactory.php @@ -0,0 +1,23 @@ +setTempDirectory(TEMP_DIR); + $configurator->addConfig(__DIR__ . '/config/config.neon'); + + return $configurator->createContainer(); + } + +} diff --git a/tests/Converter/MutationFieldConverterTest.php b/tests/Converter/MutationFieldConverterTest.php new file mode 100644 index 0000000..81dc59d --- /dev/null +++ b/tests/Converter/MutationFieldConverterTest.php @@ -0,0 +1,86 @@ +getMutationField(); + $output = MutationFieldConverter::toArray($mutationField); + + $this->assertSame('Some name', key($output)); + + $mutationFieldAsArray = reset($output); + $this->assertInstanceOf(StringType::class, $mutationFieldAsArray['type']); + $this->assertSame('Some description', $mutationFieldAsArray['description']); + $this->assertArrayHasKey('someArg', $mutationFieldAsArray['args']); + $this->assertArrayHasKey('type', $mutationFieldAsArray['args']['someArg']); + $this->assertInstanceOf(StringType::class, $mutationFieldAsArray['args']['someArg']['type']); + $this->assertTrue(is_callable($mutationFieldAsArray['resolve'])); + } + + + private function getMutationField(): MutationFieldInterface + { + return (new class () implements MutationFieldInterface + { + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'Some name'; + } + + + /** + * {@inheritdoc} + */ + public function getType(): Type + { + return Type::string(); + } + + + /** + * {@inheritdoc} + */ + public function getDescription(): string + { + return 'Some description'; + } + + + /** + * {@inheritdoc} + */ + public function getArgs(): array + { + return [ + 'someArg' => ['type' => Type::string()] + ]; + } + + + /** + * {@inheritdoc} + */ + public function resolve(array $root, array $args, $context = NULL) + { + return 'resolved'; + } + + }); + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ac34123 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,12 @@ +