diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index ecd43ca0..eb1a5cd9 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -13,9 +13,15 @@ ->addPathToScan(__DIR__.'/src/Debug', isDev: true) ->addPathToScan(__DIR__.'/tests', isDev: true) + ->ignoreErrorsOnPackage('symfony/remote-event', [ + ErrorType::DEV_DEPENDENCY_IN_PROD, + ]) ->ignoreErrorsOnPackage('symfony/routing', [ ErrorType::DEV_DEPENDENCY_IN_PROD, ]) + ->ignoreErrorsOnPackage('symfony/webhook', [ + ErrorType::DEV_DEPENDENCY_IN_PROD, + ]) ->ignoreErrorsOnPackage('twig/twig', [ ErrorType::DEV_DEPENDENCY_IN_PROD, ]) diff --git a/composer.json b/composer.json index edf4080f..98626b60 100644 --- a/composer.json +++ b/composer.json @@ -42,10 +42,12 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/http-client": "^6.4 || ^7.0", "symfony/monolog-bundle": "^3.10", + "symfony/remote-event": "^6.4 || ^7.0", "symfony/routing": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", "symfony/twig-bundle": "^6.4 || ^7.0", "symfony/var-dumper": "^6.4 || ^7.0", + "symfony/webhook": "^6.4 || ^7.0", "twig/twig": "^3.14" }, "config": { @@ -56,7 +58,9 @@ }, "suggest": { "symfony/monolog-bundle": "Enables logging througout the generating process.", - "symfony/twig-bundle": "Allows you to use Twig to render templates into PDF", + "symfony/remote-event": "Allows you to use Webhook to handle asynchronous generation.", + "symfony/twig-bundle": "Allows you to use Twig to render templates into PDF.", + "symfony/webhook": "Allows you to use Webhook to handle asynchronous generation.", "monolog/monolog": "Enables logging througout the generating process." } } diff --git a/config/builder_pdf.php b/config/builder_pdf.php index 349c960d..fac016ab 100644 --- a/config/builder_pdf.php +++ b/config/builder_pdf.php @@ -23,6 +23,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), + service('router')->nullOnInvalid(), service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) @@ -36,8 +37,8 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), - service('twig')->nullOnInvalid(), service('router')->nullOnInvalid(), + service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) ->call('setRequestContext', [service('.sensiolabs_gotenberg.request_context')->nullOnInvalid()]) @@ -51,6 +52,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), + service('router')->nullOnInvalid(), service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) diff --git a/config/builder_screenshot.php b/config/builder_screenshot.php index 7ecb4cb2..587878ab 100644 --- a/config/builder_screenshot.php +++ b/config/builder_screenshot.php @@ -20,6 +20,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), + service('router')->nullOnInvalid(), service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) @@ -33,8 +34,8 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), - service('twig')->nullOnInvalid(), service('router')->nullOnInvalid(), + service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) ->call('setRequestContext', [service('.sensiolabs_gotenberg.request_context')->nullOnInvalid()]) @@ -48,6 +49,7 @@ service('sensiolabs_gotenberg.asset.base_dir_formatter'), service('.sensiolabs_gotenberg.webhook_configuration_registry'), service('request_stack'), + service('router')->nullOnInvalid(), service('twig')->nullOnInvalid(), ]) ->call('setLogger', [service('logger')->nullOnInvalid()]) diff --git a/docs/async/webhook.md b/docs/async/webhook.md new file mode 100644 index 00000000..995e38dc --- /dev/null +++ b/docs/async/webhook.md @@ -0,0 +1,48 @@ +## Using the Symfony Webhook component + +Symfony provides a specific [Webhook component](https://symfony.com/doc/current/webhook.html) dedicated to this task. + +Its role is to parse requests related to known webhooks and dispatch a corresponding remote event. Then, this event can +be handled by your application through the [Messenger component](https://symfony.com/doc/current/messenger.html). + +The GotenbergBundle offers a native integration of this component if installed. + +### Usage + +To connect the provider to your application, you need to configure the Webhook component routing: + +```yaml +# config/packages/webhook.yaml +framework: + webhook: + routing: + gotenberg: + service: 'sensiolabs_gotenberg.webhook.request_parser' +``` + +Then, create your handler to respond to the Gotenberg RemoteEvent: + +```php +use Sensiolabs\GotenbergBundle\RemoteEvent\ErrorGotenbergEvent; +use Sensiolabs\GotenbergBundle\RemoteEvent\SuccessGotenbergEvent; +use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface; +use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; +use Symfony\Component\RemoteEvent\RemoteEvent; + +#[AsRemoteEventConsumer('gotenberg')] +class WebhookListener implements ConsumerInterface +{ + public function consume(RemoteEvent $event): void + { + if ($event instanceof SuccessGotenbergEvent) { + // Handle the event + // PDF content is available as a resource through the getFile() method + } elseif ($event instanceof ErrorGotenbergEvent) { + // Handle the error + } + } +} +``` + +> [!WARNING] +> The webhook component **won't be used** if a [native webhook configuration](native.md) is set. diff --git a/docs/webhook.md b/docs/webhook.md index 75a349ac..8f4f22f6 100644 --- a/docs/webhook.md +++ b/docs/webhook.md @@ -1,3 +1,4 @@ # Going async * [Native](./async/native.md) +* [Using the Symfony Webhook component](./async/webhook.md) diff --git a/src/Builder/AsyncBuilderTrait.php b/src/Builder/AsyncBuilderTrait.php index b71646ef..ab93b8a0 100644 --- a/src/Builder/AsyncBuilderTrait.php +++ b/src/Builder/AsyncBuilderTrait.php @@ -2,8 +2,9 @@ namespace Sensiolabs\GotenbergBundle\Builder; -use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; +use Sensiolabs\GotenbergBundle\Exception\WebhookConfigurationException; use Sensiolabs\GotenbergBundle\Webhook\WebhookConfigurationRegistryInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; trait AsyncBuilderTrait { @@ -30,16 +31,22 @@ trait AsyncBuilderTrait private WebhookConfigurationRegistryInterface $webhookConfigurationRegistry; + protected UrlGeneratorInterface|null $urlGenerator; + public function generateAsync(): void { - if (null === $this->successWebhookUrl) { - throw new MissingRequiredFieldException('->webhookUrl() was never called.'); - } + $successWebhookUrl = $this->successWebhookUrl; + if (!$successWebhookUrl) { + if (!$this->urlGenerator) { + throw new WebhookConfigurationException(\sprintf('A webhook URL or Router is required to use "%s" method. Set the URL or try to run "composer require symfony/routing".', __METHOD__)); + } - $errorWebhookUrl = $this->errorWebhookUrl ?? $this->successWebhookUrl; + $successWebhookUrl = $this->urlGenerator->generate('_webhook_controller', ['type' => 'gotenberg'], UrlGeneratorInterface::ABSOLUTE_URL); + } + $errorWebhookUrl = $this->errorWebhookUrl ?? $successWebhookUrl; $headers = [ - 'Gotenberg-Webhook-Url' => $this->successWebhookUrl, + 'Gotenberg-Webhook-Url' => $successWebhookUrl, 'Gotenberg-Webhook-Error-Url' => $errorWebhookUrl, ]; @@ -59,6 +66,7 @@ public function generateAsync(): void // Gotenberg will add the extension to the file name (e.g. filename : "file.pdf" => generated file : "file.pdf.pdf"). $headers['Gotenberg-Output-Filename'] = $this->fileName; } + $this->client->call($this->getEndpoint(), $this->getMultipartFormData(), $headers); } diff --git a/src/Builder/Pdf/AbstractChromiumPdfBuilder.php b/src/Builder/Pdf/AbstractChromiumPdfBuilder.php index c734cccf..1bf4890b 100644 --- a/src/Builder/Pdf/AbstractChromiumPdfBuilder.php +++ b/src/Builder/Pdf/AbstractChromiumPdfBuilder.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\File as DataPartFile; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; abstract class AbstractChromiumPdfBuilder extends AbstractPdfBuilder @@ -30,9 +31,10 @@ public function __construct( AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, private readonly RequestStack $requestStack, + UrlGeneratorInterface|null $urlGenerator = null, private readonly Environment|null $twig = null, ) { - parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $urlGenerator); $normalizers = [ 'extraHttpHeaders' => function (mixed $value): array { diff --git a/src/Builder/Pdf/AbstractPdfBuilder.php b/src/Builder/Pdf/AbstractPdfBuilder.php index 7215dfe8..9dd99ab5 100644 --- a/src/Builder/Pdf/AbstractPdfBuilder.php +++ b/src/Builder/Pdf/AbstractPdfBuilder.php @@ -9,6 +9,7 @@ use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Sensiolabs\GotenbergBundle\Webhook\WebhookConfigurationRegistryInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; abstract class AbstractPdfBuilder implements PdfBuilderInterface, AsyncBuilderInterface { @@ -20,10 +21,12 @@ public function __construct( GotenbergClientInterface $gotenbergClient, AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, + UrlGeneratorInterface|null $urlGenerator = null, ) { $this->client = $gotenbergClient; $this->asset = $asset; $this->webhookConfigurationRegistry = $webhookConfigurationRegistry; + $this->urlGenerator = $urlGenerator; $this->normalizers = [ 'metadata' => function (mixed $value): array { diff --git a/src/Builder/Pdf/UrlPdfBuilder.php b/src/Builder/Pdf/UrlPdfBuilder.php index e72cc8e3..eba7a235 100644 --- a/src/Builder/Pdf/UrlPdfBuilder.php +++ b/src/Builder/Pdf/UrlPdfBuilder.php @@ -22,10 +22,10 @@ public function __construct( AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, RequestStack $requestStack, + UrlGeneratorInterface|null $urlGenerator = null, Environment|null $twig = null, - private readonly UrlGeneratorInterface|null $urlGenerator = null, ) { - parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $requestStack, $twig); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $requestStack, $urlGenerator, $twig); $this->addNormalizer('route', $this->generateUrlFromRoute(...)); } diff --git a/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php b/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php index 7ffb756a..0d0729c2 100644 --- a/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php +++ b/src/Builder/Screenshot/AbstractChromiumScreenshotBuilder.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\File as DataPartFile; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; abstract class AbstractChromiumScreenshotBuilder extends AbstractScreenshotBuilder @@ -27,9 +28,10 @@ public function __construct( AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, private readonly RequestStack $requestStack, + UrlGeneratorInterface|null $urlGenerator = null, private readonly Environment|null $twig = null, ) { - parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $urlGenerator); $normalizers = [ 'extraHttpHeaders' => function (mixed $value): array { diff --git a/src/Builder/Screenshot/AbstractScreenshotBuilder.php b/src/Builder/Screenshot/AbstractScreenshotBuilder.php index 0d68221a..ef2cbb30 100644 --- a/src/Builder/Screenshot/AbstractScreenshotBuilder.php +++ b/src/Builder/Screenshot/AbstractScreenshotBuilder.php @@ -9,6 +9,7 @@ use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Sensiolabs\GotenbergBundle\Webhook\WebhookConfigurationRegistryInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; abstract class AbstractScreenshotBuilder implements ScreenshotBuilderInterface, AsyncBuilderInterface { @@ -20,10 +21,12 @@ public function __construct( GotenbergClientInterface $gotenbergClient, AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, + UrlGeneratorInterface|null $urlGenerator = null, ) { $this->client = $gotenbergClient; $this->asset = $asset; $this->webhookConfigurationRegistry = $webhookConfigurationRegistry; + $this->urlGenerator = $urlGenerator; $this->normalizers = [ 'downloadFrom' => fn (array $value): array => $this->downloadFromNormalizer($value, $this->encodeData(...)), diff --git a/src/Builder/Screenshot/UrlScreenshotBuilder.php b/src/Builder/Screenshot/UrlScreenshotBuilder.php index 3442affa..21ec5a03 100644 --- a/src/Builder/Screenshot/UrlScreenshotBuilder.php +++ b/src/Builder/Screenshot/UrlScreenshotBuilder.php @@ -22,10 +22,10 @@ public function __construct( AssetBaseDirFormatter $asset, WebhookConfigurationRegistryInterface $webhookConfigurationRegistry, RequestStack $requestStack, + UrlGeneratorInterface|null $urlGenerator = null, Environment|null $twig = null, - private readonly UrlGeneratorInterface|null $urlGenerator = null, ) { - parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $requestStack, $twig); + parent::__construct($gotenbergClient, $asset, $webhookConfigurationRegistry, $requestStack, $urlGenerator, $twig); $this->addNormalizer('route', $this->generateUrlFromRoute(...)); } diff --git a/src/Client/GotenbergResponse.php b/src/Client/GotenbergResponse.php index 34cc2cb2..fd3c8972 100644 --- a/src/Client/GotenbergResponse.php +++ b/src/Client/GotenbergResponse.php @@ -2,6 +2,7 @@ namespace Sensiolabs\GotenbergBundle\Client; +use Sensiolabs\GotenbergBundle\Utils\HeaderUtils; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Contracts\HttpClient\ResponseStreamInterface; @@ -31,13 +32,7 @@ public function getHeaders(): ResponseHeaderBag public function getFileName(): string|null { - $disposition = $this->headers->get('content-disposition', ''); - /* @see https://onlinephp.io/c/c2606 */ - if (1 === preg_match('#[^;]*;\sfilename="?(?P[^"]*)"?#', $disposition, $matches)) { - return $matches['fileName']; - } - - return null; + return HeaderUtils::extractFilename($this->headers); } /** @@ -45,11 +40,6 @@ public function getFileName(): string|null */ public function getContentLength(): int|null { - $length = $this->headers->get('content-length'); - if (null !== $length) { - return abs((int) $length); - } - - return null; + return HeaderUtils::extractContentLength($this->headers); } } diff --git a/src/DependencyInjection/SensiolabsGotenbergExtension.php b/src/DependencyInjection/SensiolabsGotenbergExtension.php index b9c490fa..66faabe2 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\Webhook\GotenbergRequestParser; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -11,6 +12,7 @@ use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Webhook\Client\AbstractRequestParser; /** * @phpstan-type SensiolabsGotenbergConfiguration array{ @@ -120,6 +122,10 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition('sensiolabs_gotenberg.asset.base_dir_formatter'); $definition->replaceArgument(2, $config['assets_directory']); + + if ($container::willBeAvailable('symfony/webhook', AbstractRequestParser::class, ['symfony/framework-bundle'])) { + $container->register('sensiolabs_gotenberg.webhook.request_parser', GotenbergRequestParser::class); + } } /** diff --git a/src/RemoteEvent/ErrorGotenbergEvent.php b/src/RemoteEvent/ErrorGotenbergEvent.php new file mode 100644 index 00000000..d9e766cb --- /dev/null +++ b/src/RemoteEvent/ErrorGotenbergEvent.php @@ -0,0 +1,32 @@ +status; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/RemoteEvent/SuccessGotenbergEvent.php b/src/RemoteEvent/SuccessGotenbergEvent.php new file mode 100644 index 00000000..db53c411 --- /dev/null +++ b/src/RemoteEvent/SuccessGotenbergEvent.php @@ -0,0 +1,48 @@ +getPayload()['file']; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getContentLength(): int + { + return $this->contentLength; + } +} diff --git a/src/Utils/HeaderUtils.php b/src/Utils/HeaderUtils.php new file mode 100644 index 00000000..ec2c2a3b --- /dev/null +++ b/src/Utils/HeaderUtils.php @@ -0,0 +1,36 @@ +get('Content-Disposition'); + if (null === $contentDisposition) { + return null; + } + + /* @see https://onlinephp.io/c/c2606 */ + if (1 === preg_match('#[^;]*;\sfilename="?(?P[^"]*)"?#', $contentDisposition, $matches)) { + return $matches['fileName']; + } + + return null; + } + + /** + * @return non-negative-int|null + */ + public static function extractContentLength(HeaderBag $headers): int|null + { + $length = $headers->get('content-length'); + if (null === $length) { + return null; + } + + return abs((int) $length); + } +} diff --git a/src/Webhook/GotenbergRequestParser.php b/src/Webhook/GotenbergRequestParser.php new file mode 100644 index 00000000..3bca69c2 --- /dev/null +++ b/src/Webhook/GotenbergRequestParser.php @@ -0,0 +1,64 @@ +headers->get($this->idHeaderName))) { + throw new RejectWebhookException(406, \sprintf('Missing "%s" HTTP request header.', $this->idHeaderName)); + } + + if ($this->userAgent !== ($type = $request->headers->get('User-Agent', ''))) { + throw new RejectWebhookException(406, \sprintf('Invalid user agent "%s".', $type)); + } + + if ('json' === $request->getContentTypeFormat()) { + /** @var array{status: int, message: string} $payload */ + $payload = $request->toArray(); + + return new ErrorGotenbergEvent( + $id, + $payload, + $payload['status'], + $payload['message'], + ); + } + + return new SuccessGotenbergEvent( + $request->headers->get($this->idHeaderName) ?? '', + $request->getContent(true), + HeaderUtils::extractFilename($request->headers) ?? '', + $request->headers->get('Content-Type', ''), + HeaderUtils::extractContentLength($request->headers) ?? 0, + ); + } +} diff --git a/tests/Builder/AbstractBuilderTestCase.php b/tests/Builder/AbstractBuilderTestCase.php index da3e0d2c..6e6ad47e 100644 --- a/tests/Builder/AbstractBuilderTestCase.php +++ b/tests/Builder/AbstractBuilderTestCase.php @@ -10,6 +10,11 @@ use Sensiolabs\GotenbergBundle\Webhook\WebhookConfigurationRegistryInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -17,6 +22,8 @@ abstract class AbstractBuilderTestCase extends TestCase { protected const FIXTURE_DIR = __DIR__.'/../Fixtures'; + protected static UrlGeneratorInterface $urlGenerator; + protected static Environment $twig; protected static AssetBaseDirFormatter $assetBaseDirFormatter; @@ -33,6 +40,9 @@ abstract class AbstractBuilderTestCase extends TestCase public static function setUpBeforeClass(): void { + $routeCollection = new RouteCollection(); + $routeCollection->add('_webhook_controller', new Route('/webhook/{type}')); + self::$urlGenerator = new UrlGenerator($routeCollection, new RequestContext()); self::$twig = new Environment(new FilesystemLoader(self::FIXTURE_DIR), [ 'strict_variables' => true, ]); diff --git a/tests/Builder/AsyncBuilderTraitTest.php b/tests/Builder/AsyncBuilderTraitTest.php index 18801ba6..acd94bf5 100644 --- a/tests/Builder/AsyncBuilderTraitTest.php +++ b/tests/Builder/AsyncBuilderTraitTest.php @@ -10,12 +10,17 @@ use Sensiolabs\GotenbergBundle\Builder\DefaultBuilderTrait; use Sensiolabs\GotenbergBundle\Client\GotenbergClient; use Sensiolabs\GotenbergBundle\Client\GotenbergResponse; -use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; +use Sensiolabs\GotenbergBundle\Exception\WebhookConfigurationException; use Sensiolabs\GotenbergBundle\Formatter\AssetBaseDirFormatter; use Sensiolabs\GotenbergBundle\Webhook\WebhookConfigurationRegistryInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(AsyncBuilderTrait::class)] @@ -27,10 +32,10 @@ class AsyncBuilderTraitTest extends TestCase { public function testRequiresAtLeastSuccessWebhookUrl(): void { - $builder = $this->getBuilder(new MockHttpClient([])); + $builder = $this->getBuilder(new MockHttpClient([]), disableUrlGenerator: true); - $this->expectException(MissingRequiredFieldException::class); - $this->expectExceptionMessage('->webhookUrl() was never called.'); + $this->expectException(WebhookConfigurationException::class); + $this->expectExceptionMessage('A webhook URL or Router is required to use "Sensiolabs\GotenbergBundle\Builder\AsyncBuilderTrait::generateAsync" method. Set the URL or try to run "composer require symfony/routing".'); $builder->generateAsync(); } @@ -175,7 +180,19 @@ public function get(string $name): array $builder->generateAsync(); } - private function getBuilder(MockHttpClient $httpClient, WebhookConfigurationRegistryInterface|null $registry = null): AsyncBuilderInterface + public function testWebhookComponentCanBeUsed(): void + { + $builder = $this->getBuilder(new MockHttpClient(function ($method, $url, $options): MockResponse { + $this->assertContains('Gotenberg-Webhook-Url: http://localhost/webhook/gotenberg', $options['headers']); + $this->assertContains('Gotenberg-Webhook-Error-Url: http://localhost/webhook/gotenberg', $options['headers']); + + return new MockResponse(); + })); + + $builder->generateAsync(); + } + + private function getBuilder(MockHttpClient $httpClient, WebhookConfigurationRegistryInterface|null $registry = null, bool $disableUrlGenerator = false): AsyncBuilderInterface { $registry ??= new class implements WebhookConfigurationRegistryInterface { public function add(string $name, array $configuration): void @@ -198,14 +215,22 @@ public function get(string $name): array } }; - return new class($httpClient, $registry) implements AsyncBuilderInterface { + $urlGenerator = null; + if (!$disableUrlGenerator) { + $routeCollection = new RouteCollection(); + $routeCollection->add('_webhook_controller', new Route('/webhook/{type}')); + $urlGenerator = new UrlGenerator($routeCollection, new RequestContext()); + } + + return new class($httpClient, $registry, $urlGenerator) implements AsyncBuilderInterface { use AsyncBuilderTrait; - public function __construct(HttpClientInterface $httpClient, WebhookConfigurationRegistryInterface $registry) + public function __construct(HttpClientInterface $httpClient, WebhookConfigurationRegistryInterface $registry, UrlGeneratorInterface|null $urlGenerator) { $this->client = new GotenbergClient($httpClient); $this->webhookConfigurationRegistry = $registry; $this->asset = new AssetBaseDirFormatter(new Filesystem(), '', ''); + $this->urlGenerator = $urlGenerator; } protected function getEndpoint(): string diff --git a/tests/Builder/Pdf/AbstractChromiumPdfBuilderTest.php b/tests/Builder/Pdf/AbstractChromiumPdfBuilderTest.php index 6b2eaa1b..ae0118c3 100644 --- a/tests/Builder/Pdf/AbstractChromiumPdfBuilderTest.php +++ b/tests/Builder/Pdf/AbstractChromiumPdfBuilderTest.php @@ -447,7 +447,7 @@ public function testThrowIfTwigNotAvailable(): void $this->expectException(\LogicException::class); $this->expectExceptionMessage('Twig is required to use "Sensiolabs\GotenbergBundle\Builder\Pdf\AbstractChromiumPdfBuilder::withRenderedPart" method. Try to run "composer require symfony/twig-bundle".'); - $builder = $this->getChromiumPdfBuilder(false); + $builder = $this->getChromiumPdfBuilder(twig: false); $builder->header('header.html.twig', ['name' => 'World']); } @@ -460,9 +460,9 @@ public function testThrowIfTwigTemplateIsInvalid(): void $builder->header('templates/invalid.html.twig'); } - private function getChromiumPdfBuilder(bool $twig = true, RequestStack $requestStack = new RequestStack()): AbstractChromiumPdfBuilder + private function getChromiumPdfBuilder(bool $urlGenerator = true, bool $twig = true, RequestStack $requestStack = new RequestStack()): AbstractChromiumPdfBuilder { - return new class($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, $requestStack, true === $twig ? self::$twig : null) extends AbstractChromiumPdfBuilder { + return new class($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, $requestStack, $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null) extends AbstractChromiumPdfBuilder { protected function getEndpoint(): string { return '/fake/endpoint'; diff --git a/tests/Builder/Pdf/HtmlPdfBuilderTest.php b/tests/Builder/Pdf/HtmlPdfBuilderTest.php index 1366fd5b..4abdec4a 100644 --- a/tests/Builder/Pdf/HtmlPdfBuilderTest.php +++ b/tests/Builder/Pdf/HtmlPdfBuilderTest.php @@ -109,9 +109,9 @@ public function testRequiredFormData(): void $builder->getMultipartFormData(); } - private function getHtmlPdfBuilder(bool $twig = true): HtmlPdfBuilder + private function getHtmlPdfBuilder(bool $urlGenerator = true, bool $twig = true): HtmlPdfBuilder { - return (new HtmlPdfBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), true === $twig ? self::$twig : null)) + return (new HtmlPdfBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null)) ->processor(new NullProcessor()) ; } diff --git a/tests/Builder/Pdf/MarkdownPdfBuilderTest.php b/tests/Builder/Pdf/MarkdownPdfBuilderTest.php index 668fddef..09a0f7de 100644 --- a/tests/Builder/Pdf/MarkdownPdfBuilderTest.php +++ b/tests/Builder/Pdf/MarkdownPdfBuilderTest.php @@ -66,9 +66,9 @@ public function testRequiredMarkdownFile(): void $builder->getMultipartFormData(); } - private function getMarkdownPdfBuilder(bool $twig = true): MarkdownPdfBuilder + private function getMarkdownPdfBuilder(bool $urlGenerator = true, bool $twig = true): MarkdownPdfBuilder { - return (new MarkdownPdfBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), true === $twig ? self::$twig : null)) + return (new MarkdownPdfBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null)) ->processor(new NullProcessor()) ; } diff --git a/tests/Builder/Pdf/UrlPdfBuilderTest.php b/tests/Builder/Pdf/UrlPdfBuilderTest.php index b4fa54ca..264720a9 100644 --- a/tests/Builder/Pdf/UrlPdfBuilderTest.php +++ b/tests/Builder/Pdf/UrlPdfBuilderTest.php @@ -137,9 +137,8 @@ private function getUrlPdfBuilder(UrlGeneratorInterface|null $urlGenerator = nul self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), - null, - $urlGenerator) - ) + $urlGenerator, + )) ->processor(new NullProcessor()) ; } diff --git a/tests/Builder/Screenshot/AbstractChromiumScreenshotBuilderTest.php b/tests/Builder/Screenshot/AbstractChromiumScreenshotBuilderTest.php index ea46afa9..9ecd32a0 100644 --- a/tests/Builder/Screenshot/AbstractChromiumScreenshotBuilderTest.php +++ b/tests/Builder/Screenshot/AbstractChromiumScreenshotBuilderTest.php @@ -89,9 +89,9 @@ public function testConfigurationIsCorrectlySet(string $key, mixed $value, array self::assertEquals($expected, $builder->getMultipartFormData()[0]); } - private function getChromiumScreenshotBuilder(bool $twig = true): AbstractChromiumScreenshotBuilder + private function getChromiumScreenshotBuilder(bool $urlGenerator = true, bool $twig = true): AbstractChromiumScreenshotBuilder { - return new class($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), true === $twig ? self::$twig : null) extends AbstractChromiumScreenshotBuilder { + return new class($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null) extends AbstractChromiumScreenshotBuilder { protected function getEndpoint(): string { return '/fake/endpoint'; diff --git a/tests/Builder/Screenshot/HtmlScreenshotBuilderTest.php b/tests/Builder/Screenshot/HtmlScreenshotBuilderTest.php index 63f59909..0a2a0251 100644 --- a/tests/Builder/Screenshot/HtmlScreenshotBuilderTest.php +++ b/tests/Builder/Screenshot/HtmlScreenshotBuilderTest.php @@ -109,9 +109,9 @@ public function testRequiredFormData(): void $builder->getMultipartFormData(); } - private function getHtmlScreenshotBuilder(bool $twig = true): HtmlScreenshotBuilder + private function getHtmlScreenshotBuilder(bool $urlGenerator = true, bool $twig = true): HtmlScreenshotBuilder { - return (new HtmlScreenshotBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), true === $twig ? self::$twig : null)) + return (new HtmlScreenshotBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null)) ->processor(new NullProcessor()) ; } diff --git a/tests/Builder/Screenshot/MarkdownScreenshotBuilderTest.php b/tests/Builder/Screenshot/MarkdownScreenshotBuilderTest.php index 7d7edaa8..945fc166 100644 --- a/tests/Builder/Screenshot/MarkdownScreenshotBuilderTest.php +++ b/tests/Builder/Screenshot/MarkdownScreenshotBuilderTest.php @@ -94,9 +94,9 @@ public function testRequiredMarkdownFile(): void $builder->getMultipartFormData(); } - private function getMarkdownScreenshotBuilder(bool $twig = true): MarkdownScreenshotBuilder + private function getMarkdownScreenshotBuilder(bool $urlGenerator = true, bool $twig = true): MarkdownScreenshotBuilder { - return (new MarkdownScreenshotBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), true === $twig ? self::$twig : null)) + return (new MarkdownScreenshotBuilder($this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), $urlGenerator ? self::$urlGenerator : null, $twig ? self::$twig : null)) ->processor(new NullProcessor()) ; } diff --git a/tests/Builder/Screenshot/UrlScreenshotBuilderTest.php b/tests/Builder/Screenshot/UrlScreenshotBuilderTest.php index 0f70d257..bd241473 100644 --- a/tests/Builder/Screenshot/UrlScreenshotBuilderTest.php +++ b/tests/Builder/Screenshot/UrlScreenshotBuilderTest.php @@ -72,15 +72,15 @@ public function testRequiredFormData(): void $builder->getMultipartFormData(); } - private function getUrlScreenshotBuilder(bool $twig = true): UrlScreenshotBuilder + private function getUrlScreenshotBuilder(bool $urlGenerator = true, bool $twig = true): UrlScreenshotBuilder { return (new UrlScreenshotBuilder( $this->gotenbergClient, self::$assetBaseDirFormatter, $this->webhookConfigurationRegistry, new RequestStack(), - true === $twig ? self::$twig : null, - $this->router, + $urlGenerator ? self::$urlGenerator : null, + $twig ? self::$twig : null, )) ->processor(new NullProcessor()) ; diff --git a/tests/Utils/HeaderUtilsTest.php b/tests/Utils/HeaderUtilsTest.php new file mode 100644 index 00000000..5be64fb6 --- /dev/null +++ b/tests/Utils/HeaderUtilsTest.php @@ -0,0 +1,28 @@ + 'attachment; filename="foo.bar"'])), + ); + } + + public function testExtractContentLength(): void + { + self::assertSame( + 123, + HeaderUtils::extractContentLength(new HeaderBag(['Content-Length' => '123'])), + ); + } +} diff --git a/tests/Webhook/Fixtures/error.json b/tests/Webhook/Fixtures/error.json new file mode 100644 index 00000000..ab331755 --- /dev/null +++ b/tests/Webhook/Fixtures/error.json @@ -0,0 +1,4 @@ +{ + "status": 500, + "message": "An error occurred." +} \ No newline at end of file diff --git a/tests/Webhook/Fixtures/error.php b/tests/Webhook/Fixtures/error.php new file mode 100644 index 00000000..c23605cc --- /dev/null +++ b/tests/Webhook/Fixtures/error.php @@ -0,0 +1,14 @@ +getRealPath(), 'r') ?: throw new LogicException('Fixture not found.'), + $file->getFilename(), + 'application/pdf', + $file->getSize(), +); diff --git a/tests/Webhook/GotenbergRequestParserTest.php b/tests/Webhook/GotenbergRequestParserTest.php new file mode 100644 index 00000000..8b171241 --- /dev/null +++ b/tests/Webhook/GotenbergRequestParserTest.php @@ -0,0 +1,58 @@ + 'application/json', + 'HTTP_GOTENBERG_TRACE' => '52fce8b6-a594-4b90-82cf-347b58ab06ae', + 'HTTP_USER_AGENT' => 'Gotenberg', + ], $payload); + } + + public function testParseSuccess(): void + { + $pdf = new \SplFileInfo(__DIR__.'/Fixtures/success.pdf'); + $pdfContent = $pdf->openFile()->fread($pdf->getSize()) ?: ''; + + $parser = $this->createRequestParser(); + $request = Request::create('/', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/pdf', + 'HTTP_GOTENBERG_TRACE' => '52fce8b6-a594-4b90-82cf-347b58ab06ae', + 'HTTP_USER_AGENT' => 'Gotenberg', + 'HTTP_CONTENT_DISPOSITION' => 'attachment; filename='.$pdf->getFilename(), + 'HTTP_CONTENT_LENGTH' => $pdf->getSize(), + ], $pdfContent); + + $wh = $parser->parse($request, $this->getSecret()); + + /** @var SuccessGotenbergEvent $remoteEvent */ + $remoteEvent = include __DIR__.'/Fixtures/success.php'; + + $this->assertInstanceOf(SuccessGotenbergEvent::class, $wh); + $this->assertSame($remoteEvent->getId(), $wh->getId()); + $this->assertSame($remoteEvent->getName(), $wh->getName()); + $this->assertSame($remoteEvent->getFilename(), $wh->getFilename()); + $this->assertSame($remoteEvent->getContentType(), $wh->getContentType()); + $this->assertSame($remoteEvent->getContentLength(), $wh->getContentLength()); + $this->assertSame(array_keys($remoteEvent->getPayload()), array_keys($wh->getPayload())); + $this->assertIsResource($wh->getPayload()['file']); + $this->assertSame($pdfContent, fread($wh->getFile(), 64000)); + } +}