diff --git a/config/builder_pdf.php b/config/builder_pdf.php index a150f137..4b60b1b4 100644 --- a/config/builder_pdf.php +++ b/config/builder_pdf.php @@ -25,6 +25,7 @@ service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; @@ -38,6 +39,7 @@ service('router')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->call('setRequestContext', [service('.sensiolabs_gotenberg.request_context')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; @@ -50,6 +52,7 @@ service('request_stack'), service('twig')->nullOnInvalid(), ]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->call('setLogger', [service('logger')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; @@ -61,6 +64,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; @@ -71,6 +75,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; @@ -81,6 +86,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.pdf_builder') ; }; diff --git a/config/builder_screenshot.php b/config/builder_screenshot.php index 7e04c109..2c2d4fa7 100644 --- a/config/builder_screenshot.php +++ b/config/builder_screenshot.php @@ -22,6 +22,7 @@ service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.screenshot_builder') ; @@ -35,6 +36,7 @@ service('router')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->call('setRequestContext', [service('.sensiolabs_gotenberg.request_context')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.screenshot_builder') ; @@ -48,6 +50,7 @@ service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) + ->call('setWebhookConfigurationRegistry', [service('.sensiolabs_gotenberg.webhook_configuration_registry')->nullOnInvalid()]) ->tag('sensiolabs_gotenberg.screenshot_builder') ; }; diff --git a/config/services.php b/config/services.php index 1bea2636..d678cb27 100644 --- a/config/services.php +++ b/config/services.php @@ -2,6 +2,8 @@ use Sensiolabs\GotenbergBundle\Client\GotenbergClient; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistry; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistryInterface; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Sensiolabs\GotenbergBundle\Gotenberg; use Sensiolabs\GotenbergBundle\GotenbergInterface; @@ -63,4 +65,13 @@ ]) ->alias(GotenbergInterface::class, 'sensiolabs_gotenberg') ; + + $services->set('.sensiolabs_gotenberg.webhook_configuration_registry', WebhookConfigurationRegistry::class) + ->args([ + service('router'), + service('.sensiolabs_gotenberg.request_context')->nullOnInvalid(), + ]) + ->tag('sensiolabs_gotenberg.webhook_configuration_registry') + ->alias(WebhookConfigurationRegistryInterface::class, '.sensiolabs_gotenberg.webhook_configuration_registry') + ; }; diff --git a/src/Builder/AsyncBuilderInterface.php b/src/Builder/AsyncBuilderInterface.php new file mode 100644 index 00000000..ca5d8e25 --- /dev/null +++ b/src/Builder/AsyncBuilderInterface.php @@ -0,0 +1,11 @@ + + */ + private array $webhookExtraHeaders = []; + private \Closure $operationIdGenerator; + private WebhookConfigurationRegistry|null $webhookConfigurationRegistry = null; + + public function generateAsync(): string + { + $operationId = ($this->operationIdGenerator ?? self::defaultOperationIdGenerator(...))(); + $this->logger?->debug('Generating a file asynchronously with operation id {sensiolabs_gotenberg.operation_id} using {sensiolabs_gotenberg.builder} builder.', [ + 'sensiolabs_gotenberg.operation_id' => $operationId, + 'sensiolabs_gotenberg.builder' => $this::class, + ]); + + $this->webhookExtraHeaders['X-Gotenberg-Operation-Id'] = $operationId; + $headers = [ + 'Gotenberg-Webhook-Url' => $this->webhookUrl, + 'Gotenberg-Webhook-Error-Url' => $this->errorWebhookUrl, + 'Gotenberg-Webhook-Extra-Http-Headers' => json_encode($this->webhookExtraHeaders, \JSON_THROW_ON_ERROR), + ]; + if (null !== $this->fileName) { + $headers['Gotenberg-Output-Filename'] = basename($this->fileName, '.pdf'); + } + $this->client->call($this->getEndpoint(), $this->getMultipartFormData(), $headers); + + return $operationId; + } + + public function setWebhookConfigurationRegistry(WebhookConfigurationRegistry $registry): static + { + $this->webhookConfigurationRegistry = $registry; + + return $this; + } + + public function webhookConfiguration(string $webhook): static + { + if (null === $this->webhookConfigurationRegistry) { + throw new WebhookConfigurationException('The WebhookConfigurationRegistry is not available.'); + } + $webhookConfiguration = $this->webhookConfigurationRegistry->get($webhook); + + return $this->webhookUrls($webhookConfiguration['success'], $webhookConfiguration['error']); + } + + public function webhookUrls(string $successWebhook, string|null $errorWebhook = null): static + { + $this->webhookUrl = $successWebhook; + $this->errorWebhookUrl = $errorWebhook ?? $successWebhook; + + return $this; + } + + /** + * @param array $extraHeaders + */ + public function webhookExtraHeaders(array $extraHeaders): static + { + $this->webhookExtraHeaders = array_merge($this->webhookExtraHeaders, $extraHeaders); + + return $this; + } + + public function operationIdGenerator(\Closure $operationIdGenerator): static + { + $this->operationIdGenerator = $operationIdGenerator; + + return $this; + } + + protected static function defaultOperationIdGenerator(): string + { + return 'gotenberg_'.bin2hex(random_bytes(16)).microtime(true); + } +} diff --git a/src/Builder/Pdf/AbstractChromiumPdfBuilder.php b/src/Builder/Pdf/AbstractChromiumPdfBuilder.php index f5a9386c..347dc4ef 100644 --- a/src/Builder/Pdf/AbstractChromiumPdfBuilder.php +++ b/src/Builder/Pdf/AbstractChromiumPdfBuilder.php @@ -4,6 +4,7 @@ use Sensiolabs\GotenbergBundle\Builder\CookieAwareTrait; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistry; use Sensiolabs\GotenbergBundle\Enumeration\EmulatedMediaType; use Sensiolabs\GotenbergBundle\Enumeration\PaperSizeInterface; use Sensiolabs\GotenbergBundle\Enumeration\Part; @@ -28,8 +29,9 @@ public function __construct( AssetBaseDirFormatter $asset, private readonly RequestStack $requestStack, private readonly Environment|null $twig = null, + WebhookConfigurationRegistry|null $webhookConfigurationRegistry = null, ) { - parent::__construct($gotenbergClient, $asset); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry); $normalizers = [ 'extraHttpHeaders' => function (mixed $value): array { @@ -586,6 +588,7 @@ protected function addConfiguration(string $configurationName, mixed $value): vo 'fail_on_console_exceptions' => $this->failOnConsoleExceptions($value), 'skip_network_idle_event' => $this->skipNetworkIdleEvent($value), 'metadata' => $this->metadata($value), + 'webhook' => null, default => throw new InvalidBuilderConfiguration(sprintf('Invalid option "%s": no method does not exist in class "%s" to configured it.', $configurationName, static::class)), }; } diff --git a/src/Builder/Pdf/AbstractPdfBuilder.php b/src/Builder/Pdf/AbstractPdfBuilder.php index 9eddae3d..6435ff75 100644 --- a/src/Builder/Pdf/AbstractPdfBuilder.php +++ b/src/Builder/Pdf/AbstractPdfBuilder.php @@ -2,12 +2,15 @@ namespace Sensiolabs\GotenbergBundle\Builder\Pdf; +use Sensiolabs\GotenbergBundle\Builder\AsyncBuilderInterface; +use Sensiolabs\GotenbergBundle\Builder\AsyncBuilderTrait; use Sensiolabs\GotenbergBundle\Builder\DefaultBuilderTrait; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; -abstract class AbstractPdfBuilder implements PdfBuilderInterface +abstract class AbstractPdfBuilder implements PdfBuilderInterface, AsyncBuilderInterface { + use AsyncBuilderTrait; use DefaultBuilderTrait; public function __construct( diff --git a/src/Builder/Pdf/UrlPdfBuilder.php b/src/Builder/Pdf/UrlPdfBuilder.php index 4d6c3fce..6b25b7d8 100644 --- a/src/Builder/Pdf/UrlPdfBuilder.php +++ b/src/Builder/Pdf/UrlPdfBuilder.php @@ -3,6 +3,7 @@ namespace Sensiolabs\GotenbergBundle\Builder\Pdf; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistry; use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Symfony\Component\HttpFoundation\RequestStack; @@ -22,8 +23,9 @@ public function __construct( RequestStack $requestStack, Environment|null $twig = null, private readonly UrlGeneratorInterface|null $urlGenerator = null, + WebhookConfigurationRegistry|null $webhookConfigurationRegistry = null, ) { - parent::__construct($gotenbergClient, $asset, $requestStack, $twig); + parent::__construct($gotenbergClient, $asset, $requestStack, $twig, $webhookConfigurationRegistry); $this->addNormalizer('route', $this->generateUrlFromRoute(...)); } diff --git a/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php b/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php index 81776220..51cf8c0e 100644 --- a/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php +++ b/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php @@ -4,6 +4,7 @@ use Sensiolabs\GotenbergBundle\Builder\CookieAwareTrait; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistryInterface; use Sensiolabs\GotenbergBundle\Enumeration\EmulatedMediaType; use Sensiolabs\GotenbergBundle\Enumeration\Part; use Sensiolabs\GotenbergBundle\Enumeration\ScreenshotFormat; @@ -26,8 +27,9 @@ public function __construct( AssetBaseDirFormatter $asset, private readonly RequestStack $requestStack, private readonly Environment|null $twig = null, + WebhookConfigurationRegistryInterface|null $webhookConfigurationRegistry = null, ) { - parent::__construct($gotenbergClient, $asset); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry); $normalizers = [ 'extraHttpHeaders' => function (mixed $value): array { diff --git a/src/Builder/Screenshot/AbstractScreenshotBuilder.php b/src/Builder/Screenshot/AbstractScreenshotBuilder.php index dc6a25c9..8d203663 100644 --- a/src/Builder/Screenshot/AbstractScreenshotBuilder.php +++ b/src/Builder/Screenshot/AbstractScreenshotBuilder.php @@ -2,14 +2,17 @@ namespace Sensiolabs\GotenbergBundle\Builder\Screenshot; +use Sensiolabs\GotenbergBundle\Builder\AsyncBuilderInterface; +use Sensiolabs\GotenbergBundle\Builder\AsyncBuilderTrait; use Sensiolabs\GotenbergBundle\Builder\DefaultBuilderTrait; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Sensiolabs\GotenbergBundle\Enumeration\Part; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Symfony\Component\Mime\Part\DataPart; -abstract class AbstractScreenshotBuilder implements ScreenshotBuilderInterface +abstract class AbstractScreenshotBuilder implements ScreenshotBuilderInterface, AsyncBuilderInterface { + use AsyncBuilderTrait; use DefaultBuilderTrait; public function __construct( diff --git a/src/Builder/Screenshot/UrlScreenshotBuilder.php b/src/Builder/Screenshot/UrlScreenshotBuilder.php index ff797e7e..dd622d00 100644 --- a/src/Builder/Screenshot/UrlScreenshotBuilder.php +++ b/src/Builder/Screenshot/UrlScreenshotBuilder.php @@ -3,6 +3,7 @@ namespace Sensiolabs\GotenbergBundle\Builder\Screenshot; use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistryInterface; use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Symfony\Component\HttpFoundation\RequestStack; @@ -22,8 +23,9 @@ public function __construct( RequestStack $requestStack, Environment|null $twig = null, private readonly UrlGeneratorInterface|null $urlGenerator = null, + WebhookConfigurationRegistryInterface|null $webhookConfigurationRegistry = null, ) { - parent::__construct($gotenbergClient, $asset, $requestStack, $twig); + parent::__construct($gotenbergClient, $asset, $requestStack, $twig, $webhookConfigurationRegistry); $this->addNormalizer('route', $this->generateUrlFromRoute(...)); } diff --git a/src/Client/GotenbergClient.php b/src/Client/GotenbergClient.php index 96d3ad5b..31312279 100644 --- a/src/Client/GotenbergClient.php +++ b/src/Client/GotenbergClient.php @@ -28,7 +28,7 @@ public function call(string $endpoint, array $multipartFormData, array $headers ], ); - if (200 !== $response->getStatusCode()) { + if (!\in_array($response->getStatusCode(), [200, 204], true)) { throw new ClientException($response->getContent(false), $response->getStatusCode()); } diff --git a/src/Debug/Builder/TraceablePdfBuilder.php b/src/Debug/Builder/TraceablePdfBuilder.php index ccb5e785..c79df3bc 100644 --- a/src/Debug/Builder/TraceablePdfBuilder.php +++ b/src/Debug/Builder/TraceablePdfBuilder.php @@ -2,6 +2,7 @@ namespace Sensiolabs\GotenbergBundle\Debug\Builder; +use Sensiolabs\GotenbergBundle\Builder\AsyncBuilderInterface; use Sensiolabs\GotenbergBundle\Builder\GotenbergFileResult; use Sensiolabs\GotenbergBundle\Builder\Pdf\PdfBuilderInterface; use Symfony\Component\Stopwatch\Stopwatch; @@ -50,6 +51,22 @@ public function generate(): GotenbergFileResult return $response; } + public function generateAsync(): string + { + if (!$this->inner instanceof AsyncBuilderInterface) { + throw new \LogicException(sprintf('The inner builder of %s must implement %s.', self::class, AsyncBuilderInterface::class)); + } + + $name = self::$count.'.'.$this->inner::class.'::'.__FUNCTION__; + ++self::$count; + + $swEvent = $this->stopwatch->start($name, 'gotenberg.generate_pdf'); + $gotenbergTrace = $this->inner->generateAsync(); + $swEvent->stop(); + + return $gotenbergTrace; + } + /** * @param array $arguments */ diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 70a3ba34..59cd609a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -37,9 +37,13 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->append($this->addNamedWebhookDefinition()) ->arrayNode('default_options') ->addDefaultsIfNotSet() ->children() + ->scalarNode('webhook') + ->info('Webhook configuration name.') + ->end() ->arrayNode('pdf') ->addDefaultsIfNotSet() ->append($this->addPdfHtmlNode()) @@ -73,6 +77,7 @@ private function addPdfHtmlNode(): NodeDefinition ; $this->addChromiumPdfOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -87,6 +92,7 @@ private function addPdfUrlNode(): NodeDefinition ; $this->addChromiumPdfOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -101,6 +107,7 @@ private function addPdfMarkdownNode(): NodeDefinition ; $this->addChromiumPdfOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -115,6 +122,7 @@ private function addScreenshotHtmlNode(): NodeDefinition ; $this->addChromiumScreenshotOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -129,6 +137,7 @@ private function addScreenshotUrlNode(): NodeDefinition ; $this->addChromiumScreenshotOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -143,6 +152,7 @@ private function addScreenshotMarkdownNode(): NodeDefinition ; $this->addChromiumScreenshotOptionsNode($treebuilder->getRootNode()); + $this->addWebhookDeclarationNode($treebuilder->getRootNode()); return $treebuilder->getRootNode(); } @@ -651,4 +661,93 @@ private function addPdfMetadata(): NodeDefinition ->end() ; } + + private function addNamedWebhookDefinition(): NodeDefinition + { + $treeBuilder = new TreeBuilder('webhook'); + + return $treeBuilder->getRootNode() + ->defaultValue([]) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->validate() + ->ifTrue(static function ($option) { + return !\is_string($option); + }) + ->thenInvalid('Invalid header name %s') + ->end() + ->end() + ->append($this->addWebhookConfigurationNode('success')) + ->append($this->addWebhookConfigurationNode('error')) + ->end() + ->validate() + ->ifTrue(static function ($option): bool { + return !isset($option['success']); + }) + ->thenInvalid('Invalid webhook configuration : At least a "success" key is required.') + ->end() + ->end(); + } + + private function addWebhookDeclarationNode(ArrayNodeDefinition $parent): void + { + $parent + ->children() + ->arrayNode('webhook') + ->info('Webhook configuration name or definition.') + ->beforeNormalization() + ->ifString() + ->then(static function (string $v): array { + return ['config_name' => $v]; + }) + ->end() + ->children() + ->append($this->addWebhookConfigurationNode('success')) + ->append($this->addWebhookConfigurationNode('error')) + ->scalarNode('config_name') + ->info('The name of the webhook configuration to use.') + ->end() + ->end() + ->validate() + ->ifTrue(static function ($option): bool { + return !isset($option['config_name']) && !isset($option['success']); + }) + ->thenInvalid('Invalid webhook configuration : either reference an existing webhook configuration or declare a new one with "success" and optionally "error" keys.') + ->end() + ->end(); + } + + private function addWebhookConfigurationNode(string $name): NodeDefinition + { + $treeBuilder = new TreeBuilder($name); + + return $treeBuilder->getRootNode() + ->children() + ->scalarNode('url') + ->info('The URL to call.') + ->end() + ->variableNode('route') + ->info('Route configuration.') + ->beforeNormalization() + ->ifArray() + ->then(function (array $v): array { + return [$v[0], $v[1] ?? []]; + }) + ->ifString() + ->then(function (string $v): array { + return [$v, []]; + }) + ->end() + ->validate() + ->ifTrue(function ($v): bool { + return !\is_array($v) || \count($v) !== 2 || !\is_string($v[0]) || !\is_array($v[1]); + }) + ->thenInvalid('The "route" parameter must be a string or an array containing a string and an array.') + ->end() + ->end() + ->end() + ; + } } diff --git a/src/DependencyInjection/SensiolabsGotenbergExtension.php b/src/DependencyInjection/SensiolabsGotenbergExtension.php index c33f3316..66abfb2e 100644 --- a/src/DependencyInjection/SensiolabsGotenbergExtension.php +++ b/src/DependencyInjection/SensiolabsGotenbergExtension.php @@ -4,6 +4,7 @@ use Sensiolabs\GotenbergBundle\Builder\Pdf\PdfBuilderInterface; use Sensiolabs\GotenbergBundle\Builder\Screenshot\ScreenshotBuilderInterface; +use Sensiolabs\GotenbergBundle\DependencyInjection\WebhookConfiguration\WebhookConfigurationRegistryInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -12,13 +13,16 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Routing\RequestContext; +/** + * @phpstan-type WebhookDefinition array{url?: string, route?: array{0: string, 1: array}} + */ class SensiolabsGotenbergExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); - /** @var array{base_uri: string, http_client: string|null, request_context?: array{base_uri?: string}, assets_directory: string, default_options: array{pdf: array{html: array, url: array, markdown: array, office: array, merge: array, convert: array}, screenshot: array{html: array, url: array, markdown: array}}} $config */ + /** @var array{base_uri: string, http_client: string|null, request_context?: array{base_uri?: string}, assets_directory: string, webhook: array, default_options: array{pdf: array{html: array, url: array, markdown: array, office: array, merge: array, convert: array}, screenshot: array{html: array, url: array, markdown: array}, webhook?: string}} $config */ $config = $this->processConfiguration($configuration, $configs); $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); @@ -48,9 +52,14 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('sensiolabs_gotenberg.screenshot_builder') ; + $container->registerForAutoconfiguration(WebhookConfigurationRegistryInterface::class) + ->addTag('sensiolabs_gotenberg.webhook_configuration_registry') + ; + $container->setAlias('sensiolabs_gotenberg.http_client', new Alias($config['http_client'] ?? 'http_client', false)); $baseUri = $config['request_context']['base_uri'] ?? null; + $defaultWebhookConfig = $config['default_options']['webhook'] ?? null; if (null !== $baseUri) { $requestContextDefinition = new Definition(RequestContext::class); @@ -60,32 +69,28 @@ public function load(array $configs, ContainerBuilder $container): void $container->setDefinition('.sensiolabs_gotenberg.request_context', $requestContextDefinition); } - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.html'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['html'])]); + foreach ($config['webhook'] as $name => $configuration) { + $container->getDefinition('.sensiolabs_gotenberg.webhook_configuration_registry') + ->addMethodCall('add', [$name, $configuration]); + } + + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.html', $container, $config['default_options']['pdf']['html'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.url'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['url'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.url', $container, $config['default_options']['pdf']['url'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.markdown'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['markdown'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.markdown', $container, $config['default_options']['pdf']['markdown'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.office'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['office'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.office', $container, $config['default_options']['pdf']['office'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.merge'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['merge'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.merge', $container, $config['default_options']['pdf']['merge'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.pdf_builder.convert'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['pdf']['convert'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.pdf_builder.convert', $container, $config['default_options']['pdf']['convert'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.screenshot_builder.html'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['screenshot']['html'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.screenshot_builder.html', $container, $config['default_options']['screenshot']['html'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.screenshot_builder.url'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['screenshot']['url'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.screenshot_builder.url', $container, $config['default_options']['screenshot']['url'], $defaultWebhookConfig); - $definition = $container->getDefinition('.sensiolabs_gotenberg.screenshot_builder.markdown'); - $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config['default_options']['screenshot']['markdown'])]); + $this->processDefaultOptions('.sensiolabs_gotenberg.screenshot_builder.markdown', $container, $config['default_options']['screenshot']['markdown'], $defaultWebhookConfig); $definition = $container->getDefinition('sensiolabs_gotenberg.asset.base_dir_formatter'); $definition->replaceArgument(2, $config['assets_directory']); @@ -104,4 +109,33 @@ private function cleanUserOptions(array $userConfigurations): array return null !== $config; }); } + + /** + * @param array $config + */ + private function processDefaultOptions(string $serviceId, ContainerBuilder $container, array $config, string|null $defaultWebhookName): void + { + $definition = $container->getDefinition($serviceId); + $definition->addMethodCall('setConfigurations', [$this->cleanUserOptions($config)]); + + $webhookConfig = $config['webhook'] ?? null; + if (null === $webhookConfig && null === $defaultWebhookName) { + return; + } + + if (null === $webhookConfig) { + $definition->addMethodCall('webhookConfiguration', [$defaultWebhookName], true); + + return; + } + + if (\array_key_exists('config_name', $webhookConfig) && \is_string($webhookConfig['config_name'])) { + $name = $webhookConfig['config_name']; + } else { + $name = $serviceId.'_webhook_config'; + $container->getDefinition('.sensiolabs_gotenberg.webhook_configuration_registry') + ->addMethodCall('add', [$name, $webhookConfig]); + } + $definition->addMethodCall('webhookConfiguration', [$name], true); + } } diff --git a/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistry.php b/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistry.php new file mode 100644 index 00000000..40685d4e --- /dev/null +++ b/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistry.php @@ -0,0 +1,77 @@ +}} + */ +final class WebhookConfigurationRegistry implements WebhookConfigurationRegistryInterface +{ + /** + * @var array + */ + private array $configurations = []; + + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly RequestContext|null $requestContext, + ) { + } + + /** + * @param array{success: WebhookDefinition, error?: WebhookDefinition} $configuration + */ + public function add(string $name, array $configuration): void + { + $requestContext = $this->urlGenerator->getContext(); + if (null !== $this->requestContext) { + $this->urlGenerator->setContext($this->requestContext); + } + + try { + $success = $this->processWebhookConfiguration($configuration['success']); + $error = $success; + if (isset($configuration['error'])) { + $error = $this->processWebhookConfiguration($configuration['error']); + } + $this->configurations[$name] = ['success' => $success, 'error' => $error]; + } finally { + $this->urlGenerator->setContext($requestContext); + } + } + + /** + * @return array{success: string, error: string} + */ + public function get(string $name): array + { + if (!\array_key_exists($name, $this->configurations)) { + throw new WebhookConfigurationException(sprintf('Webhook configuration "%s" not found.', $name)); + } + + return $this->configurations[$name]; + } + + /** + * @param WebhookDefinition $webhookDefinition + * + * @throws \InvalidArgumentException + */ + private function processWebhookConfiguration(array $webhookDefinition): string + { + if (isset($webhookDefinition['url'])) { + return $webhookDefinition['url']; + } + if (isset($webhookDefinition['route'])) { + return $this->urlGenerator->generate($webhookDefinition['route'][0], $webhookDefinition['route'][1], UrlGeneratorInterface::ABSOLUTE_URL); + } + + throw new WebhookConfigurationException('Invalid webhook configuration'); + } +} diff --git a/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistryInterface.php b/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistryInterface.php new file mode 100644 index 00000000..de030c64 --- /dev/null +++ b/src/DependencyInjection/WebhookConfiguration/WebhookConfigurationRegistryInterface.php @@ -0,0 +1,19 @@ +}} + */ +interface WebhookConfigurationRegistryInterface +{ + /** + * @param array{success: WebhookDefinition, error?: WebhookDefinition} $configuration + */ + public function add(string $name, array $configuration): void; + + /** + * @return array{success: string, error: string} + */ + public function get(string $name): array; +} diff --git a/src/Exception/WebhookConfigurationException.php b/src/Exception/WebhookConfigurationException.php new file mode 100644 index 00000000..67875280 --- /dev/null +++ b/src/Exception/WebhookConfigurationException.php @@ -0,0 +1,7 @@ +>}}> + */ + public static function invalidWebhookConfigurationProvider(): \Generator + { + yield 'webhook definition without "success" and "error" keys' => [ + [['webhook' => ['foo' => ['some_key' => ['url' => 'http://example.com']]]]], + ]; + yield 'webhook definition without "success" key' => [ + [['webhook' => ['foo' => ['error' => ['url' => 'http://example.com']]]]], + ]; + yield 'webhook definition without name' => [ + [['webhook' => [['success' => ['url' => 'http://example.com']], ['error' => ['url' => 'http://example.com/error']]]]], + ]; + yield 'webhook definition with invalid "url" key' => [ + [['webhook' => ['foo' => ['success' => ['url' => ['array_element']]]]]], + ]; + yield 'webhook definition with array of string as "route" key' => [ + [['webhook' => ['foo' => ['success' => ['route' => ['array_element']]]]]], + ]; + yield 'webhook definition with array of two strings as "route" key' => [ + [['webhook' => ['foo' => ['success' => ['route' => ['array_element', 'array_element_2']]]]]], + ]; + yield 'webhook definition in default webhook declaration' => [ + [['default_options' => ['webhook' => ['success' => ['url' => 'http://example.com']]]]], + ]; + } + + /** + * @param array> $config + * + * @dataProvider invalidWebhookConfigurationProvider + */ + #[DataProvider('invalidWebhookConfigurationProvider')] + public function testInvalidWebhookConfiguration(array $config): void + { + $this->expectException(InvalidConfigurationException::class); + $processor = new Processor(); + $processor->processConfiguration( + new Configuration(), + $config, + ); + } + /** * @return array{ + * 'assets_directory': string, + * 'http_client': string, + * 'webhook': array, * 'default_options': array{ * 'pdf': array{ * 'html': array, @@ -101,6 +148,7 @@ private static function getBundleDefaultConfig(): array return [ 'assets_directory' => '%kernel.project_dir%/assets', 'http_client' => 'http_client', + 'webhook' => [], 'default_options' => [ 'pdf' => [ 'html' => [ diff --git a/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php b/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php index 123a7026..98d4e0ca 100644 --- a/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php +++ b/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php @@ -29,7 +29,8 @@ public function testGotenbergConfiguredWithValidConfig(): void $extension = new SensiolabsGotenbergExtension(); $containerBuilder = $this->getContainerBuilder(); - $extension->load(self::getValidConfig(), $containerBuilder); + $validConfig = self::getValidConfig(); + $extension->load($validConfig, $containerBuilder); $list = [ 'pdf' => [ @@ -64,6 +65,7 @@ public function testGotenbergConfiguredWithValidConfig(): void 'skip_network_idle_event' => true, 'pdf_format' => 'PDF/A-1b', 'pdf_universal_access' => true, + 'webhook' => ['config_name' => 'bar'], ], 'url' => [ 'paper_width' => 21, @@ -255,7 +257,7 @@ public function testUrlBuildersCanChangeTheirRequestContext(string $serviceName) self::assertSame('https://sensiolabs.com', $requestContextDefinition->getArgument(0)); $urlBuilderDefinition = $containerBuilder->getDefinition($serviceName); - self::assertCount(3, $urlBuilderDefinition->getMethodCalls()); + self::assertCount(4, $urlBuilderDefinition->getMethodCalls()); $indexedMethodCalls = []; foreach ($urlBuilderDefinition->getMethodCalls() as $methodCall) { @@ -387,9 +389,102 @@ public function testDataCollectorIsProperlyConfiguredIfEnabled(): void ], $dataCollectorOptions); } + public function testBuilderWebhookConfiguredWithDefaultConfiguration(): void + { + $extension = new SensiolabsGotenbergExtension(); + + $containerBuilder = $this->getContainerBuilder(); + $extension->load([['http_client' => 'http_client']], $containerBuilder); + + self::assertEmpty($containerBuilder->getDefinition('.sensiolabs_gotenberg.webhook_configuration_registry')->getMethodCalls()); + + $buildersIds = [ + '.sensiolabs_gotenberg.pdf_builder.html', + '.sensiolabs_gotenberg.pdf_builder.url', + '.sensiolabs_gotenberg.pdf_builder.markdown', + '.sensiolabs_gotenberg.pdf_builder.office', + '.sensiolabs_gotenberg.screenshot_builder.html', + '.sensiolabs_gotenberg.screenshot_builder.url', + '.sensiolabs_gotenberg.screenshot_builder.markdown', + ]; + + foreach ($buildersIds as $builderId) { + $builderDefinition = $containerBuilder->getDefinition($builderId); + $methodCalls = $builderDefinition->getMethodCalls(); + self::assertNotContains('webhookConfiguration', $methodCalls); + } + } + + public function testBuilderWebhookConfiguredWithValidConfiguration(): void + { + $extension = new SensiolabsGotenbergExtension(); + + $containerBuilder = $this->getContainerBuilder(); + $extension->load([[ + 'http_client' => 'http_client', + 'webhook' => [ + 'foo' => ['success' => ['url' => 'https://sensiolabs.com/webhook'], 'error' => ['route' => 'simple_route']], + 'baz' => ['success' => ['route' => ['array_route', ['param1', 'param2']]]], + ], + 'default_options' => [ + 'webhook' => 'foo', + 'pdf' => [ + 'html' => ['webhook' => 'bar'], + 'url' => ['webhook' => 'baz'], + 'markdown' => ['webhook' => ['success' => ['url' => 'https://sensiolabs.com/webhook-on-the-fly']]], + ], + 'screenshot' => [ + 'html' => ['webhook' => 'foo'], + 'url' => ['webhook' => 'bar'], + 'markdown' => ['webhook' => 'baz'], + ], + ], + ]], $containerBuilder); + + $expectedConfigurationMapping = [ + '.sensiolabs_gotenberg.pdf_builder.html' => 'bar', + '.sensiolabs_gotenberg.pdf_builder.url' => 'baz', + '.sensiolabs_gotenberg.pdf_builder.markdown' => '.sensiolabs_gotenberg.pdf_builder.markdown_webhook_config', + '.sensiolabs_gotenberg.pdf_builder.office' => 'foo', + '.sensiolabs_gotenberg.screenshot_builder.html' => 'foo', + '.sensiolabs_gotenberg.screenshot_builder.url' => 'bar', + '.sensiolabs_gotenberg.screenshot_builder.markdown' => 'baz', + ]; + array_map(static function (string $builderId, string $expectedConfigurationName) use ($containerBuilder): void { + foreach ($containerBuilder->getDefinition($builderId)->getMethodCalls() as $methodCall) { + [$name, $arguments] = $methodCall; + if ('webhookConfiguration' === $name) { + self::assertSame($expectedConfigurationName, $arguments[0]); + + return; + } + } + }, array_keys($expectedConfigurationMapping), array_values($expectedConfigurationMapping)); + + $webhookConfigurationRegistryDefinition = $containerBuilder->getDefinition('.sensiolabs_gotenberg.webhook_configuration_registry'); + $methodCalls = $webhookConfigurationRegistryDefinition->getMethodCalls(); + self::assertCount(3, $methodCalls); + foreach ($methodCalls as $methodCall) { + [$name, $arguments] = $methodCall; + self::assertSame('add', $name); + self::assertContains($arguments[0], ['foo', 'baz', '.sensiolabs_gotenberg.pdf_builder.markdown_webhook_config']); + self::assertSame(match ($arguments[0]) { + 'foo' => ['success' => ['url' => 'https://sensiolabs.com/webhook'], 'error' => ['route' => ['simple_route', []]]], + 'baz' => ['success' => ['route' => ['array_route', ['param1', 'param2']]]], + '.sensiolabs_gotenberg.pdf_builder.markdown_webhook_config' => ['success' => ['url' => 'https://sensiolabs.com/webhook-on-the-fly']], + default => self::fail('Unexpected webhook configuration'), + }, $arguments[1]); + } + } + /** * @return array}, 'webhook'?: string}, + * 'error'?: array{'url'?: string, 'route'?: string|array{0: string, 1: list}, 'webhook'?: string} + * }>, * 'default_options': array{ + * 'webhook': string, * 'pdf': array{ * 'html': array, * 'url': array, @@ -411,7 +506,12 @@ private static function getValidConfig(): array return [ [ 'http_client' => 'http_client', + 'webhook' => [ + 'foo' => ['success' => ['url' => 'https://sensiolabs.com/webhook'], 'error' => ['route' => 'simple_route']], + 'baz' => ['success' => ['url' => 'https://sensiolabs.com/single-url-webhook']], + ], 'default_options' => [ + 'webhook' => 'foo', 'pdf' => [ 'html' => [ 'paper_width' => 33.1, @@ -443,6 +543,7 @@ private static function getValidConfig(): array 'skip_network_idle_event' => true, 'pdf_format' => PdfFormat::Pdf1b->value, 'pdf_universal_access' => true, + 'webhook' => 'bar', ], 'url' => [ 'paper_width' => 21, @@ -466,6 +567,7 @@ private static function getValidConfig(): array 'skip_network_idle_event' => false, 'pdf_format' => PdfFormat::Pdf2b->value, 'pdf_universal_access' => false, + // 'webhook' => ['success' => ''] ], 'markdown' => [ 'paper_width' => 30, diff --git a/tests/DependencyInjection/WebhookConfigurationRegistryTest.php b/tests/DependencyInjection/WebhookConfigurationRegistryTest.php new file mode 100644 index 00000000..31edb976 --- /dev/null +++ b/tests/DependencyInjection/WebhookConfigurationRegistryTest.php @@ -0,0 +1,114 @@ +}} + */ +#[CoversClass(WebhookConfigurationRegistry::class)] +final class WebhookConfigurationRegistryTest extends TestCase +{ + public function testGetUndefinedConfiguration(): void + { + $this->expectException(WebhookConfigurationException::class); + $this->expectExceptionMessage('Webhook configuration "undefined" not found.'); + + $registry = new WebhookConfigurationRegistry($this->createMock(UrlGeneratorInterface::class), null); + $registry->get('undefined'); + } + + public function testAddConfigurationUsingCustomContext(): void + { + $requestContext = $this->createMock(RequestContext::class); + $urlGenerator = $this->getUrlGenerator($requestContext); + $registry = new WebhookConfigurationRegistry($urlGenerator, $requestContext); + $registry->add('test', ['success' => ['url' => 'http://example.com/success']]); + } + + public function testOverrideConfiguration(): void + { + $registry = new WebhookConfigurationRegistry($this->createMock(UrlGeneratorInterface::class), null); + $registry->add('test', ['success' => ['url' => 'http://example.com/success']]); + $this->assertSame(['success' => 'http://example.com/success', 'error' => 'http://example.com/success'], $registry->get('test')); + $registry->add('test', ['success' => ['url' => 'http://example.com/override']]); + $this->assertSame(['success' => 'http://example.com/override', 'error' => 'http://example.com/override'], $registry->get('test')); + } + + /** + * @return \Generator + */ + public static function configurationProvider(): \Generator + { + yield 'full definition with urls' => [ + ['success' => ['url' => 'http://example.com/success'], 'error' => ['url' => 'http://example.com/error']], + ['success' => 'http://example.com/success', 'error' => 'http://example.com/error'], + ]; + yield 'full definition with routes' => [ + ['success' => ['route' => ['test_route_success', ['param' => 'value']]], 'error' => ['route' => ['test_route_error', ['param' => 'value']]]], + ['success' => 'http://localhost/test_route?param=value', 'error' => 'http://localhost/test_route?param=value'], + ]; + yield 'partial definition with urls' => [ + ['success' => ['url' => 'http://example.com/success']], + ['success' => 'http://example.com/success', 'error' => 'http://example.com/success'], + ]; + yield 'partial definition with routes' => [ + ['success' => ['route' => ['test_route_success', ['param' => 'value']]], + 'error' => ['route' => ['test_route_error', ['param' => 'value']]], + ], + ['success' => 'http://localhost/test_route?param=value', 'error' => 'http://localhost/test_route?param=value'], + ]; + yield 'mixed definition with url and route' => [ + ['success' => ['url' => 'http://example.com/success'], 'error' => ['route' => ['test_route_error', ['param' => 'value']]], + ], + ['success' => 'http://example.com/success', 'error' => 'http://localhost/test_route?param=value'], + ]; + } + + /** + * @param array{success: WebhookDefinition, error?: WebhookDefinition} $configuration + * @param array{success: string, error: string} $expectedUrls + * + * @throws Exception + */ + #[DataProvider('configurationProvider')] + public function testAddConfiguration(array $configuration, array $expectedUrls): void + { + $registry = new WebhookConfigurationRegistry($this->getUrlGenerator(), null); + $registry->add('test', $configuration); + + $this->assertSame($expectedUrls, $registry->get('test')); + } + + private function getUrlGenerator(RequestContext|null $requestContext = null): UrlGeneratorInterface&MockObject + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $originalContext = $this->createMock(RequestContext::class); + $urlGenerator->expects(self::once())->method('getContext')->willReturn($originalContext); + $urlGenerator->expects(self::exactly(null !== $requestContext ? 2 : 1)) + ->method('setContext') + ->willReturnCallback(function (RequestContext $context) use ($originalContext, $requestContext): void { + match ($context) { + $requestContext, $originalContext => null, + default => self::fail('setContext was called with an unexpected context.'), + }; + }); + $urlGenerator->method('generate')->willReturnMap([ + ['test_route_success', ['param' => 'value'], UrlGeneratorInterface::ABSOLUTE_URL, 'http://localhost/test_route?param=value'], + ['test_route_error', ['param' => 'value'], UrlGeneratorInterface::ABSOLUTE_URL, 'http://localhost/test_route?param=value'], + ['_webhook_controller', ['type' => 'my_success_webhook'], UrlGeneratorInterface::ABSOLUTE_URL, 'http://localhost/webhook/success'], + ['_webhook_controller', ['type' => 'my_error_webhook'], UrlGeneratorInterface::ABSOLUTE_URL, 'http://localhost/webhook/error'], + ]); + + return $urlGenerator; + } +}