From aaf97516cbc30149e77444f2a4b91f3c4c394232 Mon Sep 17 00:00:00 2001 From: bwaidelich Date: Tue, 1 Aug 2023 16:13:58 +0200 Subject: [PATCH] !!! FEATURE: Major overhaul based on wwwision/types-graphql --- Classes/AbstractResolver.php | 46 -- Classes/AbstractScalarResolver.php | 37 -- Classes/AccessibleObject.php | 129 ----- Classes/Controller/StandardController.php | 73 --- Classes/GraphQLContext.php | 32 -- Classes/GraphQLMiddleware.php | 155 +++++ Classes/GraphQLMiddlewareFactory.php | 50 ++ Classes/Http/HttpOptionsMiddleware.php | 46 -- Classes/IterableAccessibleObject.php | 112 ---- Classes/Package.php | 72 --- Classes/Resolver.php | 114 ++++ Classes/ResolverInterface.php | 11 - Classes/SchemaService.php | 188 ------ Classes/TypeResolver.php | 49 -- Classes/View/GraphQlView.php | 86 --- Configuration/Caches.yaml | 7 +- Configuration/Development/Caches.yaml | 3 + Configuration/Development/Settings.yaml | 3 + Configuration/Objects.yaml | 16 +- Configuration/Routes.yaml | 22 - Configuration/Settings.yaml | 15 +- README.md | 299 +++------- .../Private/Templates/Standard/Index.html | 540 ------------------ Tests/Unit/AccessibleObjectTest.php | 183 ------ Tests/Unit/Fixtures/ExampleObject.php | 66 --- Tests/Unit/Fixtures/InvalidExampleType.php | 29 - Tests/Unit/Fixtures/ValidExampleType.php | 30 - Tests/Unit/GraphQLContextTest.php | 34 -- Tests/Unit/Http/HttpOptionsComponentTest.php | 111 ---- Tests/Unit/IterableAccessibleObjectTest.php | 86 --- Tests/Unit/TypeResolverTest.php | 67 --- composer.json | 13 +- playground.png | Bin 52472 -> 0 bytes 33 files changed, 415 insertions(+), 2309 deletions(-) delete mode 100644 Classes/AbstractResolver.php delete mode 100644 Classes/AbstractScalarResolver.php delete mode 100644 Classes/AccessibleObject.php delete mode 100644 Classes/Controller/StandardController.php delete mode 100644 Classes/GraphQLContext.php create mode 100644 Classes/GraphQLMiddleware.php create mode 100644 Classes/GraphQLMiddlewareFactory.php delete mode 100644 Classes/Http/HttpOptionsMiddleware.php delete mode 100644 Classes/IterableAccessibleObject.php delete mode 100644 Classes/Package.php create mode 100644 Classes/Resolver.php delete mode 100644 Classes/ResolverInterface.php delete mode 100644 Classes/SchemaService.php delete mode 100644 Classes/TypeResolver.php delete mode 100644 Classes/View/GraphQlView.php create mode 100644 Configuration/Development/Caches.yaml create mode 100644 Configuration/Development/Settings.yaml delete mode 100644 Configuration/Routes.yaml delete mode 100644 Resources/Private/Templates/Standard/Index.html delete mode 100644 Tests/Unit/AccessibleObjectTest.php delete mode 100644 Tests/Unit/Fixtures/ExampleObject.php delete mode 100644 Tests/Unit/Fixtures/InvalidExampleType.php delete mode 100644 Tests/Unit/Fixtures/ValidExampleType.php delete mode 100644 Tests/Unit/GraphQLContextTest.php delete mode 100644 Tests/Unit/Http/HttpOptionsComponentTest.php delete mode 100644 Tests/Unit/IterableAccessibleObjectTest.php delete mode 100644 Tests/Unit/TypeResolverTest.php delete mode 100644 playground.png diff --git a/Classes/AbstractResolver.php b/Classes/AbstractResolver.php deleted file mode 100644 index 9d0c71b..0000000 --- a/Classes/AbstractResolver.php +++ /dev/null @@ -1,46 +0,0 @@ - $config) { - $resolveMethod = [$this, $name]; - if (is_callable($resolveMethod)) { - $config['resolve'] = $resolveMethod; - } - - $resolvedFields[$name] = $config; - } - - return $resolvedFields; - }; - - return $typeConfig; - } -} diff --git a/Classes/AbstractScalarResolver.php b/Classes/AbstractScalarResolver.php deleted file mode 100644 index d3a29e6..0000000 --- a/Classes/AbstractScalarResolver.php +++ /dev/null @@ -1,37 +0,0 @@ -object = $object; - } - - /** - * @return object - */ - public function getObject() - { - return $this->object; - } - - /** - * @param string $propertyName - * @return bool - */ - public function offsetExists($propertyName) - { - if ($this->object === null) { - return false; - } - if (preg_match('/^(is|has)([A-Z])/', $propertyName) === 1) { - return is_callable([$this->object, $propertyName]); - } - return ObjectAccess::isPropertyGettable($this->object, $propertyName); - } - - /** - * @param string $propertyName - * @return mixed - */ - public function offsetGet($propertyName) - { - if ($this->object === null) { - return null; - } - if (preg_match('/^(is|has)([A-Z])/', $propertyName) === 1) { - return (boolean)call_user_func([$this->object, $propertyName]); - } - $result = ObjectAccess::getProperty($this->object, $propertyName); - if ($result instanceof \IteratorAggregate) { - return new IterableAccessibleObject($result->getIterator()); - } - if (is_array($result) || $result instanceof \Iterator) { - return new IterableAccessibleObject($result); - } - if ($result instanceof \DateTimeInterface) { - return $result; - } - if (is_object($result)) { - return new self($result); - } - return $result; - } - - /** - * @param string $offset - * @param mixed $value - */ - public function offsetSet($offset, $value) - { - throw new \RuntimeException('The AccessibleObject wrapper does not allow for mutation!', 1460895624); - } - - /** - * @param string $offset - */ - public function offsetUnset($offset) - { - throw new \RuntimeException('The AccessibleObject wrapper does not allow for mutation!', 1460895625); - } - - /** - * This is required in order to implicitly cast wrapped string types for example - * - * @return string - */ - function __toString() - { - return (string)$this->object; - } - - -} \ No newline at end of file diff --git a/Classes/Controller/StandardController.php b/Classes/Controller/StandardController.php deleted file mode 100644 index 0948c18..0000000 --- a/Classes/Controller/StandardController.php +++ /dev/null @@ -1,73 +0,0 @@ - GraphQlView::class]; - - /** - * @param string $endpoint The GraphQL endpoint, to allow for providing multiple APIs (this value is set from the routing usually) - * @return void - */ - public function indexAction($endpoint) - { - $this->schemaService->verifySettings($endpoint); - $this->view->assign('endpoint', $endpoint); - } - - /** - * @param string $endpoint The GraphQL endpoint, to allow for providing multiple APIs (this value is set from the routing usually) - * @param string $query The GraphQL query string (see GraphQL::execute()) - * @param array $variables list of variables (if any, see GraphQL::execute()). Note: The variables can be JSON-serialized to a string or a "real" array - * @param string $operationName The operation to execute (if multiple, see GraphQL::execute()) - * @return void - * @Flow\SkipCsrfProtection - * @throws NoSuchArgumentException - */ - public function queryAction($endpoint, $query, $variables = null, $operationName = null) - { - if ($variables !== null && is_string($this->request->getArgument('variables'))) { - $variables = json_decode($this->request->getArgument('variables'), true); - } - - $schema = $this->schemaService->getSchemaForEndpoint($endpoint); - $context = new GraphQLContext($this->request->getHttpRequest()); - GraphQL::setDefaultFieldResolver([SchemaService::class, 'defaultFieldResolver']); - $result = GraphQL::executeQuery($schema, $query, null, $context, $variables, $operationName); - $this->view->assign('result', $result); - } - -} diff --git a/Classes/GraphQLContext.php b/Classes/GraphQLContext.php deleted file mode 100644 index d5c9012..0000000 --- a/Classes/GraphQLContext.php +++ /dev/null @@ -1,32 +0,0 @@ -httpRequest = $httpRequest; - } - - /** - * @return ServerRequestInterface - */ - public function getHttpRequest(): ServerRequestInterface - { - return $this->httpRequest; - } - -} diff --git a/Classes/GraphQLMiddleware.php b/Classes/GraphQLMiddleware.php new file mode 100644 index 0000000..ccc2839 --- /dev/null +++ b/Classes/GraphQLMiddleware.php @@ -0,0 +1,155 @@ +url + if (!\in_array($request->getMethod(), ['POST', 'OPTIONS'], true) || $request->getUri()->getPath() !== $this->uriPath) { + return $handler->handle($request); + } + if ($this->simulateControllerObjectName !== null) { + $mockActionRequest = ActionRequest::fromHttpRequest($request); + // Simulate a request to the specified controller to trigger authentication + $mockActionRequest->setControllerObjectName($this->simulateControllerObjectName); + $this->securityContext->setRequest($mockActionRequest); + } + $response = $this->responseFactory->createResponse(); + $response = $this->addCorsHeaders($response); + if ($request->getMethod() === 'POST') { + $response = $this->handlePostRequest($request, $response); + } + return $response; + } + + private function handlePostRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $api = $this->serviceLocator->get($this->apiObjectName); + $config = ServerConfig::create() + ->setSchema($this->getSchema()) + ->setFieldResolver(new Resolver( + $api, + $this->typeNamespaces === [] ? [(new ReflectionClass($api))->getNamespaceName()] : $this->typeNamespaces, + ))->setErrorsHandler($this->handleGraphQLErrors(...)); + if ($this->debugMode) { + $config->setDebugFlag(); + } + $server = new StandardServer($config); + try { + $request = $this->parseRequestBody($request); + } catch (\JsonException $_) { + return new Response(400, [], 'Invalid JSON request'); + } + + $bodyStream = $this->streamFactory->createStream(); + $newResponse = $server->processPsrRequest($request, $response, $bodyStream); + // For some reason we need to rewind the stream in order to prevent an empty response body + $bodyStream->rewind(); + return $newResponse; + } + + /** + * @throws \JsonException + */ + private function parseRequestBody(ServerRequestInterface $request): ServerRequestInterface + { + if (!empty($request->getParsedBody())) { + return $request; + } + $parsedBody = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + return $request->withParsedBody($parsedBody); + } + + private function addCorsHeaders(ResponseInterface $response): ResponseInterface + { + return $response + ->withHeader('Access-Control-Allow-Origin', $this->corsOrigin) + ->withHeader('Access-Control-Allow-Methods', 'POST,OPTIONS') + ->withHeader('Access-Control-Allow-Headers', 'Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'); + } + + private function handleGraphQLErrors(array $errors, callable $formatter): array + { + return array_map(function(Throwable $error) use ($formatter) { + if (!$error instanceof ClientAware || !$error->isClientSafe()) { + $this->throwableStorage->logThrowable($error); + } + return $formatter($error); + }, $errors); + } + + private function getSchema(): Schema + { + $cacheKey = md5($this->apiObjectName); + if ($this->schemaCache->has($cacheKey)) { + $documentNode = AST::fromArray($this->schemaCache->get($cacheKey)); + } else { + /** @var GraphQLGenerator $generator */ + $generator = $this->serviceLocator->get(GraphQLGenerator::class); + $schema = $generator->generate($this->apiObjectName)->render(); + try { + $documentNode = Parser::parse($schema); + } catch (SyntaxError $e) { + throw new \RuntimeException(sprintf('Failed to parse GraphQL Schema: %s', $e->getMessage()), 1652975280, $e); + } + try { + $this->schemaCache->set($cacheKey, AST::toArray($documentNode)); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('Failed to store parsed GraphQL Scheme in cache: %s', $e->getMessage()), 1652975323, $e); + } + } + + return BuildSchema::build($documentNode); + } +} diff --git a/Classes/GraphQLMiddlewareFactory.php b/Classes/GraphQLMiddlewareFactory.php new file mode 100644 index 0000000..8cf5762 --- /dev/null +++ b/Classes/GraphQLMiddlewareFactory.php @@ -0,0 +1,50 @@ +debugMode, + $this->corsOrigin, + $this->streamFactory, + $this->responseFactory, + $this->schemaCache, + $this->throwableStorage, + $this->securityContext, + $this->objectManager, + ); + } +} diff --git a/Classes/Http/HttpOptionsMiddleware.php b/Classes/Http/HttpOptionsMiddleware.php deleted file mode 100644 index 0032ab3..0000000 --- a/Classes/Http/HttpOptionsMiddleware.php +++ /dev/null @@ -1,46 +0,0 @@ - skip - if ($request->getMethod() !== 'OPTIONS') { - return $next->handle($request); - } - $endpoint = ltrim($request->getUri()->getPath(), '\/'); - // no matching graphQL endpoint configured => skip - if (!isset($this->endpoints[$endpoint])) { - return $next->handle($request); - } - return $this->responseFactory->createResponse()->withHeader('Allow', 'GET, POST'); - } -} diff --git a/Classes/IterableAccessibleObject.php b/Classes/IterableAccessibleObject.php deleted file mode 100644 index 10ca1fa..0000000 --- a/Classes/IterableAccessibleObject.php +++ /dev/null @@ -1,112 +0,0 @@ -innerIterator = $object; - } elseif (is_array($object)) { - $this->innerIterator = new \ArrayIterator($object); - } else { - throw new \InvalidArgumentException('The IterableAccessibleObject only works on arrays or objects implementing the Iterator interface', 1460895979); - } - } - - /** - * @return \Iterator - */ - public function getIterator() - { - return $this->innerIterator; - } - - /** - * @return AccessibleObject - */ - public function current() - { - $currentElement = $this->innerIterator->current(); - if (is_object($currentElement)) { - return new AccessibleObject($currentElement); - } - return $currentElement; - } - - /** - * @return void - */ - public function next() - { - $this->innerIterator->next(); - } - - /** - * @return string - */ - public function key() - { - return $this->innerIterator->key(); - } - - /** - * @return bool - */ - public function valid() - { - return $this->innerIterator->valid(); - } - - /** - * @return void - */ - public function rewind() - { - $this->innerIterator->rewind(); - } -} \ No newline at end of file diff --git a/Classes/Package.php b/Classes/Package.php deleted file mode 100644 index 39658f6..0000000 --- a/Classes/Package.php +++ /dev/null @@ -1,72 +0,0 @@ -getSignalSlotDispatcher(); - $applicationContext = $bootstrap->getContext(); - - $dispatcher->connect(CacheCommandController::class, 'warmupCaches', SchemaService::class, 'warmupCaches'); - if ($applicationContext->isProduction()) { - return; - } - $dispatcher->connect(Sequence::class, 'afterInvokeStep', function (Step $step) use ($bootstrap, $dispatcher) { - if ($step->getIdentifier() !== 'neos.flow:systemfilemonitor') { - return; - } - $graphQlFileMonitor = FileMonitor::createFileMonitorAtBoot(self::FILE_MONITOR_IDENTIFIER, $bootstrap); - $configurationManager = $bootstrap->getEarlyInstance(ConfigurationManager::class); - $endpointsConfiguration = $configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, 'Wwwision.GraphQL.endpoints'); - $packageManager = $bootstrap->getEarlyInstance(PackageManager::class); - - foreach($endpointsConfiguration as $endpointConfiguration) { - if (!isset($endpointConfiguration['schema'])) { - continue; - } - $resourceUriParts = Functions::parse_url($endpointConfiguration['schema']); - if (isset($resourceUriParts['scheme']) && $resourceUriParts['scheme'] === 'resource') { - $package = $packageManager->getPackage($resourceUriParts['host']); - $schemaPathAndFilename = Files::concatenatePaths([$package->getResourcesPath(), $resourceUriParts['path']]); - } else { - $schemaPathAndFilename = $endpointConfiguration['schema']; - } - $graphQlFileMonitor->monitorFile($schemaPathAndFilename); - } - - $graphQlFileMonitor->detectChanges(); - $graphQlFileMonitor->shutdownObject(); - }); - - $dispatcher->connect(FileMonitor::class, 'filesHaveChanged', function(string $fileMonitorIdentifier, array $changedFiles) use ($bootstrap) { - if ($fileMonitorIdentifier !== self::FILE_MONITOR_IDENTIFIER || $changedFiles === []) { - return; - } - $schemaCache = $bootstrap->getObjectManager()->get(CacheManager::class)->getCache('Wwwision_GraphQL_Schema'); - $schemaCache->flush(); - }); - } -} diff --git a/Classes/Resolver.php b/Classes/Resolver.php new file mode 100644 index 0000000..d319f9a --- /dev/null +++ b/Classes/Resolver.php @@ -0,0 +1,114 @@ + $typeNamespaces + */ + public function __construct( + private readonly object $api, + private readonly array $typeNamespaces, + ) { + } + + /** + * @param array|null> $args + */ + public function __invoke(object|string|null $objectValue, array $args, mixed $contextValue, ResolveInfo $info): mixed + { + $fieldName = $info->fieldName; + $objectValue ??= $this->api; + + if (method_exists($objectValue, $fieldName)) { + $objectValue = $objectValue->{$fieldName}(...$this->convertArguments($args, $info->fieldDefinition)); + } elseif (property_exists($objectValue, $fieldName)) { + $objectValue = $objectValue->{$fieldName}; + } else { + return null; + } + if ($objectValue instanceof BackedEnum) { + $objectValue = $objectValue->value; + } + return $objectValue; + } + + /** + * @param array|null> $arguments + * @return array|object|null> + */ + private function convertArguments(array $arguments, FieldDefinition $fieldDefinition): array + { + $result = []; + foreach ($arguments as $name => $value) { + $argumentDefinition = $fieldDefinition->getArg($name); + $result[$name] = $this->convertArgument($value, $argumentDefinition); + } + return $result; + } + + /** + * @param string|bool|int|array|null $argument + * @return string|bool|int|array|object|null + */ + private function convertArgument(string|bool|int|array|null $argument, ?Argument $argumentDefinition): string|bool|int|array|object|null + { + if ($argument === null) { + return null; + } + $type = $argumentDefinition?->getType(); + if ($type instanceof NonNull) { + $type = $type->getWrappedType(); + } + $argumentType = $type->name; + if ($type instanceof ListOfType) { + $type = $type->ofType; + if ($type instanceof NonNull) { + $type = $type->getWrappedType(); + } + $argumentType = $type->name . 's'; + } + if (str_ends_with($argumentType, 'Input')) { + $argumentType = substr($argumentType, 0, -5); + } + + $className = $this->resolveClassName($argumentType); + if ($className !== null) { + try { + return instantiate($className, $argument); + } catch (InvalidArgumentException $e) { + throw new RequestError(sprintf('Validation error for %s: %s', $argumentType, $e->getMessage()), 1688654808, $e); + } + } + return $argument; + } + + /** + * @param string $argumentType + * @return class-string|null + */ + private function resolveClassName(string $argumentType): ?string + { + foreach ($this->typeNamespaces as $namespace) { + $className = rtrim($namespace, '\\') . '\\' . $argumentType; + if (class_exists($className)) { + return $className; + } + } + return null; + } +} diff --git a/Classes/ResolverInterface.php b/Classes/ResolverInterface.php deleted file mode 100644 index 2e4d7c5..0000000 --- a/Classes/ResolverInterface.php +++ /dev/null @@ -1,11 +0,0 @@ -verifySettings($endpoint); - $endpointConfiguration = $this->endpointsConfiguration[$endpoint]; - - if (isset($endpointConfiguration['querySchema'])) { - $schemaConfig = SchemaConfig::create() - ->setQuery($this->typeResolver->get($endpointConfiguration['querySchema'])); - if (isset($endpointConfiguration['mutationSchema'])) { - $schemaConfig->setMutation($this->typeResolver->get($endpointConfiguration['mutationSchema'])); - } - if (isset($endpointConfiguration['subscriptionSchema'])) { - $schemaConfig->setSubscription($this->typeResolver->get($endpointConfiguration['subscriptionSchema'])); - } - if (isset($endpointConfiguration['configTypes'])) { - $configTypes = $endpointConfiguration['configTypes']; - array_walk($configTypes, function (&$configType) { - $configType = $this->typeResolver->get($configType); - }); - $schemaConfig->setTypes($configTypes); - } - return new Schema($schemaConfig); - } - - $cacheKey = urlencode($endpoint); - if ($this->schemaCache->has($cacheKey)) { - $documentNode = $this->schemaCache->get($cacheKey); - } else { - $schemaPathAndFilename = $endpointConfiguration['schema']; - $content = Files::getFileContents($schemaPathAndFilename); - $documentNode = Parser::parse($content); - $this->schemaCache->set($cacheKey, $documentNode); - } - - $resolverConfiguration = $endpointConfiguration['resolvers'] ?? []; - $resolverPathPattern = $endpointConfiguration['resolverPathPattern'] ?? null; - /** @var ResolverInterface[] $resolvers */ - $resolvers = []; - return BuildSchema::build($documentNode, function ($config) use (&$resolvers, $resolverConfiguration, $resolverPathPattern) { - $name = $config['name']; - - if (!isset($resolvers[$name])) { - if (isset($resolverConfiguration[$name])) { - $resolvers[$name] = $this->objectManager->get($resolverConfiguration[$name]); - } elseif ($resolverPathPattern !== null) { - $possibleResolverClassName = str_replace('{Type}', $name, $resolverPathPattern); - if ($this->objectManager->isRegistered($possibleResolverClassName)) { - $resolvers[$name] = $this->objectManager->get($possibleResolverClassName); - } - } - } - if (isset($resolvers[$name])) { - return $resolvers[$name]->decorateTypeConfig($config); - } - return $config; - }); - } - - /** - * Verifies the settings for a given $endpoint and throws an exception if they are not valid - * - * @param string $endpoint - * @return void - * @throws \InvalidArgumentException if the settings are incorrect - */ - public function verifySettings(string $endpoint) - { - if (!isset($this->endpointsConfiguration[$endpoint])) { - throw new \InvalidArgumentException(sprintf('The endpoint "%s" is not configured.', $endpoint), 1461435428); - } - - if (!isset($this->endpointsConfiguration[$endpoint]['schema']) && !isset($this->endpointsConfiguration[$endpoint]['querySchema'])) { - throw new \InvalidArgumentException(sprintf('There is no root query schema configured for endpoint "%s".', $endpoint), 1461435432); - } - - if (isset($this->endpointsConfiguration[$endpoint]['schema']) && !file_exists($this->endpointsConfiguration[$endpoint]['schema'])) { - throw new \InvalidArgumentException(sprintf('The Schema file configured for endpoint "%s" does not exist at: "%s".', $endpoint, $this->endpointsConfiguration[$endpoint]['schema']), 1516719329); - } - } - - /** - * @param string|object|array $source - * @param array $args - * @param GraphQLContext $context - * @param ResolveInfo $info - * @return mixed - */ - public static function defaultFieldResolver($source, array $args, GraphQLContext $context, ResolveInfo $info) - { - $fieldName = $info->fieldName; - $property = null; - - if (is_array($source) || $source instanceof \ArrayAccess) { - if (isset($source[$fieldName])) { - $property = $source[$fieldName]; - } - } else if (is_object($source)) { - if (ObjectAccess::isPropertyGettable($source, $fieldName)) { - $property = ObjectAccess::getProperty($source, $fieldName); - } - } - - if ($property instanceof \Closure) { - return $property($source, $args, $context, $info); - } - - return $property; - } - - - /** - * Builds the schema for the given $endpoint and saves it in the cache - * - * @param string $endpoint - * @return DocumentNode - */ - private function buildSchemaForEndpoint(string $endpoint) - { - $schemaPathAndFilename = $this->endpointsConfiguration[$endpoint]['schema']; - $content = Files::getFileContents($schemaPathAndFilename); - $documentNode = Parser::parse($content); - $this->schemaCache->set(urlencode($endpoint), $documentNode, [md5($schemaPathAndFilename)]); - return $documentNode; - } - - /** - * @return void - */ - public function warmupCaches() - { - foreach($this->endpointsConfiguration as $endpoint => $endpointConfiguration) { - if (!isset($endpointConfiguration['schema'])) { - continue; - } - $this->buildSchemaForEndpoint($endpoint); - } - } -} diff --git a/Classes/TypeResolver.php b/Classes/TypeResolver.php deleted file mode 100644 index f7f6d80..0000000 --- a/Classes/TypeResolver.php +++ /dev/null @@ -1,49 +0,0 @@ -get(SomeClass::class)) - * - * @Flow\Scope("singleton") - */ -class TypeResolver -{ - private const INITIALIZING = '__initializing__'; - - /** - * @var ObjectType[] - */ - private $types = []; - - /** - * @param string $typeClassName - * @return ObjectType - */ - public function get($typeClassName) - { - if (!is_string($typeClassName)) { - throw new \InvalidArgumentException(sprintf('Expected string, got "%s"', is_object($typeClassName) ? get_class($typeClassName) : gettype($typeClassName)), 1460065671); - } - if (!is_subclass_of($typeClassName, Type::class)) { - throw new \InvalidArgumentException(sprintf('The TypeResolver can only resolve types extending "GraphQL\Type\Definition\Type", got "%s"', $typeClassName), 1461436398); - } - if (!array_key_exists($typeClassName, $this->types)) { - // The following code seems weird but it is a way to detect circular dependencies (see - $this->types[$typeClassName] = self::INITIALIZING; - $this->types[$typeClassName] = new $typeClassName($this); - } - if ($this->types[$typeClassName] === self::INITIALIZING) { - throw new \RuntimeException(sprintf('The GraphQL Type "%s" seems to have circular dependencies. Please define the fields as callbacks to prevent this error.', $typeClassName), 1554382971); - } - return $this->types[$typeClassName]; - } -} diff --git a/Classes/View/GraphQlView.php b/Classes/View/GraphQlView.php deleted file mode 100644 index 33bcb95..0000000 --- a/Classes/View/GraphQlView.php +++ /dev/null @@ -1,86 +0,0 @@ -variables['result'])) { - throw new FlowException(sprintf('The GraphQlView expects a variable "result" of type "%s", non given!', ExecutionResult::class), 1469545196); - } - $result = $this->variables['result']; - if (!$result instanceof ExecutionResult) { - throw new FlowException(sprintf('The GraphQlView expects a variable "result" of type "%s", "%s" given!', ExecutionResult::class, is_object($result) ? get_class($result) : gettype($result)), 1469545198); - } - - $response = $this->controllerContext->getResponse(); - $response->setContentType('application/json'); - - return json_encode($this->formatResult($result)); - } - - /** - * Formats the result of the GraphQL execution, converting Flow exceptions by hiding the original exception message - * and adding status- and referenceCode. - * - * @param ExecutionResult $executionResult - * @return array - */ - private function formatResult(ExecutionResult $executionResult) - { - $convertedResult = [ - 'data' => $executionResult->data, - ]; - if (!empty($executionResult->errors)) { - $convertedResult['errors'] = array_map(function(Error $error) { - $errorResult = [ - 'message' => $error->message, - 'locations' => $error->getLocations() - ]; - $exception = $error->getPrevious(); - if ($exception instanceof FlowException) { - $errorResult['message'] = ResponseInformationHelper::getStatusMessageByCode($exception->getStatusCode()); - $errorResult['_exceptionCode'] = $exception->getCode(); - $errorResult['_statusCode'] = $exception->getStatusCode(); - $errorResult['_referenceCode'] = $exception->getReferenceCode(); - } - if ($exception instanceof \Exception) { - $message = $this->throwableStorage->logThrowable($exception); - $this->logger->error($message); - } - return $errorResult; - }, $executionResult->errors); - } - if (!empty($executionResult->extensions)) { - $convertedResult['extensions'] = (array)$executionResult->extensions; - } - return $convertedResult; - } -} diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml index 3e448be..de59c41 100644 --- a/Configuration/Caches.yaml +++ b/Configuration/Caches.yaml @@ -1,3 +1,6 @@ -Wwwision_GraphQL_Schema: +Wwwision_GraphQL_GraphQLSchemaCache: frontend: Neos\Cache\Frontend\VariableFrontend - backend: Neos\Cache\Backend\FileBackend + backend: Neos\Cache\Backend\SimpleFileBackend + backendOptions: + # 0 = don't expire + defaultLifetime: 0 diff --git a/Configuration/Development/Caches.yaml b/Configuration/Development/Caches.yaml new file mode 100644 index 0000000..363a771 --- /dev/null +++ b/Configuration/Development/Caches.yaml @@ -0,0 +1,3 @@ +Wwwision_GraphQL_GraphQLSchemaCache: + # disable GraphQL schema caching in development mode + backend: Neos\Cache\Backend\NullBackend diff --git a/Configuration/Development/Settings.yaml b/Configuration/Development/Settings.yaml new file mode 100644 index 0000000..e09b1ba --- /dev/null +++ b/Configuration/Development/Settings.yaml @@ -0,0 +1,3 @@ +Wwwision: + GraphQL: + debugMode: true diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index f7972bb..0050867 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,9 +1,13 @@ -Wwwision\GraphQL\SchemaService: - properties: - 'schemaCache': +Wwwision\GraphQL\GraphQLMiddlewareFactory: + arguments: + 1: + setting: Wwwision.GraphQL.debugMode + 2: + setting: Wwwision.GraphQL.corsOrigin + 3: object: - factoryObjectName: Neos\Flow\Cache\CacheManager - factoryMethodName: getCache + factoryObjectName: 'Neos\Flow\Cache\CacheManager' + factoryMethodName: 'getCache' arguments: 1: - value: Wwwision_GraphQL_Schema + value: 'Wwwision_GraphQL_GraphQLSchemaCache' diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml deleted file mode 100644 index a0fe159..0000000 --- a/Configuration/Routes.yaml +++ /dev/null @@ -1,22 +0,0 @@ -- - name: 'graphql - GraphQL Playground' - uriPattern: '' - defaults: - '@package': 'Wwwision.GraphQL' - '@controller': 'Standard' - '@action': 'index' - '@format': 'html' - 'endpoint': '' - appendExceedingArguments: true - httpMethods: [GET] - -- - name: 'graphql - endpoint' - uriPattern: '' - defaults: - '@package': 'Wwwision.GraphQL' - '@controller': 'Standard' - '@action': 'query' - '@format': 'json' - 'endpoint': '' - httpMethods: [POST] diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index d0bcbf2..94434f4 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -1,14 +1,5 @@ -Neos: - Flow: - http: - middlewares: - 'Wwwision.GraphQL:Options': - position: 'before routing' - middleware: 'Wwwision\GraphQL\Http\HttpOptionsMiddleware' - Wwwision: GraphQL: - endpoints: [] -# 'some-endpoint': -# 'queryType': 'Some\Fully\Qualified\Namespace' -# 'mutationType': 'Some\Fully\Qualified\Namespace' + debugMode: false + # The "Access-Control-Allow-Origin" response header + corsOrigin: '*' diff --git a/README.md b/README.md index c7aeeaa..156d463 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,122 @@ # Wwwision.GraphQL -Easily create GraphQL APIs with Neos and Flow. +Easily create GraphQL APIs with [https://www.neos.io/](Neos) and [https://flow.neos.io/](Flow). ## Background This package is a small collection of tools that'll make it easier to provide [GraphQL](http://graphql.org/) endpoints with Neos and Flow. -It is a wrapper for the [PHP port of webonyx](https://github.com/webonyx/graphql-php) that comes with following additions: +It is a wrapper for the [PHP port of webonyx](https://github.com/webonyx/graphql-php) that comes with automatic Schema generation from PHP code (using [wwwision/types](https://github.com/bwaidelich/types)) +and an easy-to-configure [PSR-15](https://www.php-fig.org/psr/psr-15/) compatible HTTP middleware. -* A `TypeResolver` that allows for easy interdependency between complex GraphQL type definitions -* The `AccessibleObject` and `IterableAccessibleObject` wrappers that make it possible to expose arbitrary objects to - the GraphQL API -* A `StandardController` that renders the [GraphQL Playground](https://github.com/prismagraphql/graphql-playground) and acts as dispatcher - for API calls -* A HTTP Component that responds to `OPTIONS` requests correctly (required for CORS preflight requests for example) -* A custom `GraphQLContext` that is available in all resolvers and allows access to the current HTTP Request +## Usage -## Installation +Install via [composer](https://getcomposer.org/doc/): ``` composer require wwwision/graphql ``` -(Refer to the [composer documentation](https://getcomposer.org/doc/) for more details) +### Simple tutorial -## Simple tutorial - -Create a simple Root Query definition within any Flow package: - -`ExampleRootQuery.php`: +Create a class containing at least one public method with a `Query` attribute (see [wwwision/types-graphql](https://github.com/bwaidelich/types-graphql) for more details): ```php 'ExampleRootQuery', - 'fields' => [ - 'ping' => [ - 'type' => Type::string(), - 'resolve' => function () { - return 'pong'; - }, - ], - ], - ]); + #[Query] + public function ping(string $name): string { + return strtoupper($name); } } ``` -Now register this endpoint like so: - -`Settings.yaml`: +Now define a [virtual object](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/ObjectManagement.html#sect-virtual-objects) for the [HTTP middleware](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Http.html#middlewares-chain) +in some `Objects.yaml` configuration: ```yaml -Wwwision: - GraphQL: - endpoints: - 'test': - 'querySchema': 'Your\Package\ExampleRootQuery' +'Your.Package:GraphQLMiddleware': + className: 'Wwwision\GraphQL\GraphQLMiddleware' + scope: singleton + factoryObjectName: Wwwision\GraphQL\GraphQLMiddlewareFactory + arguments: + 1: + value: '/graphql' + 2: + value: 'Your\Package\YourApi' ``` -And, lastly, activate the corresponding routes: - -`Settings.yaml`: +And, lastly, register that custom middleware in `Settings.yaml`: ```yaml Neos: Flow: - mvc: - routes: - 'Wwwision.GraphQL': - variables: - 'endpoint': 'test' -``` - -This will make the endpoint "test" available under `/test`. - -Note: If you already have more specific routes in place, or want to provide multiple GraphQL endpoints you can as well -activate routes in your global `Routes.yaml` file: - -```yaml -- - name: 'GraphQL API' - uriPattern: '' - subRoutes: - 'GraphQLSubroutes': - package: 'Wwwision.GraphQL' - variables: - 'endpoint': 'test' + http: + middlewares: + 'Your.Package:GraphQL': + position: 'before routing' + middleware: 'Your.Package:GraphQLMiddleware' ``` -**Congratulations**, your first GraphQL API is done and you should be able to invoke the GraphQL Playground by browsing to `/test`: - -![](playground.png) - -For a more advanced example, have a look at the [Neos Content Repository implementation](https://github.com/bwaidelich/Wwwision.Neos.GraphQL) - -## Custom context +And with that, a working GraphQL API is accessible underneath `/graphql`. -Resolvers should be as simple and self-contained as possible. But sometimes it's useful to have access to the current -HTTP request. For example in order to do explicit authentication or to render URLs. -With v2.1+ there's a new `GraphQLContext` accessible to all resolvers that allows to access the current HTTP request: +### Complex types -```php - function ($value, array $args, GraphQLContext $context) { - $baseUri = $context->getHttpRequest()->getBaseUri(); - // ... - }, -``` - -`$value` is the object containing the field. Its value is `null` on the root mutation/query. -`$args` is the array of arguments specified for that field. It's an empty array if no arguments have been specified. -`$context` is an instance of the `GraphQLContext` with a getter for the current HTTP request. - -## Circular Dependencies - -Sometimes GraphQL types reference themselves. -For example a type `Person` could have a field `friends` that is a list of `Person`-types itself. - -The following code won't work with the latest version of this package: - -```php - 'Person', - 'fields' => [ - 'name' => ['type' => Type::string()], - 'friends' => [ - // THIS WON'T WORK! - 'type' => Type::listOf($typeResolver->get(self::class)), - 'resolve' => function () { - // ... - }, - ], - ], - ]); - } -} -``` -To solve this, the fields can be configured as closure like described in the [graphql-php documentation](https://webonyx.github.io/graphql-php/type-system/object-types/#recurring-and-circular-types): +By default, all types with the *same namespace* as the specified API class will be resolved automatically, so you could do: ```php - 'Person', - 'fields' => function() use ($typeResolver) { - return [ - 'name' => ['type' => Type::string()], - 'friends' => [ - 'type' => Type::listOf($typeResolver->get(self::class)), - 'resolve' => function () { - // ... - }, - ], - ]; - } - ]); - } +#[Query] +public function ping(Name $name): Name { + return strtoupper($name); } ``` - -Alternatively the schema can be defined via a `*.graphql` file: - -## Define Schemas using the GraphQL Schema language - -Since version 3.0 schemas can be defined using the [GraphQL Schema language](https://graphql.org/learn/schema/). - -Routes are configured like above, but in the endpoint settings instead of the `querySchema` the schema file and so called -`resolvers` are configured like so: +as long as there is a suitable `Name` object in the same namespace (`Your\Package`). +To support types from _different_ namespaces, those can be specified as third argument of the `GraphQLMiddlewareFactory`: ```yaml -Wwwision: - GraphQL: - endpoints: - 'test': - schema: 'resource://Wwwision.Test/Private/GraphQL/schema.graphql' - resolvers: - 'Query': 'Wwwision\Test\ExampleResolver' +'Your.Package:GraphQLMiddleware': + # ... + arguments: + # ... + # Look for classes in the following namespaces when resolving types: + 3: + value: + - 'Your\Package\Types' + - 'SomeOther\Package\Commands' ``` -The corresponding schema could look like: +### Authentication -```graphql -schema { - query: Query -} - -type Query { - # some description - ping(name: String!): String -} -``` +Commonly the GraphQL middleware is executed before the routing middleware. So the `Security\Context` is not yet initialized. +This package allows you to "simulate" the request to an MVC controller thought in order to initialize security. +This is done with the fourth argument of the `GraphQLMiddlewareFactory`: -And the resolver like: - -```php -indexAction())' - 'Wwwision.GraphQL:Api.Blacklist': - matcher: 'method(Wwwision\GraphQL\Controller\StandardController->queryAction())' - 'Wwwision.GraphQL:Playground': - matcher: 'method(Wwwision\GraphQL\Controller\StandardController->indexAction(endpoint == "{parameters.endpoint}"))' - parameters: - 'endpoint': - className: 'Neos\Flow\Security\Authorization\Privilege\Parameter\StringPrivilegeParameter' - 'Wwwision.GraphQL:Api': - matcher: 'method(Wwwision\GraphQL\Controller\StandardController->queryAction(endpoint == "{parameters.endpoint}"))' - parameters: - 'endpoint': - className: 'Neos\Flow\Security\Authorization\Privilege\Parameter\StringPrivilegeParameter' -``` - -With the two blacklist privileges calls to the endpoints are forbidden by default (this is not required -if you use this package with Neos because that already blocks controller actions by default). -The other two privileges allow you to selectively grant access to a given endpoint like so: +Contributions in the form of [issues](https://github.com/bwaidelich/Wwwision.GraphQL/issues) or [pull requests](https://github.com/bwaidelich/Wwwision.GraphQL/pulls) are highly appreciated +## License -```yaml -roles: - 'Neos.Flow:Everybody': - privileges: - - - privilegeTarget: 'Wwwision.GraphQL:Api' - parameters: - 'endpoint': 'test' - permission: GRANT -``` - -This would re-enable the GraphQL API (POST requests) for any user, but keep the Playground blocked. +See [LICENSE](./LICENSE) diff --git a/Resources/Private/Templates/Standard/Index.html b/Resources/Private/Templates/Standard/Index.html deleted file mode 100644 index 61bbdad..0000000 --- a/Resources/Private/Templates/Standard/Index.html +++ /dev/null @@ -1,540 +0,0 @@ - - - - - - - - GraphQL Playground - - - - - - - - - - -
- -
Loading - GraphQL Playground -
-
- -
- - - diff --git a/Tests/Unit/AccessibleObjectTest.php b/Tests/Unit/AccessibleObjectTest.php deleted file mode 100644 index 55aa41a..0000000 --- a/Tests/Unit/AccessibleObjectTest.php +++ /dev/null @@ -1,183 +0,0 @@ -accessibleObject = new AccessibleObject(new ExampleObject('Foo')); - } - - /** - * @test - */ - public function getObjectReturnsTheUnalteredObject() - { - $object = new \stdClass(); - $accessibleObject = new AccessibleObject($object); - $this->assertSame($object, $accessibleObject->getObject()); - } - - /** - * @test - */ - public function offsetExistsReturnsFalseIfObjectIsNotSet() - { - $accessibleObject = new AccessibleObject(null); - $this->assertFalse($accessibleObject->offsetExists('foo')); - } - - public function simpleOffsetGetDataProvider() - { - return [ - ['someString', 'Foo'], - ['isFoo', true], - ['hasBar', false], - - - // is* and has* can be omitted (ObjectAccess behavior) - ['foo', true], - ['bar', false], - - // The following tests show that property resolving works case insensitive - // like the underlying ObjectAccess. I think it would be less error prone if they didn't - // But that's the way it currently works, so these tests merely document that behavior - ['SomeString', 'Foo'], - ['somestring', 'Foo'], - ['SoMeStRiNg', 'Foo'], - ]; - } - - /** - * @test - * @dataProvider simpleOffsetGetDataProvider - * @param string $propertyName - * @param mixed $expectedValue - */ - public function simpleOffsetGetTests($propertyName, $expectedValue) - { - $this->assertSame($expectedValue, $this->accessibleObject[$propertyName]); - } - - /** - * @test - * @expectedException \Neos\Utility\Exception\PropertyNotAccessibleException - */ - public function offsetGetThrowsExceptionForUnknownProperties() - { - $this->accessibleObject['unknown']; - } - - /** - * @test - */ - public function offsetGetWrapsSimpleArrayProperties() - { - /** @var \Iterator $arrayIterator */ - $arrayIterator = $this->accessibleObject['someArray']; - $this->assertInstanceOf(IterableAccessibleObject::class, $arrayIterator); - $firstArrayValue = $arrayIterator->current(); - $firstArrayKey = $arrayIterator->key(); - $arrayIterator->next(); - $secondArrayValue = $arrayIterator->current(); - $secondArrayKey = $arrayIterator->key(); - $this->assertSame('string', $firstArrayKey); - $this->assertSame('neos', $secondArrayKey); - $this->assertSame('Foo', $firstArrayValue); - $this->assertSame('rocks', $secondArrayValue); - } - - /** - * @test - */ - public function offsetGetReturnsDateTimeProperties() - { - /** @var \DateTimeInterface $date */ - $date = $this->accessibleObject['someDate']; - $this->assertInstanceOf(\DateTimeInterface::class, $date); - $this->assertSame('13.12.1980', $date->format('d.m.Y')); - } - - /** - * @test - */ - public function offsetGetWrapsArraySubObjects() - { - /** @var \Iterator $subObjectsIterator */ - $subObjectsIterator = $this->accessibleObject['someSubObjectsArray']; - $this->assertInstanceOf(IterableAccessibleObject::class, $subObjectsIterator); - $firstSubObject = $subObjectsIterator->current(); - $subObjectsIterator->next(); - $secondSubObject = $subObjectsIterator->current(); - $this->assertInstanceOf(AccessibleObject::class, $firstSubObject); - $this->assertInstanceOf(AccessibleObject::class, $secondSubObject); - $this->assertSame('Foo nested a', $firstSubObject['someString']); - $this->assertSame('Foo nested b', $secondSubObject['someString']); - } - - /** - * @test - */ - public function offsetGetWrapsIterableSubObjects() - { - /** @var \Iterator $subObjectsIterator */ - $subObjectsIterator = $this->accessibleObject['someSubObjectsIterator']; - $this->assertInstanceOf(IterableAccessibleObject::class, $subObjectsIterator); - $firstSubObject = $subObjectsIterator->current(); - $subObjectsIterator->next(); - $secondSubObject = $subObjectsIterator->current(); - $this->assertInstanceOf(AccessibleObject::class, $firstSubObject); - $this->assertInstanceOf(AccessibleObject::class, $secondSubObject); - $this->assertSame('Foo nested a', $firstSubObject['someString']); - $this->assertSame('Foo nested b', $secondSubObject['someString']); - } - - /** - * @test - */ - public function offsetGetWrapsSubObjects() - { - $this->assertInstanceOf(AccessibleObject::class, $this->accessibleObject['someSubObject']); - $this->assertInstanceOf(AccessibleObject::class, $this->accessibleObject['someSubObject']['someSubObject']); - $this->assertSame('Foo nested nested', $this->accessibleObject['someSubObject']['someSubObject']['someString']); - } - - /** - * @test - * @expectedException \RuntimeException - */ - public function offsetSetThrowsException() - { - $this->accessibleObject['someString'] = 'This must not be possible'; - } - - /** - * @test - * @expectedException \RuntimeException - */ - public function offsetUnsetThrowsException() - { - unset($this->accessibleObject['someString']); - } - - /** - * @test - */ - public function toStringReturnsCastedObject() - { - $this->assertSame('ExampleObject (string-casted)', (string)$this->accessibleObject); - } -} \ No newline at end of file diff --git a/Tests/Unit/Fixtures/ExampleObject.php b/Tests/Unit/Fixtures/ExampleObject.php deleted file mode 100644 index 4406036..0000000 --- a/Tests/Unit/Fixtures/ExampleObject.php +++ /dev/null @@ -1,66 +0,0 @@ -string = $string; - } - - public function getSomeString() - { - return $this->string; - } - - public function getSomeArray() - { - return ['string' => $this->string, 'neos' => 'rocks']; - } - - public function isFoo() - { - return true; - } - - public function hasBar() - { - return false; - } - - public function getSomeDate() - { - return new \DateTimeImmutable('1980-12-13'); - } - - public function getSomeSubObject() - { - return new self($this->string . ' nested'); - } - - public function getSomeSubObjectsArray() - { - return [ - new self($this->string . ' nested a'), - new self($this->string . ' nested b') - ]; - } - - public function getSomeSubObjectsIterator() - { - return new \ArrayIterator($this->getSomeSubObjectsArray()); - } - - public function __toString() - { - return 'ExampleObject (string-casted)'; - } -} \ No newline at end of file diff --git a/Tests/Unit/Fixtures/InvalidExampleType.php b/Tests/Unit/Fixtures/InvalidExampleType.php deleted file mode 100644 index 015f6f7..0000000 --- a/Tests/Unit/Fixtures/InvalidExampleType.php +++ /dev/null @@ -1,29 +0,0 @@ - 'InvalidExampleType', - 'fields' => [ - 'someString' => ['type' => Type::string()], - // for circular dependencies fields must be declared as callback, see https://webonyx.github.io/graphql-php/type-system/object-types/#recurring-and-circular-types - 'selfReference' => ['type' => $typeResolver->get(self::class)], - ], - ]); - } - -} diff --git a/Tests/Unit/Fixtures/ValidExampleType.php b/Tests/Unit/Fixtures/ValidExampleType.php deleted file mode 100644 index c20cdb1..0000000 --- a/Tests/Unit/Fixtures/ValidExampleType.php +++ /dev/null @@ -1,30 +0,0 @@ - 'ValidExampleType', - 'fields' => function() use ($typeResolver) { - return [ - 'someString' => ['type' => Type::string()], - 'selfReference' => ['type' => $typeResolver->get(self::class)], - ]; - } - ]); - } - -} diff --git a/Tests/Unit/GraphQLContextTest.php b/Tests/Unit/GraphQLContextTest.php deleted file mode 100644 index 0cfe3e9..0000000 --- a/Tests/Unit/GraphQLContextTest.php +++ /dev/null @@ -1,34 +0,0 @@ -mockHttpRequest = $this->getMockBuilder(Request::class)->disableOriginalConstructor()->getMock(); - $this->graphQLContext = new GraphQLContext($this->mockHttpRequest); - } - - /** - * @test - */ - public function getHttpRequestReturnsTheSpecifiedRequestInstance() - { - $this->assertSame($this->mockHttpRequest, $this->graphQLContext->getHttpRequest()); - } -} \ No newline at end of file diff --git a/Tests/Unit/Http/HttpOptionsComponentTest.php b/Tests/Unit/Http/HttpOptionsComponentTest.php deleted file mode 100644 index d8bbfc7..0000000 --- a/Tests/Unit/Http/HttpOptionsComponentTest.php +++ /dev/null @@ -1,111 +0,0 @@ -httpOptionsComponent = new HttpOptionsComponent(); - - $this->mockComponentContext = $this->getMockBuilder(ComponentContext::class)->disableOriginalConstructor()->getMock(); - - $this->mockHttpRequest = $this->getMockBuilder(Request::class)->disableOriginalConstructor()->getMock(); - $this->mockComponentContext->expects($this->any())->method('getHttpRequest')->will($this->returnValue($this->mockHttpRequest)); - - $this->mockHttpResponse = $this->getMockBuilder(Response::class)->disableOriginalConstructor()->getMock(); - $this->mockComponentContext->expects($this->any())->method('getHttpResponse')->will($this->returnValue($this->mockHttpResponse)); - } - - /** - * @test - */ - public function handleSkipsNonOptionRequests() - { - $mockGraphQLEndpoints = [ - 'existing-endpoint' => ['querySchema' => 'Foo'], - ]; - $this->inject($this->httpOptionsComponent, 'endpoints', $mockGraphQLEndpoints); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getMethod')->will($this->returnValue('GET')); - $this->mockHttpRequest->expects($this->never())->method('getRelativePath'); - - $this->mockHttpResponse->expects($this->never())->method('setHeader'); - $this->mockComponentContext->expects($this->never())->method('setParameter'); - $this->httpOptionsComponent->handle($this->mockComponentContext); - } - - /** - * @test - */ - public function handleSkipsOptionsRequestsThatDontMatchConfiguredEndpoints() - { - $mockGraphQLEndpoints = [ - 'existing-endpoint' => ['querySchema' => 'Foo'], - ]; - $this->inject($this->httpOptionsComponent, 'endpoints', $mockGraphQLEndpoints); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getMethod')->will($this->returnValue('OPTIONS')); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getRelativePath')->will($this->returnValue('non-existing-endpoint')); - - $this->mockHttpResponse->expects($this->never())->method('setHeader'); - $this->mockComponentContext->expects($this->never())->method('setParameter'); - $this->httpOptionsComponent->handle($this->mockComponentContext); - } - - /** - * @test - */ - public function handleSetsAllowHeaderForMatchingOptionsRequests() - { - $mockGraphQLEndpoints = [ - 'existing-endpoint' => ['querySchema' => 'Foo'], - ]; - $this->inject($this->httpOptionsComponent, 'endpoints', $mockGraphQLEndpoints); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getMethod')->will($this->returnValue('OPTIONS')); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getRelativePath')->will($this->returnValue('existing-endpoint')); - - $this->mockHttpResponse->expects($this->exactly(1))->method('setHeader')->with('Allow', 'GET, POST'); - $this->httpOptionsComponent->handle($this->mockComponentContext); - } - - /** - * @test - */ - public function handleCancelsComponentChainForMatchingOptionsRequests() - { - $mockGraphQLEndpoints = [ - 'existing-endpoint' => ['querySchema' => 'Foo'], - ]; - $this->inject($this->httpOptionsComponent, 'endpoints', $mockGraphQLEndpoints); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getMethod')->will($this->returnValue('OPTIONS')); - $this->mockHttpRequest->expects($this->atLeastOnce())->method('getRelativePath')->will($this->returnValue('existing-endpoint')); - - $this->mockComponentContext->expects($this->exactly(1))->method('setParameter')->with(ComponentChain::class, 'cancel', true); - $this->httpOptionsComponent->handle($this->mockComponentContext); - } -} \ No newline at end of file diff --git a/Tests/Unit/IterableAccessibleObjectTest.php b/Tests/Unit/IterableAccessibleObjectTest.php deleted file mode 100644 index 5b1872c..0000000 --- a/Tests/Unit/IterableAccessibleObjectTest.php +++ /dev/null @@ -1,86 +0,0 @@ -iterableAccessibleObject = new IterableAccessibleObject([ - new ExampleObject('Foo'), - new ExampleObject('Bar') - ]); - } - - /** - * @test - * @expectedException \InvalidArgumentException - */ - public function constructorThrowsExceptionWhenPassedNull() - { - new IterableAccessibleObject(null); - } - - /** - * @test - * @expectedException \InvalidArgumentException - */ - public function constructorThrowsExceptionWhenPassedNonIterableObject() - { - /** @noinspection PhpParamsInspection */ - new IterableAccessibleObject(new \stdClass()); - } - - /** - * @test - */ - public function constructorConvertsArraysToArrayIterator() - { - $someArray = ['foo' => 'Foo', 'bar' => 'Bar']; - $iterableAccessibleObject = new IterableAccessibleObject($someArray); - $this->assertInstanceOf(\ArrayIterator::class, $iterableAccessibleObject->getIterator()); - $this->assertSame($someArray, iterator_to_array($iterableAccessibleObject->getIterator())); - } - - /** - * @test - */ - public function getIteratorReturnsTheUnalteredInnerIterator() - { - $someIterator = new \ArrayIterator(['foo' => 'Foo', 'bar' => 'Bar']); - $iterableAccessibleObject = new IterableAccessibleObject($someIterator); - $this->assertSame($someIterator, $iterableAccessibleObject->getIterator()); - } - - /** - * @test - */ - public function currentObjectElementsAreWrapped() - { - $this->assertInstanceOf(AccessibleObject::class, $this->iterableAccessibleObject->current()); - $this->assertSame('Foo', $this->iterableAccessibleObject->current()['someString']); - } - - /** - * @test - */ - public function currentScalarElementsAreNotWrapped() - { - $arrayProperty = ['foo' => 'Foo', 'bar' => 'Bar']; - $iterableAccessibleObject = new IterableAccessibleObject([$arrayProperty]); - - $this->assertSame($arrayProperty, $iterableAccessibleObject->current()); - } -} \ No newline at end of file diff --git a/Tests/Unit/TypeResolverTest.php b/Tests/Unit/TypeResolverTest.php deleted file mode 100644 index a293f3a..0000000 --- a/Tests/Unit/TypeResolverTest.php +++ /dev/null @@ -1,67 +0,0 @@ -typeResolver = new TypeResolver(); - } - - /** - * @test - * @expectedException \InvalidArgumentException - */ - public function getThrowsExceptionIfTypeClassNameIsNoString() - { - $this->typeResolver->get(123); - } - - /** - * @test - * @expectedException \InvalidArgumentException - */ - public function getThrowsExceptionIfTypeClassNameIsNoValidTypeDefinition() - { - $this->typeResolver->get('stdClass'); - } - - /** - * @test - */ - public function getThrowsExceptionIfTypeHasCircularDependenciesWithoutCallbacks() - { - $this->expectException(\RuntimeException::class); - $this->typeResolver->get(InvalidExampleType::class); - } - - /** - * @test - */ - public function getSupportsRecursiveTypes() - { - $exampleType = $this->typeResolver->get(ValidExampleType::class); - $this->assertSame('ValidExampleType', $exampleType->name); - } - - /** - * @test - */ - public function getReturnsTheSameInstancePerType() - { - $exampleType1 = $this->typeResolver->get(ValidExampleType::class); - $exampleType2 = $this->typeResolver->get(ValidExampleType::class); - $this->assertSame($exampleType1, $exampleType2); - } -} diff --git a/composer.json b/composer.json index 0f7f00d..50526e1 100644 --- a/composer.json +++ b/composer.json @@ -4,19 +4,18 @@ "name": "wwwision/graphql", "license": "MIT", "require": { - "neos/flow": "^7.0", - "webonyx/graphql-php": "^0.13.0" + "neos/flow": "^7.3 || ^8.0 || ^8.1 || 8.2 || ^8.3", + "webonyx/graphql-php": "^15", + "psr/http-message": "^2" + }, + "require-dev": { + "roave/security-advisories": "dev-latest" }, "autoload": { "psr-4": { "Wwwision\\GraphQL\\": "Classes" } }, - "autoload-dev": { - "psr-4": { - "Wwwision\\GraphQL\\Tests\\": "Tests" - } - }, "extra": { "neos": { "package-key": "Wwwision.GraphQL" diff --git a/playground.png b/playground.png deleted file mode 100644 index f7942b9917ea2c15eb6a830344ec74b1f537c976..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52472 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%0DMUFX_B{X%)jtq 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