├── .circleci └── config.yml ├── .dependabot └── config.yml ├── .gitignore ├── Classes ├── Context.php ├── Controller │ └── GraphQLController.php ├── Directive │ ├── AuthDirective.php │ ├── CachedDirective.php │ └── CostDirective.php ├── Exception │ ├── InvalidContextException.php │ └── InvalidResolverException.php ├── Http │ └── HttpOptionsMiddleware.php ├── Log │ └── RequestLoggerInterface.php ├── Package.php ├── ResolveCacheInterface.php ├── ResolverGeneratorInterface.php ├── ResolverInterface.php ├── Resolvers.php ├── SchemaEnvelopeInterface.php ├── Service │ ├── DefaultFieldResolver.php │ ├── SchemaService.php │ └── ValidationRuleService.php └── Transform │ └── FlowErrorTransform.php ├── Configuration ├── Caches.yaml ├── Objects.yaml ├── Production │ └── Settings.yaml ├── Routes.yaml ├── Settings.yaml └── Testing │ ├── Caches.yaml │ ├── Routes.yaml │ └── Settings.yaml ├── LICENSE ├── Readme.md ├── Resources └── Private │ └── GraphQL │ └── schema.root.graphql ├── Tests └── Functional │ ├── Directive │ ├── AuthDirectiveTest.php │ ├── CachedDirectiveTest.php │ ├── CostDirectiveTest.php │ └── Fixtures │ │ ├── QueryResolver.php │ │ └── schema.graphql │ └── GraphQLFunctionTestCase.php ├── composer.json ├── composer.json.ci └── phpcs.xml.dist /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | aliases: 4 | - &ci-build-image quay.io/yeebase/ci-build:7.3 5 | - &workspace_root ~/workspace 6 | 7 | - &save_composer_cache 8 | key: composer-cache-v1-{{ .Branch }}-{{ checksum "composer.json" }} 9 | paths: 10 | - /composer/cache-dir 11 | 12 | - &restore_composer_cache 13 | keys: 14 | - composer-cache-v1-{{ .Branch }}-{{ checksum "composer.json.ci" }} 15 | - composer-cache-v1-{{ .Branch }}- 16 | - composer-cache-v1- 17 | 18 | - &attach_workspace 19 | at: *workspace_root 20 | 21 | - &persist_to_workspace 22 | root: . 23 | paths: 24 | - . 25 | 26 | jobs: 27 | checkout: 28 | docker: 29 | - image: *ci-build-image 30 | environment: 31 | COMPOSER_CACHE_DIR: /composer/cache-dir 32 | steps: 33 | - checkout 34 | - restore_cache: *restore_composer_cache 35 | 36 | - run: | 37 | mkdir graphql 38 | shopt -s extglob dotglob 39 | mv !(graphql) graphql 40 | shopt -u dotglob 41 | cp graphql/composer.json.ci composer.json 42 | cp graphql/phpcs.xml.dist phpcs.xml.dist 43 | composer update 44 | 45 | - save_cache: *save_composer_cache 46 | - persist_to_workspace: *persist_to_workspace 47 | 48 | lint: 49 | working_directory: *workspace_root 50 | docker: 51 | - image: *ci-build-image 52 | steps: 53 | - attach_workspace: *attach_workspace 54 | - run: bin/phpcs graphql/Classes 55 | 56 | tests: 57 | working_directory: *workspace_root 58 | docker: 59 | - image: *ci-build-image 60 | environment: 61 | FLOW_CONTEXT: Testing 62 | steps: 63 | - attach_workspace: *attach_workspace 64 | - run: bin/phpunit -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml graphql/Tests/Functional 65 | 66 | workflows: 67 | version: 2 68 | build_and_test: 69 | jobs: 70 | - checkout: 71 | filters: 72 | branches: 73 | ignore: /dependabot.*/ 74 | - lint: 75 | requires: 76 | - checkout 77 | - tests: 78 | requires: 79 | - checkout 80 | 81 | build_and_test_dependabot: 82 | jobs: 83 | - hold: 84 | type: approval 85 | filters: 86 | branches: 87 | only: /dependabot.*/ 88 | - checkout: 89 | requires: 90 | - hold 91 | - lint: 92 | requires: 93 | - checkout 94 | - tests: 95 | requires: 96 | - checkout 97 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "php:composer" 4 | directory: "/" 5 | update_schedule: "weekly" 6 | target_branch: "master" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | -------------------------------------------------------------------------------- /Classes/Context.php: -------------------------------------------------------------------------------- 1 | request = $controllerContext->getRequest()->getMainRequest(); 18 | } 19 | 20 | public function getRequest(): RequestInterface 21 | { 22 | return $this->request; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Classes/Controller/GraphQLController.php: -------------------------------------------------------------------------------- 1 | request->getArgument('variables'))) { 77 | $variables = json_decode($this->request->getArgument('variables'), true); 78 | } 79 | 80 | $schema = $this->schemaService->getSchemaForEndpoint($endpoint); 81 | $validationRules = $this->validationRuleService->getValidationRulesForEndpoint($endpoint); 82 | 83 | $endpointConfiguration = $this->endpointConfigurations[$endpoint] ?? []; 84 | 85 | if (isset($endpointConfiguration['context'])) { 86 | $contextClassname = $endpointConfiguration['context']; 87 | } else { 88 | $contextClassname = $this->contextClassName; 89 | } 90 | 91 | $context = new $contextClassname($this->controllerContext); 92 | if (! $context instanceof Context) { 93 | throw new InvalidContextException('The configured Context must extend \t3n\GraphQL\Context', 1545945332); 94 | } 95 | 96 | if (isset($endpointConfiguration['logRequests']) && $endpointConfiguration['logRequests'] === true) { 97 | $this->requestLogger->info('Incoming graphql request', ['endpoint' => $endpoint, 'query' => json_encode($query), 'variables' => empty($variables) ? 'none' : $variables]); 98 | } 99 | 100 | GraphQL::setDefaultFieldResolver([DefaultFieldResolver::class, 'resolve']); 101 | 102 | $result = GraphQL::executeQuery( 103 | $schema, 104 | $query, 105 | null, 106 | $context, 107 | $variables, 108 | $operationName, 109 | null, 110 | $validationRules 111 | ); 112 | 113 | $this->response->setContentType('application/json'); 114 | return json_encode($result->toArray()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Classes/Directive/AuthDirective.php: -------------------------------------------------------------------------------- 1 | authenticatedRoles = array_keys($securityContext->getRoles()); 22 | } 23 | 24 | protected function wrapResolver(FieldDefinition $field): void 25 | { 26 | $resolve = $field->resolveFn ?? [DefaultFieldResolver::class, 'resolve']; 27 | 28 | $field->resolveFn = function ($source, $args, $context, $info) use ($resolve) { 29 | if (! in_array($this->args['required'], $this->authenticatedRoles)) { 30 | throw new Error('Not allowed'); 31 | } 32 | 33 | return $resolve($source, $args, $context, $info); 34 | }; 35 | } 36 | 37 | /** 38 | * @param mixed[] $details 39 | */ 40 | public function visitFieldDefinition(FieldDefinition $field, array $details): void 41 | { 42 | $this->wrapResolver($field); 43 | } 44 | 45 | public function visitObject(ObjectType $object): void 46 | { 47 | foreach ($object->getFields() as $field) { 48 | $this->wrapResolver($field); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Classes/Directive/CachedDirective.php: -------------------------------------------------------------------------------- 1 | resolveFn ?? [DefaultFieldResolver::class, 'resolve']; 29 | 30 | $field->resolveFn = function ($root, $variables, $context, ResolveInfo $resolveInfo) use ($resolve) { 31 | $entryIdentifier = md5(implode('.', $resolveInfo->path) . json_encode($variables)); 32 | 33 | if ($this->cache->has($entryIdentifier)) { 34 | return $this->cache->get($entryIdentifier); 35 | } 36 | 37 | $result = $resolve($root, $variables, $context, $resolveInfo); 38 | 39 | $this->cache->set($entryIdentifier, $result, $this->args['tags'], $this->args['maxAge']); 40 | return $result; 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Classes/Directive/CostDirective.php: -------------------------------------------------------------------------------- 1 | objectComplexityMap = new \ArrayObject(); 22 | } 23 | 24 | /** 25 | * @param mixed[] $details 26 | */ 27 | public function visitFieldDefinition(FieldDefinition $field, array $details): void 28 | { 29 | $complexity = $this->args['complexity'] ?? null; 30 | $multipliers = $this->args['multipliers']; 31 | 32 | $complexityFn = function (int $childrenComplexity, array $args) use ($complexity, $multipliers, $field): int { 33 | $typeName = Type::getNamedType($field->getType())->name; 34 | $typeComplexity = $this->objectComplexityMap[$typeName] ?? 1; 35 | $complexity = $complexity ?? $typeComplexity; 36 | 37 | $multiplier = 0; 38 | foreach ($multipliers as $multiplierArg) { 39 | $arg = Arrays::getValueByPath($args, $multiplierArg); 40 | if ($arg === null) { 41 | continue; 42 | } 43 | 44 | if (is_array($arg)) { 45 | $multiplier += count($args); 46 | } else { 47 | $multiplier += $arg; 48 | } 49 | } 50 | 51 | $multiplier = max(1, $multiplier); 52 | return ($complexity + $childrenComplexity) * $multiplier; 53 | }; 54 | 55 | ObjectAccess::setProperty($field, 'complexityFn', $complexityFn, true); 56 | } 57 | 58 | public function visitObject(ObjectType $object): void 59 | { 60 | $complexity = $this->args['complexity'] ?? null; 61 | $this->objectComplexityMap[$object->name] = $complexity ?? 1; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Classes/Exception/InvalidContextException.php: -------------------------------------------------------------------------------- 1 | getMethod() !== 'OPTIONS') { 26 | return $handler->handle($request); 27 | } 28 | 29 | // We explode the request target because custom routes like /some/custom/route/ are 30 | // are common. So we double check here if the last part in the route matches a configured 31 | // endpoint 32 | $endpoint = explode('/', ltrim($request->getRequestTarget(), '\/')); 33 | 34 | if (! isset($this->endpoints[end($endpoint)])) { 35 | return $handler->handle($request); 36 | } 37 | 38 | return new Response(200, ['Content-Type' => 'application/json', 'Allow' => 'GET, POST'], json_encode(['success' => true])); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Classes/Log/RequestLoggerInterface.php: -------------------------------------------------------------------------------- 1 | monitorTypeDefResources($schemaConfiguration, $packageManager, $fileMonitor); 31 | } 32 | return; 33 | } 34 | 35 | $typeDefs = $configuration['typeDefs'] ?? null; 36 | 37 | if (! $typeDefs) { 38 | return; 39 | } 40 | 41 | if (substr($typeDefs, 0, 11) !== 'resource://') { 42 | return; 43 | } 44 | 45 | $uriParts = Functions::parse_url($typeDefs); 46 | /** @var BasePackage $package */ 47 | $package = $packageManager->getPackage($uriParts['host']); 48 | $absolutePath = Files::concatenatePaths([$package->getResourcesPath(), $uriParts['path']]); 49 | $fileMonitor->monitorFile($absolutePath); 50 | } 51 | 52 | public function boot(Bootstrap $bootstrap): void 53 | { 54 | if ($bootstrap->getContext()->isProduction()) { 55 | return; 56 | } 57 | 58 | $dispatcher = $bootstrap->getSignalSlotDispatcher(); 59 | $dispatcher->connect(Sequence::class, 'afterInvokeStep', function (Step $step) use ($bootstrap): void { 60 | if ($step->getIdentifier() !== 'neos.flow:systemfilemonitor') { 61 | return; 62 | } 63 | 64 | $graphQLFileMonitor = FileMonitor::createFileMonitorAtBoot(static::FILE_MONITOR_IDENTIFIER, $bootstrap); 65 | $configurationManager = $bootstrap->getEarlyInstance(ConfigurationManager::class); 66 | $packageManager = $bootstrap->getEarlyInstance(PackageManager::class); 67 | $endpointsConfiguration = $configurationManager->getConfiguration( 68 | ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, 69 | 't3n.GraphQL.endpoints' 70 | ); 71 | 72 | foreach ($endpointsConfiguration as $endpointConfiguration) { 73 | $this->monitorTypeDefResources($endpointConfiguration, $packageManager, $graphQLFileMonitor); 74 | } 75 | 76 | $graphQLFileMonitor->detectChanges(); 77 | $graphQLFileMonitor->shutdownObject(); 78 | }); 79 | 80 | $dispatcher->connect( 81 | FileMonitor::class, 82 | 'filesHaveChanged', 83 | static function (string $fileMonitorIdentifier, array $changedFiles) use ($bootstrap): void { 84 | if ($fileMonitorIdentifier !== static::FILE_MONITOR_IDENTIFIER || count($changedFiles) === 0) { 85 | return; 86 | } 87 | 88 | /** @var CacheManager $cacheManager */ 89 | $cacheManager = $bootstrap->getObjectManager()->get(CacheManager::class); 90 | $cacheManager->getCache('t3n_GraphQL_Schema')->flush(); 91 | } 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Classes/ResolveCacheInterface.php: -------------------------------------------------------------------------------- 1 | \Resolver\Class\Name] 14 | * ]; 15 | * 16 | * @return mixed[] 17 | */ 18 | public function generate(): array; 19 | } 20 | -------------------------------------------------------------------------------- /Classes/ResolverInterface.php: -------------------------------------------------------------------------------- 1 | get(ReflectionService::class); 59 | $classNames = $reflectionService->getAllImplementationClassNamesForInterface(ResolverInterface::class); 60 | 61 | $types = []; 62 | foreach ($classNames as $className) { 63 | $classReflection = new ReflectionClass($className); 64 | 65 | $fields = array_filter( 66 | array_map( 67 | static function (ReflectionMethod $method): string { 68 | return $method->getName(); 69 | }, 70 | $classReflection->getMethods(ReflectionMethod::IS_PUBLIC) 71 | ), 72 | static function (string $methodName): bool { 73 | return in_array($methodName, self::$internalGraphQLMethods) || substr($methodName, 0, 2) !== '__'; 74 | } 75 | ); 76 | 77 | $types[$className] = [ 78 | 'typeName' => preg_replace('/Resolver/', '', $classReflection->getShortName()), 79 | 'fields' => $fields, 80 | ]; 81 | } 82 | 83 | return $types; 84 | } 85 | 86 | protected function __construct() 87 | { 88 | } 89 | 90 | protected function initialize(): void 91 | { 92 | if ($this->resolvers) { 93 | return; 94 | } 95 | 96 | $typeMap = static::aggregateTypes($this->objectManager); 97 | $resolverClasses = []; 98 | 99 | if ($this->generator) { 100 | $generator = $this->objectManager->get($this->generator); 101 | 102 | if (! $generator instanceof ResolverGeneratorInterface) { 103 | throw new InvalidResolverException(sprintf('The configured resolver %s generator must implement ResolverGeneratorInterface', $this->generator)); 104 | } 105 | $resolverClasses = $generator->generate(); 106 | } 107 | 108 | if ($this->pathPattern) { 109 | foreach ($typeMap as $className => $info) { 110 | $possibleMatch = str_replace('{Type}', $info['typeName'], $this->pathPattern); 111 | 112 | if ($className !== $possibleMatch) { 113 | continue; 114 | } 115 | 116 | $resolverClasses[$info['typeName']] = $className; 117 | } 118 | } 119 | 120 | foreach ($this->types as $typeName => $className) { 121 | if (! isset($typeMap[$className])) { 122 | continue; 123 | } 124 | 125 | $resolverClasses[$typeName] = $className; 126 | } 127 | 128 | foreach ($resolverClasses as $typeName => $className) { 129 | $fields = $typeMap[$className]['fields']; 130 | $this->resolvers[$typeName] = []; 131 | foreach ($fields as $fieldName) { 132 | $this->resolvers[$typeName][$fieldName] = function (...$args) use ($className, $fieldName) { 133 | return $this->objectManager->get($className)->$fieldName(...$args); 134 | }; 135 | } 136 | } 137 | } 138 | 139 | public function withGenerator(string $generatorClassname): self 140 | { 141 | $this->generator = $generatorClassname; 142 | return $this; 143 | } 144 | 145 | public function withPathPattern(string $pathPattern): self 146 | { 147 | $this->pathPattern = trim($pathPattern, '/'); 148 | return $this; 149 | } 150 | 151 | public function withType(string $typeName, string $className): self 152 | { 153 | $this->types[$typeName] = trim($className, '/'); 154 | return $this; 155 | } 156 | 157 | /** 158 | * @param mixed $offset 159 | */ 160 | public function offsetExists($offset): bool 161 | { 162 | $this->initialize(); 163 | return isset($this->resolvers); 164 | } 165 | 166 | /** 167 | * @param mixed $offset 168 | * 169 | * @return mixed|null 170 | */ 171 | public function offsetGet($offset) 172 | { 173 | $this->initialize(); 174 | return $this->resolvers[$offset] ?? null; 175 | } 176 | 177 | /** 178 | * @param mixed $offset 179 | * @param mixed $value 180 | */ 181 | public function offsetSet($offset, $value): void 182 | { 183 | // not implemented on purpose 184 | } 185 | 186 | /** 187 | * @param mixed $offset 188 | */ 189 | public function offsetUnset($offset): void 190 | { 191 | // not implemented on purpose 192 | } 193 | 194 | public function getIterator(): ArrayIterator 195 | { 196 | $this->initialize(); 197 | return new ArrayIterator($this->resolvers); 198 | } 199 | 200 | /** 201 | * @return mixed[] 202 | */ 203 | public function toArray(): array 204 | { 205 | $this->initialize(); 206 | return $this->resolvers; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /Classes/SchemaEnvelopeInterface.php: -------------------------------------------------------------------------------- 1 | fieldName; 28 | if (is_object($source) && ObjectAccess::isPropertyGettable($source, $fieldName)) { 29 | $resolvedProperty = ObjectAccess::getProperty($source, $fieldName); 30 | } 31 | 32 | if ($resolvedProperty instanceof \Closure) { 33 | return $resolvedProperty($source, $args, $context, $info); 34 | } 35 | 36 | return $resolvedProperty; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Classes/Service/SchemaService.php: -------------------------------------------------------------------------------- 1 | firstLevelCache[$endpoint])) { 58 | return $this->firstLevelCache[$endpoint]; 59 | } 60 | 61 | $endpointConfiguration = $this->endpoints[$endpoint] ?? null; 62 | 63 | if (! $endpointConfiguration) { 64 | throw new InvalidArgumentException(sprintf('No schema found for endpoint "%s"', $endpoint)); 65 | } 66 | 67 | if (isset($endpointConfiguration['schemas'])) { 68 | $schema = $this->getMergedSchemaFromConfigurations($endpointConfiguration); 69 | } else { 70 | $schema = $this->getMergedSchemaFromConfigurations([ 'schemas' => [$endpointConfiguration] ]); 71 | } 72 | 73 | $this->firstLevelCache[$endpoint] = $schema; 74 | return $schema; 75 | } 76 | 77 | /** 78 | * @return mixed[] 79 | */ 80 | public function getEndpointConfiguration(string $endpoint): ?array 81 | { 82 | return $this->endpoints[$endpoint] ?? null; 83 | } 84 | 85 | protected function getSchemaFromEnvelope(string $envelopeClassName): Schema 86 | { 87 | $envelope = $this->objectManager->get($envelopeClassName); 88 | if (! $envelope instanceof SchemaEnvelopeInterface) { 89 | throw new TypeError(sprintf('%s has to implement %s', $envelopeClassName, SchemaEnvelopeInterface::class)); 90 | } 91 | 92 | return $envelope->getSchema(); 93 | } 94 | 95 | /** 96 | * @param mixed[] $configuration 97 | * 98 | * @return mixed[] 99 | */ 100 | protected function getSchemaFromConfiguration(array $configuration): array 101 | { 102 | $options = ['typeDefs' => '']; 103 | 104 | if (substr($configuration['typeDefs'], 0, 11) === 'resource://') { 105 | $options['typeDefs'] = Files::getFileContents($configuration['typeDefs']); 106 | if ($options['typeDefs'] === false) { 107 | throw new TypeError(sprintf('File "%s" does not exist', $configuration['typeDefs'])); 108 | } 109 | } else { 110 | $options['typeDefs'] = $configuration['typeDefs']; 111 | } 112 | 113 | $resolvers = Resolvers::create(); 114 | 115 | if (isset($configuration['resolverGenerator'])) { 116 | if ($this->objectManager->isRegistered($configuration['resolverGenerator'])) { 117 | $resolvers->withGenerator($configuration['resolverGenerator']); 118 | } 119 | } 120 | 121 | if (isset($configuration['resolverPathPattern'])) { 122 | $resolvers->withPathPattern($configuration['resolverPathPattern']); 123 | } 124 | 125 | if (isset($configuration['resolvers']) && is_array($configuration['resolvers'])) { 126 | foreach ($configuration['resolvers'] as $typeName => $resolverClass) { 127 | $resolvers->withType($typeName, $resolverClass); 128 | } 129 | } 130 | 131 | $options['resolvers'] = $resolvers; 132 | 133 | if (isset($configuration['schemaDirectives']) && is_array($configuration['schemaDirectives'])) { 134 | $options['schemaDirectives'] = []; 135 | foreach ($configuration['schemaDirectives'] as $directiveName => $schemaDirectiveVisitor) { 136 | $options['schemaDirectives'][$directiveName] = new $schemaDirectiveVisitor(); 137 | } 138 | } 139 | 140 | if (isset($configuration['transforms']) && is_array($configuration['transforms'])) { 141 | $options['transforms'] = array_map( 142 | static function (string $transformClassName): Transform { 143 | return new $transformClassName(); 144 | }, 145 | $configuration['transforms'] 146 | ); 147 | } 148 | 149 | return $options; 150 | } 151 | 152 | /** 153 | * @param mixed[] $configuration 154 | * 155 | * @return mixed[] 156 | */ 157 | protected function getMergedSchemaFromConfigurations(array $configuration): Schema 158 | { 159 | $schemaConfigurations = (new PositionalArraySorter($configuration['schemas']))->toArray(); 160 | 161 | $executableSchemas = []; 162 | 163 | // Determine default error transformer 164 | if (array_key_exists('errorTransform', $configuration) && ! empty($configuration['errorTransform']) && class_exists($configuration['errorTransform'])) { 165 | $transforms = [new $configuration['errorTransform']()]; 166 | } else { 167 | $transforms = [new FlowErrorTransform()]; 168 | } 169 | 170 | $options = [ 171 | 'typeDefs' => [], 172 | 'resolvers' => [], 173 | 'schemaDirectives' => [], 174 | 'resolverValidationOptions' => [ 175 | 'allowResolversNotInSchema' => $configuration['resolverValidationOptions']['allowResolversNotInSchema'] ?? true, 176 | 'requireResolversForResolveType' => $configuration['resolverValidationOptions']['requireResolversForResolveType'] ?? null, 177 | ], 178 | ]; 179 | 180 | foreach ($schemaConfigurations as $schemaConfiguration) { 181 | if (isset($schemaConfiguration['schemaEnvelope'])) { 182 | $executableSchemas[] = $this->getSchemaFromEnvelope($schemaConfiguration['schemaEnvelope']); 183 | } else { 184 | $schemaInfo = $this->getSchemaFromConfiguration($schemaConfiguration); 185 | $options['typeDefs'][] = $schemaInfo['typeDefs']; 186 | $options['resolvers'] = array_merge_recursive($options['resolvers'], $schemaInfo['resolvers']->toArray()); 187 | $options['schemaDirectives'] = array_merge($options['schemaDirectives'], $schemaInfo['schemaDirectives'] ?? []); 188 | $transforms = array_merge($transforms, $schemaInfo['transforms'] ?? []); 189 | } 190 | } 191 | 192 | if (isset($configuration['schemaDirectives'])) { 193 | foreach ($configuration['schemaDirectives'] as $directiveName => $schemaDirectiveVisitorClassName) { 194 | $schemaDirectiveVisitor = new $schemaDirectiveVisitorClassName(); 195 | 196 | if (! $schemaDirectiveVisitor instanceof SchemaDirectiveVisitor) { 197 | throw new TypeError(sprintf('%s has to extend %s', $schemaDirectiveVisitorClassName, SchemaDirectiveVisitor::class)); 198 | } 199 | 200 | $options['schemaDirectives'][$directiveName] = $schemaDirectiveVisitor; 201 | } 202 | } 203 | 204 | $schema = null; 205 | if (count($options['typeDefs']) > 0) { 206 | $cacheIdentifier = md5(serialize($options['typeDefs'])); 207 | if ($this->schemaCache->has($cacheIdentifier)) { 208 | $options['typeDefs'] = $this->schemaCache->get($cacheIdentifier); 209 | } else { 210 | $options['typeDefs'] = Parser::parse(ConcatenateTypeDefs::invoke($options['typeDefs'])); 211 | $this->schemaCache->set($cacheIdentifier, $options['typeDefs']); 212 | } 213 | $schema = GraphQLTools::makeExecutableSchema($options); 214 | } 215 | 216 | if ($schema) { 217 | $executableSchemas[] = $schema; 218 | } 219 | 220 | if (count($executableSchemas) > 1) { 221 | $schema = GraphQLTools::mergeSchemas(['schemas' => $executableSchemas]); 222 | } else { 223 | $schema = $executableSchemas[0]; 224 | } 225 | 226 | if (isset($configuration['transforms'])) { 227 | $transformConfiguration = (new PositionalArraySorter($configuration['transforms']))->toArray(); 228 | 229 | foreach ($transformConfiguration as $transformClassName) { 230 | $transforms[] = new $transformClassName(); 231 | } 232 | } 233 | 234 | return GraphQLTools::transformSchema($schema, $transforms); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Classes/Service/ValidationRuleService.php: -------------------------------------------------------------------------------- 1 | endpoints[$endpoint]['validationRules'] ?? []; 30 | $validationRulesConfiguration = (new PositionalArraySorter($rawValidationRulesConfiguration))->toArray(); 31 | 32 | $addedRules = array_map( 33 | static function (array $validationRuleConfiguration): ValidationRule { 34 | $className = $validationRuleConfiguration['className']; 35 | $arguments = $validationRuleConfiguration['arguments'] ?? []; 36 | return new $className(...array_values($arguments)); 37 | }, 38 | $validationRulesConfiguration 39 | ); 40 | 41 | return array_merge( 42 | DocumentValidator::allRules(), 43 | $addedRules 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Classes/Transform/FlowErrorTransform.php: -------------------------------------------------------------------------------- 1 | errors = array_map(function (Error $error) { 32 | $previousError = $error->getPrevious(); 33 | if (! $previousError instanceof Error) { 34 | $message = $this->throwableStorage->logThrowable($previousError); 35 | 36 | if (! $this->includeExceptionMessageInOutput) { 37 | $message = preg_replace('/.* - See also: (.+)\.txt$/s', 'Internal error ($1)', $message); 38 | } 39 | 40 | return new Error( 41 | $message, 42 | $error->getNodes(), 43 | $error->getSource(), 44 | $error->getPositions(), 45 | $error->getPath(), 46 | $previousError 47 | ); 48 | } 49 | 50 | return $error; 51 | }, $result->errors); 52 | 53 | return $result; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Configuration/Caches.yaml: -------------------------------------------------------------------------------- 1 | t3n_GraphQL_Resolve: 2 | frontend: Neos\Cache\Frontend\VariableFrontend 3 | backend: Neos\Cache\Backend\FileBackend 4 | 5 | t3n_GraphQL_Schema: 6 | frontend: Neos\Cache\Frontend\VariableFrontend 7 | backend: Neos\Cache\Backend\FileBackend 8 | -------------------------------------------------------------------------------- /Configuration/Objects.yaml: -------------------------------------------------------------------------------- 1 | t3n\GraphQL\ResolveCacheInterface: 2 | factoryObjectName: Neos\Flow\Cache\CacheManager 3 | factoryMethodName: getCache 4 | arguments: 5 | 1: 6 | value: t3n_GraphQL_Resolve 7 | 8 | t3n\GraphQL\Service\SchemaService: 9 | properties: 10 | 'schemaCache': 11 | object: 12 | factoryObjectName: Neos\Flow\Cache\CacheManager 13 | factoryMethodName: getCache 14 | arguments: 15 | 1: 16 | value: t3n_GraphQL_Schema 17 | 18 | t3n\GraphQL\Log\RequestLoggerInterface: 19 | scope: singleton 20 | factoryObjectName: Neos\Flow\Log\PsrLoggerFactoryInterface 21 | factoryMethodName: get 22 | arguments: 23 | 1: 24 | value: graphQLRequestLogger 25 | -------------------------------------------------------------------------------- /Configuration/Production/Settings.yaml: -------------------------------------------------------------------------------- 1 | t3n: 2 | GraphQL: 3 | includeExceptionMessageInOutput: false 4 | -------------------------------------------------------------------------------- /Configuration/Routes.yaml: -------------------------------------------------------------------------------- 1 | - 2 | name: 'graphql - endpoint' 3 | uriPattern: '' 4 | defaults: 5 | '@package': 't3n.GraphQL' 6 | '@controller': 'GraphQL' 7 | '@action': 'query' 8 | '@format': 'json' 9 | 'endpoint': '' 10 | httpMethods: [POST] 11 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Flow: 3 | http: 4 | middlewares: 5 | 'graphQLOptions': 6 | position: 'before routing' 7 | middleware: 't3n\GraphQL\Http\HttpOptionsMiddleware' 8 | log: 9 | graphQLRequestLogger: 10 | logger: Neos\Flow\Log\Logger 11 | backend: Neos\Flow\Log\Backend\FileBackend 12 | backendOptions: 13 | logFileURL: '%FLOW_PATH_DATA%Logs/GraphQLRequests.log' 14 | createParentDirectories: true 15 | severityThreshold: '%LOG_INFO%' 16 | maximumLogFileSize: 10485760 17 | logFilesToKeep: 1 18 | logMessageOrigin: false 19 | 20 | t3n: 21 | GraphQL: 22 | context: 't3n\GraphQL\Context' 23 | includeExceptionMessageInOutput: true 24 | endpoints: [] 25 | # 'some-endpoint': 26 | # 'logRequests': true # if enabled all requests are logged 27 | # 'context:' 'Foo\Vendor\GraphQL\Context # optional context that overrides the global context 28 | # 'schemaEnvelope': 'Some\Fully\Qualified\Namespace' 29 | # 'errorTransform': t3n\GraphQL\Transform\FlowErrorTransform # optional: override the default error transformer for this schema 30 | -------------------------------------------------------------------------------- /Configuration/Testing/Caches.yaml: -------------------------------------------------------------------------------- 1 | t3n_GraphQL_Resolve: 2 | frontend: Neos\Cache\Frontend\VariableFrontend 3 | backend: Neos\Cache\Backend\TransientMemoryBackend 4 | 5 | t3n_GraphQL_Schema: 6 | frontend: Neos\Cache\Frontend\VariableFrontend 7 | backend: Neos\Cache\Backend\TransientMemoryBackend 8 | -------------------------------------------------------------------------------- /Configuration/Testing/Routes.yaml: -------------------------------------------------------------------------------- 1 | - 2 | name: 'test GraphQL API' 3 | uriPattern: 'api-test/test-endpoint' 4 | defaults: 5 | '@package': 't3n.GraphQL' 6 | '@controller': 'GraphQL' 7 | '@action': 'query' 8 | '@format': 'json' 9 | 'endpoint': 'test-endpoint' 10 | httpMethods: [POST] 11 | -------------------------------------------------------------------------------- /Configuration/Testing/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Flow: 3 | mvc: 4 | routes: 5 | 't3n.GraphQL': 6 | position: 'start' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 t3n – digital pioneers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/t3n/graphql.svg?style=svg)](https://circleci.com/gh/t3n/graphql) [![Latest Stable Version](https://poser.pugx.org/t3n/graphql/v/stable)](https://packagist.org/packages/t3n/graphql) [![Total Downloads](https://poser.pugx.org/t3n/graphql/downloads)](https://packagist.org/packages/t3n/graphql) 2 | 3 | # t3n.GraphQL 4 | 5 | Flow Package to add graphql APIs to [Neos and Flow](https://neos.io) that also supports advanced features like schema stitching, validation rules, schema directives and more. 6 | This package doesn't provide a GraphQL client to test your API. We suggest to use the [GraphlQL Playground](https://github.com/prisma/graphql-playground) 7 | 8 | Simply install the package via composer: 9 | 10 | ```bash 11 | composer require "t3n/graphql" 12 | ``` 13 | 14 | Version 2.x supports neos/flow >= 6.0.0 15 | 16 | ## Configuration 17 | 18 | In order to use your GraphQL API endpoint some configuration is necessary. 19 | 20 | ### Endpoints 21 | 22 | Let's assume that the API should be accessible under the URL http://localhost/api/my-endpoint. 23 | 24 | To make this possible, you first have to add the route to your `Routes.yaml`: 25 | 26 | ```yaml 27 | - name: 'GraphQL API' 28 | uriPattern: 'api/' 29 | subRoutes: 30 | 'GraphQLSubroutes': 31 | package: 't3n.GraphQL' 32 | variables: 33 | 'endpoint': 'my-endpoint' 34 | ``` 35 | 36 | Don't forget to load your routes at all: 37 | 38 | ```yaml 39 | Neos: 40 | Flow: 41 | mvc: 42 | routes: 43 | 'Your.Package': 44 | position: 'start' 45 | ``` 46 | 47 | Now the route is activated and available. 48 | 49 | ### Schema 50 | 51 | The next step is to define a schema that can be queried. 52 | 53 | Create a `schema.graphql` file: 54 | 55 | /Your.Package/Resources/Private/GraphQL/schema.root.graphql 56 | 57 | ```graphql schema 58 | type Query { 59 | ping: String! 60 | } 61 | 62 | type Mutation { 63 | pong: String! 64 | } 65 | 66 | schema { 67 | query: Query 68 | mutation: Mutation 69 | } 70 | ``` 71 | 72 | Under the hood we use [t3n/graphql-tools](https://github.com/t3n/graphql-tools). This package is a php port from 73 | [Apollos graphql-tools](https://github.com/apollographql/graphql-tools/). This enables you to use some advanced 74 | features like schema stitching. So it's possible to configure multiple schemas per endpoint. All schemas 75 | will be merged internally together to a single schema. 76 | 77 | Add a schema to your endpoint like this: 78 | 79 | ```yaml 80 | t3n: 81 | GraphQL: 82 | endpoints: 83 | 'my-endpoint': # use your endpoint variable here 84 | schemas: 85 | root: # use any key you like here 86 | typeDefs: 'resource://Your.Package/Private/GraphQL/schema.root.graphql' 87 | ``` 88 | 89 | To add another schema just add a new entry below the `schemas` index for your endpoint. 90 | 91 | You can also use the extend feature: 92 | 93 | /Your.Package/Resources/Private/GraphQL/schema.yeah.graphql 94 | 95 | ```graphql schema 96 | extend type Query { 97 | yippie: String! 98 | } 99 | ``` 100 | 101 | ```yaml 102 | t3n: 103 | GraphQL: 104 | endpoints: 105 | 'my-endpoint': # 106 | schemas: 107 | yeah: 108 | typeDefs: 'resource://Your.Package/Private/GraphQL/schema.yeah.graphql' 109 | ``` 110 | 111 | ### Resolver 112 | 113 | Now you need to add some Resolver. You can add a Resolver for each of your types. 114 | Given this schema: 115 | 116 | ```graphql schema 117 | type Query { 118 | product(id: ID!): Product 119 | products: [Product] 120 | } 121 | 122 | type Product { 123 | id: ID! 124 | name: String! 125 | price: Float! 126 | } 127 | ``` 128 | 129 | You might want to configure Resolver for both types: 130 | 131 | ```yaml 132 | t3n: 133 | GraphQL: 134 | endpoints: 135 | 'my-endpoint': 136 | schemas: 137 | mySchema: 138 | resolvers: 139 | Query: 'Your\Package\GraphQL\Resolver\QueryResolver' 140 | Product: 'Your\Package\GraphQL\Resolver\ProductResolver' 141 | ``` 142 | 143 | Each resolver must implement `t3n\GraphQL\ResolverInterface` ! 144 | 145 | You can also add resolvers dynamically so you don't have to configure each resolver separately: 146 | 147 | ```yaml 148 | t3n: 149 | GraphQL: 150 | endpoints: 151 | 'my-endpoint': 152 | schemas: 153 | mySchema: 154 | resolverPathPattern: 'Your\Package\GraphQL\Resolver\Type\{Type}Resolver' 155 | resolvers: 156 | Query: 'Your\Package\GraphQL\Resolver\QueryResolver' 157 | ``` 158 | 159 | With this configuration the class `Your\Package\GraphQL\Resolver\Type\ProductResolver` would be responsible 160 | for queries on a Product type. The {Type} will evaluate to your type name. 161 | 162 | As a third option you can create resolvers programmatically. Therefore you can register a class that implements 163 | the `t3n\GraphQL\ResolverGeneratorInterface`. This might be useful to auto generate a resolver mapping: 164 | 165 | ```yaml 166 | t3n: 167 | GraphQL: 168 | endpoints: 169 | 'my-endpoint': 170 | schemas: 171 | mySchema: 172 | resolverGenerator: 'Your\Package\GraphQL\Resolver\ResolverGenerator' 173 | ``` 174 | 175 | The Generator must return an array with this structure: ['typeName' => \Resolver\Class\Name] 176 | 177 | ☝️ Note: Your Resolver can override each other. All resolver configurations are applied in this order: 178 | - ResolverGenerator 179 | - dynamic "{Type}Resolver" 180 | - specific Resolver 181 | 182 | #### Resolver Implementation 183 | 184 | A implementation for our example could look like this (pseudocode): 185 | 186 | ```php 187 | someServiceToFetchProducts->findAll(); 202 | } 203 | 204 | public function product($_, $variables): ?Product 205 | { 206 | $id = $variables['id']; 207 | return $this->someServiceToFetchProducts->getProductById($id); 208 | } 209 | } 210 | ``` 211 | 212 | ```php 213 | getName(); 226 | } 227 | } 228 | ``` 229 | 230 | An example query like: 231 | 232 | ```graphql 233 | query { 234 | products { 235 | id 236 | name 237 | price 238 | } 239 | } 240 | ``` 241 | 242 | would invoke the QueryResolver in first place and call the `products()` method. This method 243 | returns an array with Product objects. For each of the objects the ProductResolver is used. 244 | To fetch the actual value there is a DefaultFieldResolver. If you do not configure a method 245 | named as the requests property it will be used to fetch the value. The DefaultFieldResolver 246 | will try to fetch the data itself via `ObjectAccess::getProperty($source, $fieldName)`. 247 | So if your Product Object has a `getName()` it will be used. You can still overload the 248 | implementation just like in the example. 249 | 250 | All resolver methods share the same signature: 251 | 252 | ```php 253 | method($source, $args, $context, $info) 254 | ``` 255 | 256 | #### Interface Types 257 | 258 | When working with interfaces, you need Resolvers for your interfaces as well. Given this schema: 259 | 260 | ```graphql schema 261 | interface Person { 262 | firstName: String! 263 | lastName: String! 264 | } 265 | 266 | type Customer implements Person { 267 | firstName: String! 268 | lastName: String! 269 | email: String! 270 | } 271 | 272 | type Supplier implements Person { 273 | firstName: String! 274 | lastName: String! 275 | phone: String 276 | } 277 | ``` 278 | 279 | You need to configure a `Person` Resolver as well as Resolvers for the concrete implementations. 280 | 281 | While the concrete Type Resolvers do not need any special attention, the `Person` Resolver implements the 282 | `__resolveType` function returning the type names: 283 | 284 | ```php 285 | isActive() ? 'ACTIVE' : 'INACTIVE'; 334 | } 335 | ``` 336 | 337 | #### Scalar Types 338 | 339 | Types at the leaves of a Json tree are defined by Scalars. Own Scalars are implemented by a Resolver 340 | implementing the functions `serialize()`, `parseLiteral()` and `parseValue()`. 341 | 342 | This example shows the implementation of a `DateTime` Scalar Type. For the given Schema definition: 343 | 344 | ```graphql schema 345 | scalar DateTime 346 | ``` 347 | 348 | The `DateTimeResolver` looks the following when working with Unix timestamps: 349 | 350 | ```php 351 | getTimestamp(); 365 | } 366 | 367 | public function parseLiteral(Node $ast): ?DateTime 368 | { 369 | if ($ast instanceof IntValueNode) { 370 | $dateTime = new DateTime(); 371 | $dateTime->setTimestamp((int)$ast->value); 372 | return $dateTime; 373 | } 374 | return null; 375 | } 376 | 377 | public function parseValue(int $value): DateTime 378 | { 379 | $dateTime = new DateTime(); 380 | $dateTime->setTimestamp($value); 381 | return $dateTime; 382 | } 383 | } 384 | ``` 385 | 386 | You have to make the `DateTimeResolver` available again through one of the configuration options in the Settings.yaml. 387 | 388 | ### Context 389 | 390 | The third argument in your Resolver method signature is the Context. 391 | By Default it's set to `t3n\GraphQContext` which exposes the current request. 392 | 393 | It's easy to set your very own Context per endpoint. This might be handy to share some Code or Objects 394 | between all your Resolver implementations. Make sure to extend `t3n\GraphQContext` 395 | 396 | Let's say we have an graphql endpoint for a shopping basket (simplified): 397 | 398 | ```graphql schema 399 | type Query { 400 | basket: Basket 401 | } 402 | 403 | type Mutation { 404 | addItem(item: BasketItemInput): Basket 405 | } 406 | 407 | type Basket { 408 | items: [BasketItem] 409 | amount: Float! 410 | } 411 | 412 | type BasketItem { 413 | id: ID! 414 | name: String! 415 | price: Float! 416 | } 417 | 418 | input BasketItemInput { 419 | name: String! 420 | price: Float! 421 | } 422 | ``` 423 | 424 | First of all configure your context for your shopping endpoint: 425 | 426 | ```yaml 427 | t3n: 428 | GraphQL: 429 | endpoints: 430 | 'shop': 431 | context: 'Your\Package\GraphQL\ShoppingBasketContext' 432 | schemas: 433 | basket: 434 | typeDefs: 'resource://Your.Package/Private/GraphQL/schema.graphql' 435 | resolverPathPattern: 'Your\Package\GraphQL\Resolver\Type\{Type}Resolver' 436 | resolvers: 437 | Query: 'Your\Package\GraphQL\Resolver\QueryResolver' 438 | Mutation: 'Your\Package\GraphQL\Resolver\MutationResolver' 439 | ``` 440 | 441 | A context for this scenario would inject the current basket (probably flow session scoped); 442 | 443 | ```php 444 | basket; 466 | } 467 | } 468 | ``` 469 | 470 | And the corresponding resolver classes: 471 | 472 | ```php 473 | getBasket(); 488 | } 489 | } 490 | ``` 491 | 492 | ```php 493 | setName($variables['name']); 509 | $item->setPrice($variables['price']); 510 | 511 | $basket = $context->getBasket(); 512 | $basket->addItem($item); 513 | 514 | return $basket; 515 | } 516 | } 517 | ``` 518 | 519 | ### Log incoming requests 520 | 521 | You can enable logging of incoming requests per endpoint: 522 | 523 | ```yaml 524 | t3n: 525 | GraphQL: 526 | endpoints: 527 | 'your-endpoint': 528 | logRequests: true 529 | ``` 530 | 531 | Once activated all incoming requests will be logged to `Data/Logs/GraphQLRequests.log`. Each log entry 532 | will contain the endpoint, query and variables. 533 | 534 | ### Secure your endpoint 535 | 536 | To secure your api endpoints you have several options. The easiest way is to just configure 537 | some privilege for your Resolver: 538 | 539 | ```yaml 540 | privilegeTargets: 541 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 542 | 'Your.Package:Queries': 543 | matcher: 'method(public Your\Package\GraphQL\Resolver\QueryResolver->.*())' 544 | 'Your.Package:Mutations': 545 | matcher: 'method(public Your\Package\GraphQL\Resolver\MutationResolver->.*())' 546 | 547 | roles: 548 | 'Your.Package:SomeRole': 549 | privileges: 550 | - privilegeTarget: 'Your.Package:Queries' 551 | permission: GRANT 552 | - privilegeTarget: 'Your.Package:Mutations' 553 | permission: GRANT 554 | ``` 555 | 556 | You could also use a custom Context to access the current logged in user. 557 | 558 | ### Schema directives 559 | 560 | By default this package provides three directives: 561 | 562 | - AuthDirective 563 | - CachedDirective 564 | - CostDirective 565 | 566 | To enable those Directives add this configuration to your endpoint: 567 | 568 | ```yaml 569 | t3n: 570 | GraphQL: 571 | endpoints: 572 | 'your-endpoint': 573 | schemas: 574 | root: # use any key you like here 575 | typeDefs: 'resource://t3n.GraphQL/Private/GraphQL/schema.root.graphql' 576 | schemaDirectives: 577 | auth: 't3n\GraphQL\Directive\AuthDirective' 578 | cached: 't3n\GraphQL\Directive\CachedDirective' 579 | cost: 't3n\GraphQL\Directive\CostDirective' 580 | ``` 581 | 582 | #### AuthDirective 583 | 584 | The AuthDirective will check the security context for current authenticated roles. 585 | This enables you to protect objects or fields to user with given roles. 586 | 587 | Use it like this to allow Editors to update a product but restrict the removal to Admins only: 588 | 589 | ```graphql schema 590 | type Mutation { 591 | updateProduct(): Product @auth(required: "Neos.Neos:Editor") 592 | removeProduct(): Boolean @auth(required: "Neos.Neos:Administrator") 593 | } 594 | ``` 595 | 596 | #### CachedDirective 597 | 598 | Caching is always a thing. Some queries might be expensive to resolve and it's worthy to cache the result. 599 | Therefore you should use the CachedDirective: 600 | 601 | ```graphql schema 602 | type Query { 603 | getProduct(id: ID!): Product @cached(maxAge: 100, tags: ["some-tag", "another-tag"]) 604 | } 605 | ``` 606 | 607 | The CachedDirective will use a flow cache `t3n_GraphQL_Resolve` as a backend. The directive accepts a maxAge 608 | argument as well as tags. Check the flow documentation about caching to learn about them! 609 | The cache entry identifier will respect all arguments (id in this example) as well as the query path. 610 | 611 | #### CostDirective 612 | 613 | The CostDirective will add a complexity function to your fields and objects which is used by some validation rules. 614 | Each type and children has a default complexity of 1. 615 | It allows you to annotate cost values and multipliers just like this: 616 | 617 | ```graphql schema 618 | type Product @cost(complexity: 5) { 619 | name: String! @cost(complexity: 3) 620 | price: Float! 621 | } 622 | 623 | type Query { 624 | products(limit: Int!): [Product!]! @cost(multipliers: ["limit"]) 625 | } 626 | ``` 627 | 628 | If you query `produts(limit: 3) { name, price }` the query would have a cost of: 629 | 630 | 9 per product (5 for the product itself and 3 for fetching the name and 1 for the price (default complexity)) multiplied with 3 631 | cause we defined the limit value as an multiplier. So the query would have a total complexity of 27. 632 | 633 | ### Validation rules 634 | 635 | There are several Validation rules you can enable per endpoint. The most common are the QueryDepth as well as the QueryComplexity 636 | rule. Configure your endpoint to enable those rules: 637 | 638 | ```yaml 639 | t3n: 640 | GraphQL: 641 | endpoints: 642 | 'some-endpoint': 643 | validationRules: 644 | depth: 645 | className: 'GraphQL\Validator\Rules\QueryDepth' 646 | arguments: 647 | maxDepth: 11 648 | complexity: 649 | className: 'GraphQL\Validator\Rules\QueryComplexity' 650 | arguments: 651 | maxQueryComplexity: 1000 652 | ``` 653 | 654 | The `maxQueryComplexitiy` is calculated via the CostDirective. 655 | -------------------------------------------------------------------------------- /Resources/Private/GraphQL/schema.root.graphql: -------------------------------------------------------------------------------- 1 | directive @cached( 2 | maxAge: Int! = 0 3 | tags: [String!] = [] 4 | ) on FIELD_DEFINITION 5 | 6 | directive @auth( 7 | required: String 8 | ) on OBJECT | FIELD_DEFINITION 9 | 10 | directive @cost( 11 | complexity: Int 12 | multipliers: [String!] = [] 13 | ) on OBJECT | FIELD_DEFINITION 14 | 15 | type Query { 16 | ping: String 17 | } 18 | 19 | type Mutation { 20 | ping: String 21 | } 22 | 23 | schema { 24 | query: Query 25 | mutation: Mutation 26 | } 27 | -------------------------------------------------------------------------------- /Tests/Functional/Directive/AuthDirectiveTest.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'Query' => QueryResolver::class, 22 | ], 23 | 'schemaDirectives' => [ 24 | 'auth' => AuthDirective::class, 25 | ], 26 | ]; 27 | 28 | $query = '{ secureValue1 }'; 29 | 30 | $result = $this->executeQuery($schema, $configuration, $query); 31 | 32 | static::assertFalse(isset($result['data'])); 33 | static::assertCount(1, $result['errors']); 34 | static::assertEquals('Not allowed', $result['errors'][0]['message']); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function directiveWillGrantAccessIfAuthorized(): void 41 | { 42 | $schema = __DIR__ . '/Fixtures/schema.graphql'; 43 | $configuration = [ 44 | 'resolvers' => [ 45 | 'Query' => QueryResolver::class, 46 | ], 47 | 'schemaDirectives' => [ 48 | 'auth' => AuthDirective::class, 49 | ], 50 | ]; 51 | 52 | $query = '{ secureValue2 }'; 53 | 54 | $result = $this->executeQuery($schema, $configuration, $query); 55 | 56 | static::assertFalse(isset($result['errors'])); 57 | static::assertEquals('secret2', $result['data']['secureValue2']); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/Functional/Directive/CachedDirectiveTest.php: -------------------------------------------------------------------------------- 1 | resolveCache = $this->objectManager->get(ResolveCacheInterface::class); 23 | $this->resolveCache->flush(); 24 | } 25 | 26 | /** 27 | * @test 28 | */ 29 | public function cachedDirectiveWillCacheValue(): void 30 | { 31 | $schema = __DIR__ . '/Fixtures/schema.graphql'; 32 | $configuration = [ 33 | 'resolvers' => [ 34 | 'Query' => QueryResolver::class, 35 | ], 36 | 'schemaDirectives' => [ 37 | 'cached' => CachedDirective::class, 38 | ], 39 | ]; 40 | 41 | $query = '{ cachedValue }'; 42 | 43 | $result = $this->executeQuery($schema, $configuration, $query); 44 | 45 | static::assertFalse(isset($result['errors']), 'graphql query did not execute without errors'); 46 | static::assertEquals('cachedResult', $result['data']['cachedValue']); 47 | 48 | /** @var QueryResolver $queryResolver */ 49 | $queryResolver = $this->objectManager->get(QueryResolver::class); 50 | $queryResolver->currentValue = 'newValue'; 51 | 52 | $result = $this->executeQuery($schema, $configuration, $query); 53 | static::assertEquals('cachedResult', $result['data']['cachedValue']); 54 | 55 | $this->resolveCache->flushByTag('my-test-tag'); 56 | 57 | $result = $this->executeQuery($schema, $configuration, $query); 58 | static::assertEquals('newValue', $result['data']['cachedValue']); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Functional/Directive/CostDirectiveTest.php: -------------------------------------------------------------------------------- 1 | $typeDefs, 23 | 'resolvers' => [ 24 | 'Query' => [ 25 | 'cheapTypes' => static function ($_, array $args) { 26 | $limit = $args['limit']; 27 | return array_fill( 28 | 0, 29 | $limit, 30 | ['value1' => 'aaa'] 31 | ); 32 | }, 33 | 'expensiveTypes' => static function ($_, array $args) { 34 | $limit = $args['limit']; 35 | return array_fill( 36 | 0, 37 | $limit, 38 | ['value1' => 'aaa', 'value2' => 'bbb', 'value3' => 'ccc'] 39 | ); 40 | }, 41 | ], 42 | ], 43 | 'schemaDirectives' => [ 44 | 'cost' => new CostDirective(), 45 | ], 46 | ]); 47 | 48 | return GraphQL::executeQuery( 49 | $schema, 50 | $query, 51 | null, 52 | null, 53 | null, 54 | null, 55 | null, 56 | [new QueryComplexity($maxComplexity)] 57 | ); 58 | } 59 | 60 | protected static function assertComplexity(ExecutionResult $result, int $complexity): void 61 | { 62 | static::assertCount(1, $result->errors); 63 | static::assertEquals( 64 | 'Max query complexity should be 1 but got ' . $complexity . '.', 65 | $result->errors[0]->getMessage() 66 | ); 67 | } 68 | 69 | /** 70 | * @test 71 | * 72 | * Default complexity is 1 for fields and types, so 73 | * 74 | * type CheapType { 75 | * value1: String 76 | * } 77 | * 78 | * has a complexity of 2 79 | * 80 | * Query.cheapTypes is multiplied with 5 (limit) 81 | * total complexity will be 2 * 5 = 10 82 | */ 83 | public function costDirectiveShouldRespectMultiplier(): void 84 | { 85 | $query = '{ cheapTypes(limit: 5) { value2 } }'; 86 | $result = $this->execute($query); 87 | static::assertComplexity($result, 10); 88 | } 89 | 90 | /** 91 | * @test 92 | * 93 | * type ExpensiveType #cost(complexity: 5) { 94 | * value1: String 95 | * } 96 | * 97 | * ExpensiveType.value1 has a complexity of 5 + 1 = 6 98 | * multiplied with 6 (limit) will result in 6 * 6 = 36 99 | */ 100 | public function costDirectiveShouldRespectObjects(): void 101 | { 102 | $query = '{ expensiveTypes(limit: 6) { value1 } }'; 103 | $result = $this->execute($query); 104 | static::assertComplexity($result, 36); 105 | } 106 | 107 | /** 108 | * @test 109 | * 110 | * type ExpensiveType #cost(complexity: 5) { 111 | * value2: String #cost(complexity: 3) 112 | * } 113 | * 114 | * expensiveType.value2 has a complexity of 5 + 3 = 8 115 | * multiplied with 6 (limit) will result in 8 * 6 = 48 116 | */ 117 | public function costDirectiveShouldRespectObjectsAndFields(): void 118 | { 119 | $query = '{ expensiveTypes(limit: 6) { value2 } }'; 120 | $result = $this->execute($query); 121 | static::assertComplexity($result, 48); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Tests/Functional/Directive/Fixtures/QueryResolver.php: -------------------------------------------------------------------------------- 1 | currentValue; 21 | } 22 | 23 | public function secureValue1(): string 24 | { 25 | return 'secret1'; 26 | } 27 | 28 | public function secureValue2(): string 29 | { 30 | return 'secret2'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/Functional/Directive/Fixtures/schema.graphql: -------------------------------------------------------------------------------- 1 | directive @auth( 2 | required: String 3 | ) on OBJECT | FIELD_DEFINITION 4 | 5 | directive @cached( 6 | maxAge: Int! = 0 7 | tags: [String!] = [] 8 | ) on FIELD_DEFINITION 9 | 10 | directive @cost( 11 | complexity: Int 12 | multipliers: [String!] = [] 13 | ) on OBJECT | FIELD_DEFINITION 14 | 15 | type CheapType { 16 | value1: String 17 | } 18 | 19 | type ExpensiveType @cost(complexity: 5) { 20 | value1: String 21 | value2: String @cost(complexity: 3) 22 | value3: String 23 | } 24 | 25 | type Query { 26 | secureValue1: String! @auth(required: "UnknownRole") 27 | secureValue2: String! @auth(required: "Neos.Flow:Everybody") 28 | cachedValue: String! @cached(maxAge: 100, tags: ["my-test-tag"]) 29 | 30 | cheapTypes(limit: Int!): [CheapType!]! @cost(multipliers: ["limit"]) 31 | expensiveTypes(limit: Int!): [ExpensiveType!]! @cost(multipliers: ["limit"]) 32 | } 33 | -------------------------------------------------------------------------------- /Tests/Functional/GraphQLFunctionTestCase.php: -------------------------------------------------------------------------------- 1 | $endpointConfiguration]; 27 | 28 | $schemaService = $this->objectManager->get(SchemaService::class); 29 | $this->inject($schemaService, 'endpoints', $endpointsConfiguration); 30 | $this->inject($schemaService, 'firstLevelCache', []); 31 | 32 | $this->browser->addAutomaticRequestHeader('content-type', 'application/json'); 33 | $response = $this->browser->request(new Uri('http://localhost/api-test/' . static::TEST_ENDPOINT), 'POST', ['query' => $query]); 34 | $this->browser->removeAutomaticRequestHeader('content-type'); 35 | 36 | $content = $response->getBody()->getContents(); 37 | if ($response->getStatusCode() === 500) { 38 | throw new \Exception($content); 39 | } 40 | 41 | return json_decode($content, true); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Neos Flow adapter for graphql", 3 | "type": "neos-package", 4 | "name": "t3n/graphql", 5 | "config": { 6 | "bin-dir": "bin" 7 | }, 8 | "require": { 9 | "neos/flow": "~7.0 || ^8.0 || dev-master", 10 | "t3n/graphql-tools": "~1.0.2", 11 | "php": ">=7.2", 12 | "ext-json": "*" 13 | }, 14 | "require-dev": { 15 | "t3n/coding-standard": "~1.1.0" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "t3n\\GraphQL\\": "Classes/" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "t3n\\GraphQL\\": "Classes/" 25 | } 26 | }, 27 | "extra": { 28 | "neos": { 29 | "package-key": "t3n.GraphQL" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composer.json.ci: -------------------------------------------------------------------------------- 1 | { 2 | "name": "t3n/test-setup", 3 | "description": "Test setup for flow packages", 4 | "config": { 5 | "vendor-dir": "Packages/Libraries", 6 | "bin-dir": "bin" 7 | }, 8 | "require": { 9 | "neos/flow": "~7.0", 10 | "neos/buildessentials": "~7.0", 11 | "t3n/graphql": "@dev", 12 | "t3n/coding-standard": "~1.1.0" 13 | }, 14 | "require-dev": { 15 | "squizlabs/php_codesniffer": "~3.5", 16 | "phpunit/phpunit": "~9.3", 17 | "mikey179/vfsstream": "~1.6" 18 | }, 19 | "repositories": { 20 | "srcPackage": { 21 | "type": "path", 22 | "url": "./graphql" 23 | } 24 | }, 25 | "scripts": { 26 | "post-update-cmd": "Neos\\Flow\\Composer\\InstallerScripts::postUpdateAndInstall", 27 | "post-install-cmd": "Neos\\Flow\\Composer\\InstallerScripts::postUpdateAndInstall", 28 | "post-package-update": "Neos\\Flow\\Composer\\InstallerScripts::postPackageUpdateAndInstall", 29 | "post-package-install": "Neos\\Flow\\Composer\\InstallerScripts::postPackageUpdateAndInstall" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Classes 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | --------------------------------------------------------------------------------