diff --git a/Classes/GraphQL/Middleware/GraphQLMiddleware.php b/Classes/GraphQL/Middleware/GraphQLMiddleware.php new file mode 100644 index 000000000..87174cf57 --- /dev/null +++ b/Classes/GraphQL/Middleware/GraphQLMiddleware.php @@ -0,0 +1,181 @@ +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); + $resolver = new Resolver( + $api, + $this->typeNamespaces === [] ? [(new ReflectionClass($api))->getNamespaceName()] : $this->typeNamespaces, + $this->customResolvers, + ); + $config = ServerConfig::create() + ->setSchema($this->getSchema($resolver)) + ->setFieldResolver($resolver) + ->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(fn(Throwable $error) => $this->handleGraphQLError($error, $formatter), $errors); + } + + private function handleGraphQLError(Throwable $error, callable $formatter): array + { + if (!$error instanceof ClientAware || !$error->isClientSafe()) { + $this->throwableStorage->logThrowable($error); + } + $formattedError = $formatter($error); + $originalException = $error->getPrevious(); + if ($originalException instanceof FlowException) { + $formattedError['extensions']['statusCode'] = $originalException->getStatusCode(); + $formattedError['extensions']['referenceCode'] = $originalException->getReferenceCode(); + } + if ($originalException?->getPrevious() instanceof CoerceException) { + $formattedError['extensions']['issues'] = $originalException->getPrevious()->issues; + } + return $formattedError; + } + + private function getSchema(Resolver $resolver): 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, $this->customResolvers)->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, $resolver->typeConfigDecorator(...)); + } +} diff --git a/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php b/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php new file mode 100644 index 000000000..bf5e06632 --- /dev/null +++ b/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php @@ -0,0 +1,55 @@ +debugMode, + $this->corsOrigin, + $this->streamFactory, + $this->responseFactory, + $this->schemaCache, + $this->throwableStorage, + $this->securityContext, + $this->objectManager, + $this->customResolversFactory->create($customResolversSettings ?? []), + ); + } +} diff --git a/Classes/GraphQL/Resolver/CustomResolversFactory.php b/Classes/GraphQL/Resolver/CustomResolversFactory.php new file mode 100644 index 000000000..87f2b2e35 --- /dev/null +++ b/Classes/GraphQL/Resolver/CustomResolversFactory.php @@ -0,0 +1,44 @@ + $settingsForType) { + Assert::string($typeName); + Assert::isArray($settingsForType); + foreach ($settingsForType as $fieldName => $customResolverSettings) { + Assert::string($fieldName); + Assert::isArray($customResolverSettings); + Assert::keyExists($customResolverSettings, 'resolverClassName'); + $resolverClass = $this->objectManager->get($customResolverSettings['resolverClassName']); + $customResolvers[] = new CustomResolver( + $typeName, + $fieldName, + $resolverClass->{ + $customResolverSettings['resolverMethodName'] ?? $fieldName + }(...), + $customResolverSettings['description'] ?? null, + ); + } + } + return CustomResolvers::create(...$customResolvers); + } +} diff --git a/Classes/GraphQL/Resolver/Resolver.php b/Classes/GraphQL/Resolver/Resolver.php new file mode 100644 index 000000000..49d9a40ab --- /dev/null +++ b/Classes/GraphQL/Resolver/Resolver.php @@ -0,0 +1,160 @@ + $typeNamespaces + */ + public function __construct( + private readonly object $api, + private readonly array $typeNamespaces, + CustomResolvers $customResolvers = null, + ) { + $this->customResolvers = $customResolvers ?? CustomResolvers::create(); + } + + /** + * @param array|null> $args + */ + public function __invoke( + object|string|null $objectValue, + array $args, + mixed $contextValue, + ResolveInfo $info + ): mixed { + $fieldName = $info->fieldName; + $objectValue ??= $this->api; + + $customResolver = $this->customResolvers->get($info->parentType->name, $fieldName); + if ($customResolver !== null) { + $objectValue = ($customResolver->callback)($objectValue, ... + $this->convertArguments($args, $info->fieldDefinition)); + } elseif (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; + } + + public function typeConfigDecorator(array $typeConfig, TypeDefinitionNode $typeDefinitionNode): array + { + if ($typeDefinitionNode instanceof InterfaceTypeDefinitionNode) { + $typeConfig['resolveType'] = static fn( + $value, + $context, + ResolveInfo $info + ) => $info->schema->getType(substr($value::class, strrpos($value::class, '\\') + 1)); + } + if ($typeDefinitionNode instanceof EnumTypeDefinitionNode) { + $className = $this->resolveClassName($typeConfig['name']); + $schema = Parser::getSchema($className); + if ($schema instanceof EnumSchema) { + $typeConfig['values'] = array_map(static fn(EnumCaseSchema $caseSchema + ) => $caseSchema->instantiate(null), $schema->caseSchemas); + } + } + return $typeConfig; + } + + /** + * @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|UnitEnum|array|null $argument + * @return string|bool|int|array|object|null + */ + private function convertArgument( + string|bool|int|UnitEnum|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 (CoerceException $e) { + throw new RequestError($e->getMessage(), 1688654808, $e); + } 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/Configuration/Caches.yaml b/Configuration/Caches.yaml index cba1ba896..03a233c07 100644 --- a/Configuration/Caches.yaml +++ b/Configuration/Caches.yaml @@ -1,3 +1,10 @@ Flowpack_Media_Ui_PollingCache: frontend: Neos\Cache\Frontend\StringFrontend backend: Neos\Cache\Backend\FileBackend + +Flowpack_Media_Ui_GraphQLSchemaCache: + frontend: Neos\Cache\Frontend\VariableFrontend + 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 000000000..e154f7b3d --- /dev/null +++ b/Configuration/Development/Caches.yaml @@ -0,0 +1,3 @@ +Flowpack_Media_Ui_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 000000000..e7ddc1df6 --- /dev/null +++ b/Configuration/Development/Settings.yaml @@ -0,0 +1,5 @@ +Flowpack: + Media: + Ui: + GraphQL: + debugMode: true diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 678767d78..a7e30cefd 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -10,3 +10,17 @@ 2: # cache lifetime value: 10 + +'Flowpack\Media\Ui\GraphQL\Middleware\GraphQLMiddlewareFactory': + arguments: + 1: + setting: Flowpack.Media.Ui.GraphQL.debugMode + 2: + setting: Flowpack.Media.Ui.GraphQL.corsOrigin + 3: + object: + factoryObjectName: 'Neos\Flow\Cache\CacheManager' + factoryMethodName: 'getCache' + arguments: + 1: + value: 'Flowpack_Media_Ui_GraphQLSchemaCache' diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 6f687b629..efe5552a8 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -2,3 +2,7 @@ Flowpack: Media: Ui: maximumFileUploadLimit: 10 + GraphQL: + debugMode: false + # The "Access-Control-Allow-Origin" response header + corsOrigin: '*' diff --git a/composer.json b/composer.json index 26e3f577c..79f3ff329 100644 --- a/composer.json +++ b/composer.json @@ -1,43 +1,43 @@ { - "name": "flowpack/media-ui", - "description": "This module allows managing media assets including pictures, videos, audio and documents.", - "type": "neos-package", - "require": { - "php": "^8.1", - "neos/media": "^7.3 || ~8.0", - "neos/neos": "^7.3 || ~8.0", - "neos/neos-ui": "^7.3 || ~8.0", - "t3n/graphql": "^2.1 || ^3.0.2", - "t3n/graphql-upload": "^1.0 || ^2.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" - }, - "suggest": { - "phpstan/phpstan": "For running code quality checks", - "flowpack/neos-asset-usage": "Allows filtering unused assets and other related features", - "flowpack/entity-usage-databasestorage": "Required for the asset usage features" - }, - "scripts": { - "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests", - "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests", - "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php", - "codestyle:ci": "phpstan analyse" - }, - "license": "GPL-3.0-or-later", - "autoload": { - "psr-4": { - "Flowpack\\Media\\Ui\\": "Classes" - } - }, - "autoload-dev": { - "psr-4": { - "Flowpack\\Media\\Ui\\Tests\\": "Tests" - } - }, - "config": { - "allow-plugins": { - "neos/composer-plugin": true - } + "name": "flowpack/media-ui", + "description": "This module allows managing media assets including pictures, videos, audio and documents.", + "type": "neos-package", + "require": { + "php": ">=8.1", + "neos/media": "^8.3", + "neos/neos": "^8.3", + "neos/neos-ui": "^8.3", + "webonyx/graphql-php": "^15", + "wwwision/types-graphql": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "phpstan/phpstan": "For running code quality checks", + "flowpack/neos-asset-usage": "Allows filtering unused assets and other related features", + "flowpack/entity-usage-databasestorage": "Required for the asset usage features" + }, + "scripts": { + "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests", + "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests", + "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php", + "codestyle:ci": "phpstan analyse" + }, + "license": "GPL-3.0-or-later", + "autoload": { + "psr-4": { + "Flowpack\\Media\\Ui\\": "Classes" } + }, + "autoload-dev": { + "psr-4": { + "Flowpack\\Media\\Ui\\Tests\\": "Tests" + } + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true + } + } }