├── .php_cs.dist ├── LICENSE ├── README.md ├── composer.json └── src ├── Bridge └── Symfony │ ├── Action │ └── EndpointAction.php │ ├── DependencyInjection │ ├── Configuration.php │ └── Extension.php │ ├── Event │ ├── EventDispatcherVersionHelper.php │ └── SubscriptionExtraEvent.php │ ├── EventListener │ └── SpoolNotificationsHandler.php │ ├── ExecutorAdapter │ └── GraphQLBundleExecutorAdapter.php │ ├── OverblogGraphQLSubscriptionBundle.php │ └── Resources │ └── config │ ├── routing │ ├── multiple.yaml │ └── single.yaml │ └── subscription.yml ├── Builder.php ├── Entity └── Subscriber.php ├── MessageTypes.php ├── Provider ├── AbstractJwtProvider.php ├── JwtPublishProvider.php └── JwtSubscribeProvider.php ├── Request └── JsonParser.php ├── RootValue.php ├── Storage ├── FilesystemSubscribeStorage.php ├── MemorySubscriptionStorage.php └── SubscribeStorageInterface.php ├── SubscriptionManager.php └── Update.php /.php_cs.dist: -------------------------------------------------------------------------------- 1 | exclude('vendor') 5 | ->name('*.php') 6 | ->in([__DIR__.'/src', __DIR__.'/tests']) 7 | ; 8 | 9 | return PhpCsFixer\Config::create() 10 | ->setRules( 11 | [ 12 | '@Symfony' => true, 13 | '@PHP71Migration' => true, 14 | '@PHP71Migration:risky' => true, 15 | 'single_blank_line_before_namespace' => true, 16 | 'ordered_imports' => true, 17 | 'concat_space' => ['spacing' => 'none'], 18 | 'phpdoc_no_alias_tag' => ['type' => 'var'], 19 | 'no_mixed_echo_print' => ['use' => 'echo'], 20 | 'binary_operator_spaces' => ['align_double_arrow' => false, 'align_equals' => false], 21 | 'general_phpdoc_annotation_remove' => ['author', 'category', 'copyright', 'created', 'license', 'package', 'since', 'subpackage', 'version'], 22 | 'native_function_invocation' => true, 23 | 'fully_qualified_strict_types' => true, 24 | ] 25 | ) 26 | ->setFinder($finder) 27 | ->setUsingCache(true) 28 | ; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Overblog 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OverblogGraphQLSubscription 2 | 3 | This library allow using GraphQL subscription over [Mercure protocol](https://mercure.rocks/) 4 | with any implementation of [GraphQL PHP](https://github.com/webonyx/graphql-php). It Comes out-of-the-box 5 | with a Symfony Bridge so it can be easily combine with [OverblogGraphQLBundle](https://github.com/overblog/GraphQLBundle) 6 | or [API Platform](https://github.com/api-platform/api-platform) or other Symfony implementation based on GraphQL PHP. 7 | 8 | ## Installation 9 | 10 | ```bash 11 | composer req overblog/graphql-subscription 12 | ``` 13 | 14 | ### Default builder executor 15 | 16 | ```php 17 | = 4.2 92 | # trailing_slash_on_root: false 93 | 94 | # Uncomment to enabled multiple schema 95 | #overblog_graphql_subscription_multiple_endpoint: 96 | # resource: "@OverblogGraphQLSubscriptionBundle/Resources/config/routing/multiple.yaml" 97 | # prefix: /subscriptions 98 | ``` 99 | 100 | ### Handling CORS preflight headers 101 | 102 | NelmioCorsBundle is recommended to manage CORS preflight, 103 | [follow instructions](https://github.com/nelmio/NelmioCorsBundle#installation) to install it. 104 | 105 | Here a configuration assuming that subscription endpoint is `/subscriptions`: 106 | 107 | ```yaml 108 | nelmio_cors: 109 | defaults: 110 | origin_regex: true 111 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 112 | allow_methods: ['GET', 'OPTIONS', 'POST'] 113 | allow_headers: ['Content-Type'] 114 | max_age: 3600 115 | paths: 116 | '^/subscriptions': ~ 117 | ``` 118 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overblog/graphql-subscription", 3 | "type": "library", 4 | "description": "GraphQL native subscription.", 5 | "keywords": ["GraphQL", "subscription", "mercure"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Overblog", 10 | "homepage": "http://www.over-blog.com" 11 | } 12 | ], 13 | "config" : { 14 | "sort-packages": true, 15 | "bin-dir": "bin" 16 | }, 17 | "require": { 18 | "php": ">=7.1", 19 | "ext-json": "*", 20 | "psr/log": "^1.0", 21 | "symfony/mercure": ">=0.2.0", 22 | "webonyx/graphql-php": "*" 23 | }, 24 | "require-dev": { 25 | "lcobucci/jwt": "^3.2", 26 | "overblog/graphql-bundle": ">=0.11", 27 | "phpstan/extension-installer": "^1.0", 28 | "phpstan/phpstan-phpunit": "^0.11", 29 | "phpstan/phpstan-shim": "^0.11.19", 30 | "phpstan/phpstan-symfony": "^0.11", 31 | "phpunit/phpunit": "^7.2", 32 | "symfony/framework-bundle": ">=3.4", 33 | "symfony/messenger": ">=4.0", 34 | "symfony/yaml": ">=3.4" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Overblog\\GraphQLSubscription\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Overblog\\GraphQLSubscription\\Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "test": "bin/phpunit --color=always -v --debug", 48 | "static-analysis": [ 49 | "phpstan analyse --ansi --memory-limit=1G" 50 | ], 51 | "install-cs": "test -f bin/php-cs-fixer.phar || wget https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.15.3/php-cs-fixer.phar -O bin/php-cs-fixer.phar", 52 | "fix-cs": [ 53 | "@install-cs", 54 | "@php bin/php-cs-fixer.phar fix --diff -v --allow-risky=yes --ansi" 55 | ], 56 | "check-cs": [ 57 | "@install-cs", 58 | "@php bin/php-cs-fixer.phar fix --dry-run --diff -v --allow-risky=yes --ansi" 59 | ], 60 | "code-quality": [ 61 | "rm composer.lock", 62 | "@composer install --ansi", 63 | "@static-analysis", 64 | "@check-cs" 65 | ] 66 | }, 67 | "suggest": { 68 | "nelmio/cors-bundle": "To manage CORS prefight" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Action/EndpointAction.php: -------------------------------------------------------------------------------- 1 | requestParser = $requestParser ?? [$this, 'parseRequest']; 25 | } 26 | 27 | public function __invoke( 28 | Request $request, 29 | SubscriptionManager $subscriptionManager, 30 | ?EventDispatcherInterface $dispatcher = null, 31 | ?string $schemaName = null 32 | ): Response { 33 | return $this->createJsonResponse($request, function (Request $request) use ($schemaName, $subscriptionManager, $dispatcher): ?array { 34 | [$type, $id, $payload] = ($this->requestParser)($request); 35 | try { 36 | $extra = []; 37 | if ($dispatcher && MessageTypes::GQL_START === $type) { 38 | $extra = new \ArrayObject($extra); 39 | EventDispatcherVersionHelper::dispatch( 40 | $dispatcher, new SubscriptionExtraEvent($extra), SubscriptionExtraEvent::class 41 | ); 42 | $extra = $extra->getArrayCopy(); 43 | } 44 | 45 | return $subscriptionManager->handle( 46 | \compact('type', 'id', 'payload'), 47 | $schemaName, 48 | $extra 49 | ); 50 | } catch (\InvalidArgumentException $e) { 51 | throw new BadRequestHttpException($e->getMessage(), $e); 52 | } 53 | }); 54 | } 55 | 56 | private function createJsonResponse(Request $request, callable $payloadHandler): JsonResponse 57 | { 58 | return new JsonResponse($payloadHandler($request)); 59 | } 60 | 61 | private function parseRequest(Request $request): array 62 | { 63 | return (new JsonParser())($request->headers->get('content-type'), $request->getContent()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | varDir = $varDir; 23 | } 24 | 25 | public function getConfigTreeBuilder() 26 | { 27 | $treeBuilder = new TreeBuilder(self::NAME); 28 | $rootNode = $this->getRootNodeWithoutDeprecation($treeBuilder, self::NAME); 29 | 30 | $rootNode 31 | ->addDefaultsIfNotSet() 32 | ->children() 33 | ->append($this->mercureHubNode('mercure_hub')) 34 | ->scalarNode('topic_url_pattern') 35 | ->isRequired() 36 | ->info('the url pattern to build topic, it should contain the "{id}" replacement string, optional placeholders "{channel}" and "{schemaName}" can also be used.') 37 | ->example('https://example.com/subscriptions/{id} or https://{schemaName}.example.com/{channel}/{id}.json') 38 | ->end() 39 | ->scalarNode('bus') 40 | ->info('Name of the Messenger bus where the handler for this hub must be registered. Default to the default bus if Messenger is enabled.') 41 | ->end() 42 | ->arrayNode('storage') 43 | ->addDefaultsIfNotSet() 44 | ->children() 45 | ->scalarNode('handler_id') 46 | ->defaultValue(FilesystemSubscribeStorage::class) 47 | ->info('The service id to handler subscription persistence.') 48 | ->end() 49 | ->scalarNode('path') 50 | ->defaultValue($this->varDir.'/graphql-subscriptions') 51 | ->info('The path where to stock files is useful only id using default filesystem subscription storage.') 52 | ->end() 53 | ->end() 54 | ->end() 55 | ->append($this->callableServiceNode('graphql_executor')) 56 | ->append($this->callableServiceNode('schema_builder', false)) 57 | ->append($this->callableServiceNode('request_parser', false)) 58 | ->end() 59 | ->end(); 60 | 61 | return $treeBuilder; 62 | } 63 | 64 | private function mercureHubNode(string $name): ArrayNodeDefinition 65 | { 66 | $builder = new TreeBuilder($name); 67 | $node = $this->getRootNodeWithoutDeprecation($builder, $name); 68 | $node 69 | ->isRequired() 70 | ->addDefaultsIfNotSet() 71 | ->normalizeKeys(false) 72 | ->children() 73 | ->scalarNode('handler_id') 74 | ->defaultNull() 75 | ->info('Mercure handler service id.') 76 | ->example('mercure.hub.default.publisher') 77 | ->end() 78 | ->scalarNode('url') 79 | ->info('URL of mercure hub endpoint.') 80 | ->example('https://private.example.com/hub') 81 | ->end() 82 | ->scalarNode('public_url') 83 | ->info('Public URL of mercure hub endpoint.') 84 | ->example('https://public.example.com/hub') 85 | ->end() 86 | ->scalarNode('http_client') 87 | ->defaultNull() 88 | ->info('The ID of the http client service.') 89 | ->end() 90 | ->arrayNode('publish') 91 | ->addDefaultsIfNotSet() 92 | ->children() 93 | ->scalarNode('provider') 94 | ->defaultValue(JwtPublishProvider::class) 95 | ->info('The ID of a service to call to generate the publisher JSON Web Token.') 96 | ->end() 97 | ->scalarNode('secret_key')->info('The JWT secret key to use to publish to this hub.')->end() 98 | ->end() 99 | ->end() 100 | ->arrayNode('subscribe') 101 | ->addDefaultsIfNotSet() 102 | ->children() 103 | ->scalarNode('provider') 104 | ->defaultValue(JwtSubscribeProvider::class) 105 | ->info('The ID of a service to call to generate the subscriber JSON Web Token.') 106 | ->end() 107 | ->scalarNode('secret_key')->info('The JWT secret key to use for subscribe.')->end() 108 | ->end() 109 | ->end() 110 | ->end() 111 | ->end(); 112 | 113 | return $node; 114 | } 115 | 116 | private function callableServiceNode(string $name, bool $isRequired = true): ArrayNodeDefinition 117 | { 118 | $builder = new TreeBuilder($name); 119 | $node = $this->getRootNodeWithoutDeprecation($builder, $name); 120 | if ($isRequired) { 121 | $node->isRequired(); 122 | } 123 | $node 124 | ->addDefaultsIfNotSet() 125 | ->beforeNormalization() 126 | ->ifString() 127 | ->then(function (string $callableString): array { 128 | $callable = \explode('::', $callableString, 2); 129 | 130 | return ['id' => $callable[0], 'method' => $callable[1] ?? null]; 131 | }) 132 | ->end() 133 | ->children() 134 | ->scalarNode('id')->isRequired()->end() 135 | ->scalarNode('method')->defaultNull()->end() 136 | ->end() 137 | ->end(); 138 | 139 | return $node; 140 | } 141 | 142 | private function getRootNodeWithoutDeprecation(TreeBuilder $builder, string $name): ArrayNodeDefinition 143 | { 144 | // BC layer for symfony/config 4.1 and older 145 | return \method_exists($builder, 'getRootNode') ? $builder->getRootNode() : $builder->root($name); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/DependencyInjection/Extension.php: -------------------------------------------------------------------------------- 1 | getConfiguration($configs, $container); 30 | $config = $this->processConfiguration($configuration, $configs); 31 | 32 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 33 | $loader->load('subscription.yml'); 34 | 35 | $this->setMercureHubDefinition($config, $container); 36 | $this->setJwtProvidersDefinitions($config, $container); 37 | $this->setStorageDefinition($config, $container); 38 | $this->setSubscriptionManagerDefinition($config, $container); 39 | $this->setSubscriptionActionRequestParser($config, $container); 40 | } 41 | 42 | private function setSubscriptionActionRequestParser(array $config, ContainerBuilder $container): void 43 | { 44 | $container->register(EndpointAction::class) 45 | ->setArguments([$this->resolveCallableServiceReference($config['request_parser'])]) 46 | ->addTag('controller.service_arguments') 47 | ; 48 | } 49 | 50 | private function setSubscriptionManagerDefinition(array $config, ContainerBuilder $container): void 51 | { 52 | $bus = $config['bus'] ?? null; 53 | $attributes = null === $bus ? [] : ['bus' => $bus]; 54 | 55 | $container->register(SubscriptionManager::class) 56 | ->setArguments([ 57 | new Reference($this->getAlias().'.publisher'), 58 | new Reference(SubscribeStorageInterface::class), 59 | $this->resolveCallableServiceReference($config['graphql_executor']), 60 | $config['topic_url_pattern'], 61 | new Reference($this->getAlias().'.jwt_subscribe_provider'), 62 | new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), 63 | $this->resolveCallableServiceReference($config['schema_builder']), 64 | $config['mercure_hub']['public_url'] ?? null, 65 | ]) 66 | ->addMethodCall( 67 | 'setBus', 68 | [new Reference($bus ?? 'message_bus', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)] 69 | ) 70 | ->addTag('messenger.message_handler', $attributes); 71 | 72 | if (GraphQLBundleExecutorAdapter::class === $config['graphql_executor']['id']) { 73 | $container->register(GraphQLBundleExecutorAdapter::class) 74 | ->setArguments([new Reference('Overblog\\GraphQLBundle\\Request\\Executor')]); 75 | } 76 | } 77 | 78 | private function setStorageDefinition(array $config, ContainerBuilder $container): void 79 | { 80 | $storageID = $config['storage']['handler_id']; 81 | if (FilesystemSubscribeStorage::class === $storageID) { 82 | $container->register(SubscribeStorageInterface::class, $storageID) 83 | ->setArguments([$config['storage']['path']]); 84 | } else { 85 | $container->setAlias(SubscribeStorageInterface::class, $storageID); 86 | } 87 | } 88 | 89 | private function setJwtProvidersDefinitions(array $config, ContainerBuilder $container): void 90 | { 91 | foreach (['publish' => JwtPublishProvider::class, 'subscribe' => JwtSubscribeProvider::class] as $type => $default) { 92 | $options = $config['mercure_hub'][$type]; 93 | // jwt publish and subscribe providers 94 | $jwtProviderID = \sprintf('%s.jwt_%s_provider', $this->getAlias(), $type); 95 | if ($default === $options['provider']) { 96 | if (!isset($options['secret_key'])) { 97 | throw new InvalidConfigurationException(\sprintf( 98 | '"mercure_hub.%s.secret_key" is required when using with default provider %s.', 99 | $type, 100 | $default 101 | )); 102 | } 103 | 104 | $container->register($jwtProviderID, $default) 105 | ->addArgument($options['secret_key']); 106 | } else { 107 | $container->setAlias($jwtProviderID, $options['provider']); 108 | } 109 | } 110 | } 111 | 112 | private function setMercureHubDefinition(array $config, ContainerBuilder $container): void 113 | { 114 | $serviceId = \sprintf('%s.publisher', $this->getAlias()); 115 | 116 | if (null !== $config['mercure_hub']['handler_id']) { 117 | $container->setAlias($serviceId, $config['mercure_hub']['handler_id']); 118 | } else { 119 | $container->register($serviceId, Publisher::class) 120 | ->setArguments([ 121 | $config['mercure_hub']['url'], 122 | new Reference('overblog_graphql_subscription.jwt_publish_provider'), 123 | null === $config['mercure_hub']['http_client'] ? 124 | new Reference('http_client', ContainerInterface::IGNORE_ON_INVALID_REFERENCE) : 125 | new Reference($config['mercure_hub']['http_client']), 126 | ]); 127 | } 128 | } 129 | 130 | private function resolveCallableServiceReference(array $callableServiceParams) 131 | { 132 | $callableServiceRef = null; 133 | if (isset($callableServiceParams['id'])) { 134 | $callableServiceRef = new Reference($callableServiceParams['id']); 135 | if (null !== $callableServiceParams['method']) { 136 | $callableServiceRef = [$callableServiceRef, $callableServiceParams['method']]; 137 | } 138 | } 139 | 140 | return $callableServiceRef; 141 | } 142 | 143 | public function getAlias() 144 | { 145 | return Configuration::NAME; 146 | } 147 | 148 | public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface 149 | { 150 | return new Configuration($container->getParameter('kernel.project_dir').'/var'); 151 | } 152 | 153 | /** 154 | * {@inheritdoc} 155 | */ 156 | public function prepend(ContainerBuilder $container): void 157 | { 158 | if ($container->hasExtension('mercure')) { 159 | $container->prependExtensionConfig( 160 | Configuration::NAME, 161 | [ 162 | 'mercure_hub' => [ 163 | 'publisher' => ['handler_id' => 'mercure.hub.default.publisher'], 164 | ], 165 | ] 166 | ); 167 | } 168 | 169 | if ($container->hasExtension('overblog_graphql')) { 170 | $container->prependExtensionConfig( 171 | Configuration::NAME, 172 | [ 173 | 'graphql_executor' => GraphQLBundleExecutorAdapter::class, 174 | ] 175 | ); 176 | } elseif ($container->hasExtension('api_platform')) { 177 | $container->prependExtensionConfig( 178 | Configuration::NAME, 179 | [ 180 | 'graphql_executor' => 'api_platform.graphql.executor::executeQuery', 181 | 'schema_builder' => 'api_platform.graphql.schema_builder::getSchema', 182 | ] 183 | ); 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Event/EventDispatcherVersionHelper.php: -------------------------------------------------------------------------------- 1 | = 4.3 13 | */ 14 | final class EventDispatcherVersionHelper 15 | { 16 | /** 17 | * @return bool 18 | */ 19 | public static function isForLegacy() 20 | { 21 | return Kernel::VERSION_ID < 40300; 22 | } 23 | 24 | /** 25 | * @param EventDispatcherInterface $dispatcher 26 | * @param object $event 27 | * @param string|null $eventName 28 | * 29 | * @return object the event 30 | */ 31 | public static function dispatch(EventDispatcherInterface $dispatcher, $event, $eventName) 32 | { 33 | if (self::isForLegacy()) { 34 | return $dispatcher->dispatch($eventName, $event); 35 | } else { 36 | return $dispatcher->dispatch($event, $eventName); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Event/SubscriptionExtraEvent.php: -------------------------------------------------------------------------------- 1 | = 4.3 8 | use Symfony\Contracts\EventDispatcher\Event; 9 | 10 | if (EventDispatcherVersionHelper::isForLegacy()) { 11 | final class SubscriptionExtraEvent extends \Symfony\Component\EventDispatcher\Event 12 | { 13 | /** @var \ArrayObject */ 14 | private $extra; 15 | 16 | /** 17 | * @param \ArrayObject $extra 18 | */ 19 | public function __construct(\ArrayObject $extra) 20 | { 21 | $this->extra = $extra; 22 | } 23 | 24 | /** 25 | * @return \ArrayObject 26 | */ 27 | public function getExtra(): \ArrayObject 28 | { 29 | return $this->extra; 30 | } 31 | } 32 | } else { 33 | final class SubscriptionExtraEvent extends Event 34 | { 35 | /** @var \ArrayObject */ 36 | private $extra; 37 | 38 | /** 39 | * @param \ArrayObject $extra 40 | */ 41 | public function __construct(\ArrayObject $extra) 42 | { 43 | $this->extra = $extra; 44 | } 45 | 46 | /** 47 | * @return \ArrayObject 48 | */ 49 | public function getExtra(): \ArrayObject 50 | { 51 | return $this->extra; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/EventListener/SpoolNotificationsHandler.php: -------------------------------------------------------------------------------- 1 | subscriptionManager = $subscriptionManager; 16 | } 17 | 18 | public function onKernelTerminate(): void 19 | { 20 | $this->subscriptionManager->processNotificationsSpool(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/ExecutorAdapter/GraphQLBundleExecutorAdapter.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 17 | } 18 | 19 | public function __invoke( 20 | ?string $schemaName, 21 | string $query, 22 | $rootValue = null, 23 | $context = null, 24 | ?array $variables = null, 25 | ?string $operationName = null 26 | ): ExecutionResult { 27 | return $this->executor->execute($schemaName, \compact('query', 'variables', 'operationName'), $rootValue); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/OverblogGraphQLSubscriptionBundle.php: -------------------------------------------------------------------------------- 1 | extension instanceof ExtensionInterface) { 16 | $this->extension = new Extension(); 17 | } 18 | 19 | return $this->extension; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/routing/multiple.yaml: -------------------------------------------------------------------------------- 1 | overblog_graphql_subscription_multiple_endpoint: 2 | path: /{schemaName} 3 | methods: POST 4 | defaults: 5 | _controller: Overblog\GraphQLSubscription\Bridge\Symfony\Action\EndpointAction 6 | _format: "json" 7 | requirements: 8 | schemaName: '[^/]+' 9 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/routing/single.yaml: -------------------------------------------------------------------------------- 1 | overblog_graphql_subscription_endpoint: 2 | path: / 3 | methods: POST 4 | defaults: 5 | _controller: Overblog\GraphQLSubscription\Bridge\Symfony\Action\EndpointAction 6 | _format: "json" 7 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/subscription.yml: -------------------------------------------------------------------------------- 1 | services: 2 | Overblog\GraphQLSubscription\Bridge\Symfony\EventListener\SpoolNotificationsHandler: 3 | arguments: 4 | - '@Overblog\GraphQLSubscription\SubscriptionManager' 5 | tags: 6 | - {name: kernel.event_listener, event: kernel.terminate, method: onKernelTerminate} 7 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | hubUrl = $hubUrl; 67 | 68 | return $this; 69 | } 70 | 71 | public function setPublicHubUrl(?string $publicHubUrl): self 72 | { 73 | $this->publicHubUrl = $publicHubUrl; 74 | 75 | return $this; 76 | } 77 | 78 | public function setTopicUrlPattern(string $topicUrlPattern): self 79 | { 80 | $this->topicUrlPattern = $topicUrlPattern; 81 | 82 | return $this; 83 | } 84 | 85 | public function setExecutorHandler(callable $executorHandler): self 86 | { 87 | $this->executorHandler = $executorHandler; 88 | 89 | return $this; 90 | } 91 | 92 | public function setPublisher(?Publisher $publisher): self 93 | { 94 | $this->publisher = $publisher; 95 | 96 | return $this; 97 | } 98 | 99 | public function setPublisherHttpClient(?HttpClientInterface $publisherHttpClient): self 100 | { 101 | $this->publisherHttpClient = $publisherHttpClient; 102 | 103 | return $this; 104 | } 105 | 106 | public function setPublisherProvider(?callable $publisherProvider): self 107 | { 108 | $this->publisherProvider = $publisherProvider; 109 | 110 | return $this; 111 | } 112 | 113 | public function setPublisherSecretKey(string $publisherSecretKey): self 114 | { 115 | $this->publisherSecretKey = $publisherSecretKey; 116 | 117 | return $this; 118 | } 119 | 120 | public function setSubscriberProvider(?callable $subscriberProvider): self 121 | { 122 | $this->subscriberProvider = $subscriberProvider; 123 | 124 | return $this; 125 | } 126 | 127 | public function setSubscriberSecretKey(string $subscriberSecretKey): self 128 | { 129 | $this->subscriberSecretKey = $subscriberSecretKey; 130 | 131 | return $this; 132 | } 133 | 134 | public function setSubscribeStorage(?SubscribeStorageInterface $subscribeStorage): self 135 | { 136 | $this->subscribeStorage = $subscribeStorage; 137 | 138 | return $this; 139 | } 140 | 141 | public function setSubscribeStoragePath(?string $subscribeStoragePath): self 142 | { 143 | $this->subscribeStoragePath = $subscribeStoragePath; 144 | 145 | return $this; 146 | } 147 | 148 | public function setLogger(?LoggerInterface $logger): self 149 | { 150 | $this->logger = $logger; 151 | 152 | return $this; 153 | } 154 | 155 | public function setSchemaBuilder(?callable $schemaBuilder): self 156 | { 157 | $this->schemaBuilder = $schemaBuilder; 158 | 159 | return $this; 160 | } 161 | 162 | public function setSchema(?Schema $schema): self 163 | { 164 | $this->schema = $schema; 165 | 166 | return $this; 167 | } 168 | 169 | public function getSubscriptionManager(): SubscriptionManager 170 | { 171 | $publisher = $this->publisher; 172 | if (null === $publisher) { 173 | $publisher = new Publisher( 174 | $this->hubUrl, 175 | $this->publisherProvider ?? new JwtPublishProvider($this->publisherSecretKey), 176 | $this->publisherHttpClient 177 | ); 178 | } 179 | $subscribeStorage = $this->subscribeStorage ?? new FilesystemSubscribeStorage( 180 | $this->subscribeStoragePath ?? \sys_get_temp_dir().'/graphql-subscriptions' 181 | ); 182 | $subscriberProvider = $this->subscriberProvider ?? new JwtSubscribeProvider($this->subscriberSecretKey); 183 | $schemaBuilder = $this->schemaBuilder; 184 | if (null === $schemaBuilder && null !== $this->schema) { 185 | $schema = $this->schema; 186 | $schemaBuilder = static function () use ($schema): Schema { 187 | return $schema; 188 | }; 189 | } 190 | 191 | return new SubscriptionManager( 192 | $publisher, 193 | $subscribeStorage, 194 | $this->executorHandler ?? [GraphQL::class, 'executeQuery'], 195 | $this->topicUrlPattern, 196 | $subscriberProvider, 197 | $this->logger, 198 | $schemaBuilder, 199 | $this->publicHubUrl 200 | ); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Entity/Subscriber.php: -------------------------------------------------------------------------------- 1 | id = $id; 29 | $this->topic = $topic; 30 | $this->query = $query; 31 | $this->channel = $channel; 32 | $this->variables = $variables; 33 | $this->operationName = $operationName; 34 | $this->schemaName = $schemaName; 35 | $this->extras = $extras; 36 | } 37 | 38 | public function getId(): string 39 | { 40 | return $this->id; 41 | } 42 | 43 | public function getTopic(): string 44 | { 45 | return $this->topic; 46 | } 47 | 48 | public function getQuery(): string 49 | { 50 | return $this->query; 51 | } 52 | 53 | public function getChannel(): string 54 | { 55 | return $this->channel; 56 | } 57 | 58 | public function getVariables(): ?array 59 | { 60 | return $this->variables; 61 | } 62 | 63 | public function getOperationName(): ?string 64 | { 65 | return $this->operationName; 66 | } 67 | 68 | public function getSchemaName(): ?string 69 | { 70 | return $this->schemaName; 71 | } 72 | 73 | public function getExtras(): ?array 74 | { 75 | return $this->extras; 76 | } 77 | 78 | /** 79 | * {@inheritdoc} 80 | */ 81 | public function serialize(): string 82 | { 83 | return \serialize(\array_filter(\get_object_vars($this))); 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function unserialize($serialized): void 90 | { 91 | $data = \unserialize($serialized); 92 | 93 | foreach ($data as $property => $value) { 94 | $this->$property = $value; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/MessageTypes.php: -------------------------------------------------------------------------------- 1 | Server 10 | public const GQL_STOP = 'stop'; // Client -> Server 11 | public const GQL_DATA = 'data'; // Server -> Client 12 | public const GQL_ERROR = 'error'; // Server -> Client 13 | public const GQL_SUCCESS = 'success'; // Server -> Client 14 | 15 | public const CLIENT_MESSAGE_TYPES = [ 16 | self::GQL_START, 17 | self::GQL_STOP, 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /src/Provider/AbstractJwtProvider.php: -------------------------------------------------------------------------------- 1 | checkRequirements(); 20 | $this->secretKey = $secretKey; 21 | } 22 | 23 | protected function checkRequirements(): void 24 | { 25 | if (!\class_exists('Lcobucci\JWT\Builder')) { 26 | throw new \RuntimeException(\sprintf( 27 | 'To use "%s" you must install "lcobucci/jwt" package using composer.', 28 | \get_class($this) 29 | )); 30 | } 31 | } 32 | 33 | protected function generateJWT(string $type, array $targets): string 34 | { 35 | return (string) (new Builder()) 36 | ->set('mercure', [$type => $targets]) 37 | ->sign(new Sha256(), $this->secretKey) 38 | ->getToken(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Provider/JwtPublishProvider.php: -------------------------------------------------------------------------------- 1 | generateJWT('publish', ['*']); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Provider/JwtSubscribeProvider.php: -------------------------------------------------------------------------------- 1 | generateJWT('subscribe', [$target]); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Request/JsonParser.php: -------------------------------------------------------------------------------- 1 | createInvalidMessageException($contentType); 13 | } 14 | if (false !== \strpos($contentType, ';')) { 15 | $contentType = \explode(';', (string) $contentType, 2)[0]; 16 | } 17 | 18 | if ('application/json' === \strtolower($contentType)) { 19 | $input = \json_decode($requestBody, true); 20 | if (JSON_ERROR_NONE !== \json_last_error()) { 21 | throw new \RuntimeException(\sprintf( 22 | 'Could not decode request body %s cause %s', 23 | \json_encode($requestBody), 24 | \json_encode(\json_last_error_msg()) 25 | )); 26 | } 27 | 28 | $type = $input['type'] ?? null; 29 | $id = isset($input['id']) ? (string) $input['id'] : null; 30 | $payload = $input['payload'] ?? null; 31 | 32 | return [$type, $id, $payload]; 33 | } else { 34 | throw $this->createInvalidMessageException($contentType); 35 | } 36 | } 37 | 38 | private function createInvalidMessageException(?string $contentType): \RuntimeException 39 | { 40 | return new \RuntimeException(\sprintf( 41 | 'Only "application/json" content-type is managed by parser but got %s.', 42 | \json_encode($contentType) 43 | )); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/RootValue.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 18 | $this->subscriber = $subscriber; 19 | } 20 | 21 | public function isPropagationStopped() 22 | { 23 | return $this->propagationStopped; 24 | } 25 | 26 | public function stopPropagation(): void 27 | { 28 | $this->propagationStopped = true; 29 | } 30 | 31 | public function getPayload() 32 | { 33 | return $this->payload; 34 | } 35 | 36 | public function getSubscriber(): Subscriber 37 | { 38 | return $this->subscriber; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Storage/FilesystemSubscribeStorage.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 20 | } 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public function store(Subscriber $subscriber): bool 26 | { 27 | $fileName = \sprintf( 28 | '%s/%s--%s%s', 29 | $this->directory, 30 | $subscriber->getId(), 31 | $subscriber->getChannel(), 32 | $subscriber->getSchemaName() ? '@'.$subscriber->getSchemaName() : '' 33 | ); 34 | if ($this->write($fileName, $subscriber)) { 35 | return true; 36 | } else { 37 | throw new \RuntimeException(\sprintf('Failed to write subscriber to file "%s".', $fileName)); 38 | } 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function findSubscribersByChannelAndSchemaName(string $channel, ?string $schemaName): iterable 45 | { 46 | $pattern = \sprintf( 47 | '%s/*--%s%s', 48 | $this->directory, 49 | $channel, 50 | $schemaName ? '@'.$schemaName : '' 51 | ); 52 | 53 | foreach (\glob($pattern) ?: [] as $filename) { 54 | try { 55 | yield $this->unserialize(\file_get_contents($filename)); 56 | } catch (\Throwable $e) { 57 | // Ignoring files that could not be unserialized 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function delete(string $subscriberID): bool 66 | { 67 | $fileName = $this->findOneByID($subscriberID); 68 | if (null === $fileName) { 69 | throw new \InvalidArgumentException(\sprintf( 70 | 'Subscriber with id "%s" could not be found.', 71 | $subscriberID 72 | )); 73 | } 74 | 75 | return @\unlink($fileName); 76 | } 77 | 78 | private function findOneByID(string $subscriberID): ?string 79 | { 80 | $pattern = \sprintf( 81 | '%s/%s--*', 82 | $this->directory, 83 | $subscriberID 84 | ); 85 | $files = \glob($pattern); 86 | 87 | return empty($files) ? null : $files[0]; 88 | } 89 | 90 | private function write(string $file, Subscriber $subscriber): bool 91 | { 92 | return false !== \file_put_contents($file, $this->serialize($subscriber)); 93 | } 94 | 95 | private function serialize(Subscriber $subscriber): string 96 | { 97 | return \serialize($subscriber); 98 | } 99 | 100 | private function unserialize($str): Subscriber 101 | { 102 | return \unserialize($str); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Storage/MemorySubscriptionStorage.php: -------------------------------------------------------------------------------- 1 | $subscriber) { 21 | $this->store($subscriber); 22 | } 23 | } 24 | } 25 | 26 | public function store(Subscriber $subscriber): bool 27 | { 28 | $this->storage[] = $subscriber; 29 | 30 | return true; 31 | } 32 | 33 | public function findSubscribersByChannelAndSchemaName(string $channel, ?string $schemaName): iterable 34 | { 35 | foreach ($this->storage as $subscriber) { 36 | if ($subscriber->getChannel() === $channel && $subscriber->getSchemaName() === $schemaName) { 37 | yield $subscriber; 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function delete(string $subscriberID): bool 46 | { 47 | unset($this->storage[$subscriberID]); 48 | 49 | return true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Storage/SubscribeStorageInterface.php: -------------------------------------------------------------------------------- 1 | publisher = $publisher; 67 | $this->executorHandler = $executor; 68 | $this->subscribeStorage = $subscribeStorage; 69 | $this->validateTopicUrlPattern($topicUrlPattern); 70 | $this->topicUrlPattern = $topicUrlPattern; 71 | $this->jwtSubscribeProvider = $jwtSubscribeProvider; 72 | $this->logger = $logger ?? new NullLogger(); 73 | $this->schemaBuilder = $schemaBuilder; 74 | $this->publicHubUrl = $publicHubUrl; 75 | } 76 | 77 | public function validateTopicUrlPattern(string $topicUrlPattern): void 78 | { 79 | if (false === \filter_var($topicUrlPattern, FILTER_VALIDATE_URL) || false === \strpos($topicUrlPattern, '{id}')) { 80 | throw new \InvalidArgumentException(\sprintf( 81 | 'Topic url pattern should be a valid url and should contain the "{id}" replacement string but got %s.', 82 | \json_encode($topicUrlPattern) 83 | )); 84 | } 85 | } 86 | 87 | public function getExecutorHandler(): callable 88 | { 89 | return $this->executorHandler; 90 | } 91 | 92 | public function getSubscribeStorage(): SubscribeStorageInterface 93 | { 94 | return $this->subscribeStorage; 95 | } 96 | 97 | public function setSchemaBuilder(?callable $schemaBuilder): self 98 | { 99 | $this->schemaBuilder = $schemaBuilder; 100 | 101 | return $this; 102 | } 103 | 104 | public function setBus(?MessageBusInterface $bus): self 105 | { 106 | $this->bus = $bus; 107 | 108 | return $this; 109 | } 110 | 111 | public function notify(string $channel, $payload, string $schemaName = null): void 112 | { 113 | $data = ['payload' => $payload, 'schemaName' => $schemaName, 'channel' => $channel]; 114 | if (null !== $this->bus) { 115 | $update = new Update(\serialize($data)); 116 | $this->bus->dispatch($update); 117 | } else { 118 | $this->notificationsSpool[] = $data; 119 | } 120 | } 121 | 122 | public function __invoke(Update $update): void 123 | { 124 | $this->handleData(\unserialize($update->getData())); 125 | } 126 | 127 | public function processNotificationsSpool(bool $catchException = true): void 128 | { 129 | foreach ($this->notificationsSpool as $data) { 130 | try { 131 | $this->handleData($data); 132 | } catch (\Throwable $e) { 133 | if (!$catchException) { 134 | throw $e; 135 | } 136 | $this->logger->critical( 137 | 'Caught exception or error in %s', 138 | [ 139 | 'exception' => [ 140 | 'file' => $e->getFile(), 141 | 'code' => $e->getCode(), 142 | 'message' => $e->getMessage(), 143 | 'line' => $e->getLine(), 144 | ], 145 | ] 146 | ); 147 | } 148 | } 149 | $this->notificationsSpool = []; 150 | } 151 | 152 | public function handle( 153 | array $data, 154 | ?string $schemaName = null, 155 | ?array $extra = null 156 | ): ?array { 157 | $type = $data['type'] ?? null; 158 | 159 | switch ($type) { 160 | case MessageTypes::GQL_START: 161 | $payload = [ 162 | 'query' => '', 163 | 'variables' => null, 164 | 'operationName' => null, 165 | ]; 166 | 167 | if (\is_array($data['payload'])) { 168 | $payload = \array_filter($data['payload']) + $payload; 169 | } 170 | 171 | return $this->handleStart( 172 | $payload['query'], $schemaName, $payload['variables'], $payload['operationName'], $extra 173 | ); 174 | 175 | case MessageTypes::GQL_STOP: 176 | $deleted = isset($data['id']) ? $this->subscribeStorage->delete($data['id']) : false; 177 | 178 | return [ 179 | 'type' => $deleted ? MessageTypes::GQL_SUCCESS : MessageTypes::GQL_ERROR, 180 | ]; 181 | 182 | default: 183 | throw new \InvalidArgumentException(\sprintf( 184 | 'Only "%s" types are handle by "SubscriptionHandler".', 185 | \implode('", ', MessageTypes::CLIENT_MESSAGE_TYPES) 186 | )); 187 | } 188 | } 189 | 190 | private function handleStart( 191 | string $query, ?string $schemaName, ?array $variableValues, ?string $operationName, ?array $extra 192 | ): array { 193 | $result = $this->executeQuery( 194 | $schemaName, 195 | $query, 196 | null, 197 | null, 198 | $variableValues, 199 | $operationName 200 | ); 201 | 202 | if (empty($result['errors'])) { 203 | $document = self::parseQuery($query); 204 | $operationDef = self::extractOperationDefinition($document, $operationName); 205 | $channel = self::extractSubscriptionChannel($operationDef); 206 | $id = $this->generateId(); 207 | $topic = $this->buildTopicUrl($id, $channel, $schemaName); 208 | 209 | $this->getSubscribeStorage()->store(new Subscriber( 210 | $id, 211 | $topic, 212 | $query, 213 | $channel, 214 | $variableValues, 215 | $operationName, 216 | $schemaName, 217 | $extra 218 | )); 219 | 220 | $result['extensions']['__sse'] = [ 221 | 'id' => $id, 222 | 'topic' => $topic, 223 | 'hubUrl' => $this->buildHubUrl($topic), 224 | 'accessToken' => ($this->jwtSubscribeProvider)($topic), 225 | ]; 226 | 227 | return $result; 228 | } else { 229 | return $result; 230 | } 231 | } 232 | 233 | private function handleData(array $data): void 234 | { 235 | $subscribers = $this->subscribeStorage 236 | ->findSubscribersByChannelAndSchemaName($data['channel'], $data['schemaName']); 237 | foreach ($subscribers as $subscriber) { 238 | $this->executeAndSendNotification($data['payload'], $subscriber); 239 | } 240 | } 241 | 242 | private function generateId(): string 243 | { 244 | $sha1 = \sha1(\uniqid(\time().\random_int(0, \PHP_INT_MAX), true)); 245 | 246 | return \substr($sha1, 0, 12); 247 | } 248 | 249 | private function buildTopicUrl(string $id, string $channel, ?string $schemaName): string 250 | { 251 | return \str_replace( 252 | ['{id}', '{channel}', '{schemaName}'], 253 | [$id, $channel, $schemaName], 254 | $this->topicUrlPattern 255 | ); 256 | } 257 | 258 | private function buildHubUrl(string $topic): ?string 259 | { 260 | if (null === $this->publicHubUrl) { 261 | return null; 262 | } 263 | 264 | return \sprintf( 265 | '%s%stopic=%s', 266 | $this->publicHubUrl, 267 | false === \strpos($this->publicHubUrl, '?') ? '?' : '&', 268 | \urlencode($topic) 269 | ); 270 | } 271 | 272 | private function executeQuery( 273 | ?string $schemaName, 274 | string $query, 275 | ?RootValue $rootValue = null, 276 | $context = null, 277 | ?array $variableValues = null, 278 | ?string $operationName = null 279 | ): array { 280 | $result = ($this->executorHandler)( 281 | $this->schemaBuilder ? ($this->schemaBuilder)($schemaName) : $schemaName, 282 | $query, 283 | $rootValue, 284 | $context, 285 | $variableValues, 286 | $operationName 287 | ); 288 | 289 | if ($result instanceof ExecutionResult) { 290 | $result = $result->toArray(); 291 | } 292 | 293 | return $result; 294 | } 295 | 296 | private function executeAndSendNotification($payload, Subscriber $subscriber): void 297 | { 298 | $result = $this->executeQuery( 299 | $subscriber->getSchemaName(), 300 | $subscriber->getQuery(), 301 | $rootValue = new RootValue($payload, $subscriber), 302 | null, 303 | $subscriber->getVariables(), 304 | $subscriber->getOperationName() 305 | ); 306 | 307 | if (!$rootValue->isPropagationStopped()) { 308 | $data = \json_encode([ 309 | 'type' => MessageTypes::GQL_DATA, 310 | 'id' => $subscriber->getId(), 311 | 'payload' => $result, 312 | ]); 313 | if (false === $data) { 314 | throw new \RuntimeException(\sprintf('Failed to json encode result (%s).', \json_last_error_msg())); 315 | } 316 | 317 | $update = new MercureUpdate( 318 | $subscriber->getTopic(), $data, [$subscriber->getTopic()] 319 | ); 320 | ($this->publisher)($update); 321 | } 322 | } 323 | 324 | private static function parseQuery(string $query): DocumentNode 325 | { 326 | return Parser::parse($query); 327 | } 328 | 329 | private function extractSubscriptionChannel(OperationDefinitionNode $operationDefinitionNode): string 330 | { 331 | /** @var FieldNode[] $selections */ 332 | $selections = $operationDefinitionNode 333 | ->selectionSet 334 | ->selections; 335 | 336 | if (1 !== \count($selections)) { 337 | if (null === $operationDefinitionNode->name->value) { 338 | $message = \sprintf('Anonymous Subscription operations must select only one top level field.'); 339 | } else { 340 | $message = \sprintf('Subscription "%s" must select only one top level field.', $operationDefinitionNode->name->value); 341 | } 342 | 343 | throw new \InvalidArgumentException($message); 344 | } 345 | 346 | return $selections[0]->name->value; 347 | } 348 | 349 | private static function extractOperationDefinition(DocumentNode $document, $operationName = null): OperationDefinitionNode 350 | { 351 | $operationNode = null; 352 | 353 | foreach ($document->definitions as $def) { 354 | if (!$def instanceof OperationDefinitionNode) { 355 | continue; 356 | } 357 | 358 | if (!$operationName || (isset($def->name->value) && $def->name->value === $operationName)) { 359 | $operationNode = $def; 360 | } 361 | } 362 | 363 | if (null === $operationNode || 'subscription' !== $operationNode->operation) { 364 | throw new \InvalidArgumentException(\sprintf( 365 | 'Operation should be of type subscription but %s given', 366 | isset($operationNode->operation) ? \json_encode($operationNode->operation) : 'none' 367 | )); 368 | } 369 | 370 | return $operationNode; 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /src/Update.php: -------------------------------------------------------------------------------- 1 | data = $data; 14 | } 15 | 16 | public function getData(): string 17 | { 18 | return $this->data; 19 | } 20 | } 21 | --------------------------------------------------------------------------------