From 9b8c0c4056f6e4d0ee603060f2cd182dad1d06b4 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Sat, 16 Nov 2024 16:23:42 +0100 Subject: [PATCH] feat: add Webhook component integration --- composer-dependency-analyser.php | 6 ++ composer.json | 6 +- config/builder_pdf.php | 4 +- config/builder_screenshot.php | 4 +- docs/async/webhook.md | 48 +++++++++++++ docs/webhook.md | 1 + src/Builder/AsyncBuilderTrait.php | 20 ++++-- .../Pdf/AbstractChromiumPdfBuilder.php | 4 +- src/Builder/Pdf/AbstractPdfBuilder.php | 3 + src/Builder/Pdf/UrlPdfBuilder.php | 4 +- .../AbstractChromiumScreenshotBuilder.php | 4 +- .../Screenshot/AbstractScreenshotBuilder.php | 3 + .../Screenshot/UrlScreenshotBuilder.php | 4 +- src/Client/GotenbergResponse.php | 16 +---- .../SensiolabsGotenbergExtension.php | 6 ++ src/RemoteEvent/ErrorGotenbergEvent.php | 32 +++++++++ src/RemoteEvent/SuccessGotenbergEvent.php | 48 +++++++++++++ src/Utils/HeaderUtils.php | 36 ++++++++++ src/Webhook/GotenbergRequestParser.php | 63 ++++++++++++++++++ tests/Builder/AbstractBuilderTestCase.php | 10 +++ tests/Builder/AsyncBuilderTraitTest.php | 39 +++++++++-- .../Pdf/AbstractChromiumPdfBuilderTest.php | 6 +- tests/Builder/Pdf/HtmlPdfBuilderTest.php | 4 +- tests/Builder/Pdf/MarkdownPdfBuilderTest.php | 4 +- tests/Builder/Pdf/UrlPdfBuilderTest.php | 5 +- .../AbstractChromiumScreenshotBuilderTest.php | 4 +- .../Screenshot/HtmlScreenshotBuilderTest.php | 4 +- .../MarkdownScreenshotBuilderTest.php | 4 +- .../Screenshot/UrlScreenshotBuilderTest.php | 6 +- tests/Utils/HeaderUtilsTest.php | 28 ++++++++ tests/Webhook/Fixtures/error.json | 4 ++ tests/Webhook/Fixtures/error.php | 14 ++++ tests/Webhook/Fixtures/success.pdf | Bin 0 -> 32080 bytes tests/Webhook/Fixtures/success.php | 13 ++++ tests/Webhook/GotenbergRequestParserTest.php | 58 ++++++++++++++++ 35 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 docs/async/webhook.md create mode 100644 src/RemoteEvent/ErrorGotenbergEvent.php create mode 100644 src/RemoteEvent/SuccessGotenbergEvent.php create mode 100644 src/Utils/HeaderUtils.php create mode 100644 src/Webhook/GotenbergRequestParser.php create mode 100644 tests/Utils/HeaderUtilsTest.php create mode 100644 tests/Webhook/Fixtures/error.json create mode 100644 tests/Webhook/Fixtures/error.php create mode 100644 tests/Webhook/Fixtures/success.pdf create mode 100644 tests/Webhook/Fixtures/success.php create mode 100644 tests/Webhook/GotenbergRequestParserTest.php 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..60835c48 --- /dev/null +++ b/src/Webhook/GotenbergRequestParser.php @@ -0,0 +1,63 @@ +headers->has($this->idHeaderName)) { + throw new RejectWebhookException(406, \sprintf('Missing "%s" HTTP request header.', $this->idHeaderName)); + } + + if ('Gotenberg' !== ($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( + $request->headers->get($this->idHeaderName) ?? '', + $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 @@ +9h^ zokc91fLg#1C^s|9*kph^p9p)+S`d5 zx&ZAJ<$74H8r+3F+~K+nA(}USb(4$oOkYkoLvCp*&@29 z1;_VWwX>iHef@shi+NOU-O7|HnP{yeD^yVVy3hSwl-o)UhbIa)+^&9-Oy~pkW|>-lL-4ch&)4$Q9a4r3Mag8$3&QKjw&J_)c`%f- zyl6Fh+eY0=@@+6Sxa2Oa@DAGQlf-=bN!h?*|;FE28|x?x1xX`-`r@-6kZZ0?tW&1B#bKc_*rP1 z8~$gw>c-$;#@z2G6*6YR-xw%vv~g{A2ykPZa^co&!=a;p*|rC#V!QL_DU&CZh|M}N z$vhq_xsvlHC$@Zf5!E+Wd0s~BO|fiFZ~m9a?wvQQkhy~pa4ZYTo%bUv@pH%KQ>d1^ z_~WZ%BVR^H&^c1qeUsEe5p!9*rP;=$1g{5CvJj0BRMHx)hwZZP$73jB6p|17HrW~M zXOLvLEgSA5Wlc*KrwrCfbzy#>M#!RzUnDMHkznQBsj6r0V1Uv2w+b|{t+ z9+%GI01MBkJlDmgW1SwdT3`HtAXFUjw$!olIeW|C=G%iCi#rc}esvQF%b)oPl#2z{ zPlDs8&jL^M!yd!8dUOvLO(`VmK`z`wbc={5q=V8=;)I7!LydZ0Gf_ngT69>L50HOs zLXB*l4L)#`UD;n0HGifiASqXGz4<+57T@N{PtAFUYa2hci zQ(M4+&PI2;3klE+5aiMwr9s+fnb5hf$cg|>Rxk&h*8=#ah?FP@AQ-?Mfe*BHz>v>! z{s#h~gQ@mR(Z5xza9-VcL;x^A+UM<5`c)|ltkPdP`(LqKdeZN(oZIT(uyDdw3o|}n znB-rpz0`e%>7p}!F%$=kawu%(d2a82Qw}}j1E4$p6DA;0_|=r$u+_qhC4YtKyuH8a zCoWj!Flpp}Rt~*%tp9`w`WsA8Sf4vr^lv`*PgnohPtbEW{Ka5USl@&GRUAS8C5~Wy zkM(z$SYb{1pW^8M#B@HK{E7(*tNg4fe{3-5<)HJQ`ibp#nAl;fg}H$E&+Y+%>8JCL zzhYv8QGQb?=41UOC;B&XVm;3~{xc?y-(X^ejkR+{f82+#n9ix>mwp1S zmVb^+tbY|ztUpJnUj{_jKn5;#f2o!gHrCD+{a2XIspMDXu*?U{GWwv;C@dUjz3H{pufwZIAG)LT+<(0ljA2do;UbcYjVKSAID#% z8ytU_Za{yRLvg^y+qtIy3e@k@4GviPPUiX9f!xvD=#1^Jn7T+Fe7E@o0d z7kMz~BG&;{pMF301slzre?^U)|3ZzIW6=M($iN93=jV$44e$S6WH@i_!a`uuk`5cK%`^1$5E5pbNymbRNq;!2HM+ z|7R)W$Ku@&ioRS=09{PMgDz&RLFW_GN+9Stg@7(*^+4wncS@l1xh~-CoZ&#{Gb&1; zi%V8auBtHQBphfK;_W$w4FFbv*$fx3D3M{C#{;v- z#R-$W$ynN20xP82Fe_7lonaTM03QOnLe9_@SkwLA#V&#Zwlsu=?s8e|A5sV)y=V_i zMG#g%dSPr>DLWv&=pI-p2OzyLDXf$ekX~33R?0>9V`3Us$_+@*qZ77ez^cWM*=v}x zz(UCnhk=y>hPj;LhLr*+F1-p?$_7Y(!3G7eUAi7@SpeH5nZQZ`Y?l*Ruu=fqrPsnr z0c@AET`*E$miUKj!%6{cms4V}QUKef|HDcFY?t$9uu=fqWsty10c;nOYq0iZ1+ZO4 z5^Pxj+a+(pN&#$_7sjws0NZ5{!bpKxpdVMyuu=fqWu(GN0c@9-*sxLn+hsVzN&#$_ z*Ws{I0NZ8E!%6{c7Z>ZWh=JLMAH)D#7Ql8nM8HY`Y?p~Pj1;)y{=q4*QUKd!HV!KV zuw4=ltQ5d@nXwqd2fz<4=!{X+_%xMcF5%PO2_q;)xLUfh*%PRjqd72*Gw;yI&x zC3|~dK$QmlNJ@SV1aiPVHqxdZXLmdN6oNR<`SfgM{p@Vu_L#Gg_h)Okez`~#F?2Ds zu{S?cVh-^B#T_!h9RP+-)>3w6_GfqFT&zX@kX(FcY61f8u`vZSU*2rJ# zWdK$G)VO!jycp}X8dsaRSDL>vx|LSMlYjN90oDEMQJHBE?(KyOyt@&1>wYgY1EDte z8_uuPAZJ8_X|C;+DW00>=oC`}k2A6v0)4-xQ{L?at=WI9=)26tqjz zp4PZYvvUQqsq~3ihi55Wt~c|3q`=KI{r*(f-RJi2RUiam-8Xj{JgThC*9E4zi9ifj zZof(8Y~=8~zwC8i=PJnb{a9o<@gv&Mt7-^wuaI#2OI*`7j)2kn;Bb&I`7CmGVk=wM4{K@ygB;#w`XAAqZ{@ZVPmO-^+VE zihESf>qq@(#39+|~!nY6~FS^dOcY{ik)=)MH)x1zF>|>JO z#I3Mg!&;u(ejD!hVnLQa$*jI+t;Nx&{8;Z^*X-)OnRc*oyI+%xG@F)2<^0p42TwAVHx*yo zbtWTC4w35|Ns~N0U8-p17LT;_9=+>%Wrk~*kXo`W<2{Sm?V8;Xd;*qgPR8BJ_&HH0 zdyPPoE6OaS>c$)d5Y(zoLYX#looFqcK!oI|%H)7v7D5z@4sXUbc7?c58)PAp*Ae^V zjIWhO4LL%-wJLcIJ-p-2$4BTq6ck*5z1xWXEWjn|m3N;g98MNSB+Jc6$gKX>>G$Eh zopCEvD+pFq)+`-ua)1ZO(-o76zlS8oBU~oF`VMZ2z#&!sWifZ=jE1+nLim;cCkxU!`b&!B}Z#!DHQA{ zLm;yhUa(P-ThK4lqmWho+M^zQSmM5 zGm7vhW^E^S6kW&Y>rv_Pxx2p$&BP=dp;kM5xUp{9Oi%TWuFY7?J@8g)ueYk6y;@%O zL*AuFbcRYf4?{!2DYbb`d5jDX)>sJc`aG>d(M*{zWJx@{8?Bjgi`UzgH|!%O`L$8S zJ1#ea-=sa#B#GI3^e9xf8LAh^vYqty4WfLqhNj-9Z=S`^pzXOXdG_LX&4z~3g2_)* zZexu<40)c7XL_fFute3(q^498x6(*l_9a^{XYsd)9+u+LdDDq&hN0z*YjtJq2hwZ| ztTy0MoiX)QGwYu8ZEOZ^;-ih5EW;pd&qv0I0PiacKxH#1d}Q7bDz z9?>qQZ(kTMDv=5PaI+;pqcL@zVTq1iozC!ueX{-g$Hi7wj~tF3vrseb)^v1+x)ra7 zfz`)l3U@r(U)kxa+G@7N;FY9QSC3&U=^V3brtp^3WSGrhm@cCv_xZq84XlbvCT}ae z%H3YS>mKc$)0XwP0;-H)tn?Htb1YJq%zxe54As%AzL;Dy1$#~ti=lTZboVK{b&1G~ zUiqUXv|it0dChJ9w%pfm4^&SdPQAY!{o#<`JuWt38;(iFY9P4XUyzF;5DnedQltIr zEG7oHt49Yj%TPaEHGI&a9`kij4@+Sw!Q+p8Jv*Krdu7oQv3Aee(WBkjcrg9cwg(PB{KFHuhy0!Zzpp1b7ef#|fo-8EqnP#~*QbBln6kEK!P+F%y+6Up=(69>+Ue4*4GPwnUTlT|TnD za#bEjc3JYAa!E6Lqb60cjHgzo5*u+uxp;r~tG0O2or(bn6JZ!!Ve4US zN={cWy=mJYY+#|$hO`Tryo=4LyN1H$@0Oc$jTD1dNg;-(BTJG$oJ(bv!<93p?Y`2{ zRj)2#C_cBht2C?B72gu|=~=;s!v@dmzI;(wlwvdf@$Pt;Z6R!oonH+)RVT_b78`H( zy~J98cf!;gH7I=uE>*;xC12~{%@x+YU#US)I`p+e_y{!?^QNV8Y+>fy0A{NVW2bB# z%3jV5Tej3kJ^c!C5s?to=gHBljt@m2_%r6_6}FFlhoH*OY7;yGvohJ-GPs@h(Ty)) z-5r;BKLbzdsz+@m_u)JN;J za7fz7h+a4D-=f*z_oc+A3?$0u99~>e*_GP8eubD1FCaWQL`);#!|a|#JCZxbhd5Rh zdHdX(Pm>l_7nE1hD=4tr75q1o!@84$pYOwIp$lFAI`3sffru{|66a`wnIb*@z)LcS zh)SX`#M6@hI3RfP>NkHC+TIm~35-xm_t|!pzINevVbcyYHM3GpK|Yj;xe1I3((kDx zy3uyXwZwZr=j?!v!)AKMrOg*VxYgeCoAeRag=6Yrx%SASZRGRIx0EroCRges*b?MF zN4vlsQ$!l6eKNjL5+rM=y2eox|Dxx896@gB@b@j+dbRgOrR&Xk-z&7T-jja|C!i!l zN$Kh|=zB?|6MRD>jAu0KDyIR}_-st3#ALd~Yc?&PAb-G!fX+z;h29Ebx;5zWvXG@_TanX{Dw4@`JTN>70E_I>c7ya-wv+6p>1158Z z3B;Q4bt4HX$%Nb<52p-1B|Qd#Ebb?=Z(~N^48Cxysba_}E?Uj8bLqUN|K3M_PrpGx z8LXTX@G_Rs){%)AU3dKQIex{r_6Sr^M|7Iac;o~r_yi+pKoogS zmj*OmhB7HZx?2SrEkp6#1U^OLQ~aXIjr#C+GInIO4;o*&k^&7UlaJRm!SzU7BlGIw zUBEkd=q=Rz^vRPbo`TH0AtEhNX#j>$=ZKUGX1X-)kXUsL#~{^?_~CWir;W>vjE@gA zaf(fM*L=U-{C3SN^_WtTPPr#OVAyn6XW4A|iHimgd3rp*Q$3&jd&-t4j@Dc3NmgaU z@A4a^wzQK5MzQR?FT!L_`_w6y?Ii9}+ri;<`oeM0Yee zNM}RV82Ey2fGj^*_&tsFgm;ag&qI`afoEb1&_g~cGZGKl)Ie8-ZZ*%ejJ!;#N)tqW zso|#|;?0-I)$X!4p&u~OsWoVx5Iy;7NUJM8^;vq&%&qE>2Kfd(wVbuq{7l%$v4^yWZ+&LuX7W>lb>Yg5zsPy?! zb(qa7A(wbcmjutz_(lwJukKLFYr+{O@^O$+Q9Bw#E9yXm4W8|U+LWuRO)->@UMC>z z+~x&;*LIW@M5y%I1G7MK;WA-VNE`gktBE4Q zM6$0xCZo5o4|^ykTNI3627025dYI>?bi; zc->Ph1ztomS*Yf&0K2w}?jnqdASdTDcewL~GHKy!S6-9a!ntx(Qu4asZR7)T*_OGo zIaD9{^y(w|d#$C7$eLdecH4(v?qqf>4ZhpCCI{DFK4)#nfhYR-n}C0LBTF-mR=Sh?@`8H?fnXb2Y!GXxGCXA_r@n zpP;7UrMT-;$++>l6Y1zg*7{=^sV@-?=ba|a*s2*gdd8L$rtTNh*B>^kry=cN+M-L==cdGC0G|BAaR z^DI$VO8tZ^Y+(7LwcDlM0#PEgB*E`b)i4}VN?rEN@GoZAF z=&}0ZwKpjD?3c6;jp`w64AOwgDbX*7bIzZmuHtsciy9qy9O+kJDm=~v!<#z^Y@3`J zk1P_5=?o6bj9SoB@sij6#&rW*Bs8KmWom%$5$}_MiXJ!x1$Ut%mE3RHufM&z+q#f| z8J{>bz!!M-B>^%BuLbbDWj$;u1gW)~EU?mpOeM>N^zRs_0N3p*9N{r2`6J9Z)nSh#6Fb$O$ZsHyJNIzg@4Xq5-wG zW!zs{(m?gP_R#}zx~FE|y5`1poSGZlHb^*?Od10!GHyxTPx^)?j3HWd0B715wIvl@km z)qzZ+Bz$WtB>J|-y;@Coh9r8mh|8KjVZv#LuxN*H)F6jWKqajR8|TvcQTRDD*WiOD zQ7nu(XVDw4VmfJ32$#gSAecl~JQJra!9ZRw!N?c&+&7Qp7!MO6KTganoZ>C zT%$+6A6jar{yf)97=xqsIqgIBrlkIZdL$7Wxnc&0{V^Ew!XK@JyY#R???aodInw%? z+Kce~H+IqTYn!`@9!PhyCuDR6%mTmGyXW2Po+a=}_YKa_+>N-SPC6_<*c&ot63!Q$m zevq;a&#l{TRJS>Z>uq?1*X}m!n5bW~;VH4wRrW z@qOfMSiW+FhB%|UR_sgcOIr@#m37(~k0x8Q<9f3Nhu&m6M1*SG7C+@%>Ju2?W~F7N zKneYHJy>NxUYeegv4JwX^wg=a{vZfVfYK<{wP*d!13kN`m-1KiD*pBXkS~ObX%Xiu|9ci?K@#zw)YtPZOa>BS*e&>uV&l|-Uyq*WZV>$ zyeI7tL?hgw6_Cn@u{Dr5tDCXZB`?Z1)rH1HN1TzM;q*8v!TqRF@01XVs69uP-$mOO z9Lg61+H4mgu|_Cc_!f(a$%ZqZg?wBGOv}H@3BYN2W;()}eJ8dpJ?xr?0$uolj z4W#_g(_Y#vlt%cARkzN1cpR@b6xWm7#3wRSeWNJRASAy-{1bVw({##)vfcy83#u5( zoCT9I1xM7?Rg7QJRn_71yypur@+tvFWvea!fAk0~;M{e!lBU&Sj zN1POz`wciB?#c&QoP7SeaYRv<^nt6HCNWVqLq3Bdr^|F>))-o3TD-Jj_g;r7BQOPH z{==e2d+pn8rMp2oL|Dhs4@!s| zs^U&w^cdvqcWx07mWnht{p8$4Q1@7)%akl9pMD#2AbD18z`-vl4o99AkL)C_m38#( zEFGfVE^>IBVyQMioHBV6ef`O;nTFm6@xyacyWvGTJnm>r_Y>KIq0b|qvsNcr(pZ6q zQHRYev2C+Cga~u0R!bUa)1}M@mt6?bb8~n;8&o;L zqKi_s4F}SnWTId$&1mOHslStVusCXId_!?2x09-@n(z%T zwQ!#-4|}YKzyFFG&L^D};w}Gbp=V6*L;l9Jn61C`Vc+-T8d%33_h!y3gZXc{=gQOU_0{zFzgb zwSx2mhLsc@-BpHZ|CB{#`g?uI{mj;dC}WA=t$K^JNcHu!hRRFEmU`)RPZ$h(xi#p( ztD6z}is}x#ADc=L_0(w0|ld_@esR>-ACs9P`pf zQJI*?rZMgi_C6oVFR04dT zYL9o#A#niAds<#u=?FQswnAj%VAgxLaN9Bx4%_hSLd1r{$uJKdYGSAeyQyl>JqhJ< zhEinBOf(YYVX@Z5*4@_r)^(x!!os3zsEOa50|RJ!MiZ@v$JXIOfA{nQb{$8t#WDp4 z=dHKhC580pph#S>o{k=Mj}1N8nh|WhF!V|``dwCGVPPfkKfg{;`APLNN_J`zD6&X_7P> zcZ|D_Q#0bHci75^)9{V(bu=vPiZ!ZkTUI8owG zK9b~CB1A{iX6lE$79XzD$mQ6wk$op%-zlGfKPP7vr64@}umxH(Cz0MGA2??B*-)YJ zpu1=ag(AudSKx+fqX7;S(d;=APV!h)MO$`8u&g9+$%>|Edr^iEvds#9M_XBj5RuU+ zs{=^rfw}gxsHZ8n)w7Ibc~@#StPL4Uvx;} ztuRU&e@*Q8=FvvcsUUsAm>)>C%O$r!z~IW!3R;(7(IMP5No7Al>fy1k@xfPD_PWGF z4*8ve`oskJqy4*7<~p5ugEtL6IoT>5RX|&KI^v7?(H6jN>9YaI*nt3 zcEu9kXpCEh`o_6&ISSb)#5oX|V_tfNq7!0cg*@OzvCPegD5N+YFBZd!hWZva2E|x8 z3qlU?qS-}PpwQCx6feg&yq87o7*jd8q{DqzTE#HnkSfnG`|t)c?iQkqQi~uvb+P{J zT^U7rBOYc|`E2;4LK?-$!WF5$mnyr?eWFhfxy=k811}$)fWy|s74zgshS32Qv@zfl zctJI}%s1jjfug{oK5tRxRyo0#yd#QP0Zqj%)-kMtcE;W7D-dmdfA~H{-odbXtD87T zlR+L`6lKox=#=^ftU5*?LhR6;@K;7-9Vu4EVk?PuR~$^h@YBz8ebc{T_`sH=$%FVi) zEvf=9UuZn_4f^hodPwGX3Y5KBp>>RoW>k7eM$*Q!3&+Q}K>e=F_kMq!$nll47ebO& z_+9&be&75Pl-PN>U3+)rdqrQ3k-jK>==?ZQ&>TzS5PNthql14%kuTV5EXSQ-uTY{A zd$0JbacbPatk5p)$^FpLoC#(VJhWnoO1RWf3X4{=-CJ5iB_yq+n_-^Uc{jaw`PbTV zR=nKRx^9sue`vSkD{GGA7yj}JYo*a0Rlex!yuABepPPO-TOKy5AA|^N3ct>R-Nk(f z!M6_7kA#4C@{;E0By_b8ZM_p&&B5w}MZn9Z#4ghsmXm;6k^TG?w?g}QcD~}~@V6L> zA36vpN=aS|ttp~?jEf7cROgT4+)VSlWj{BtLe|8z!0WEZA9QahkEFwEKhhIjB8V@d zrRe779X*u~FFynwT>%%`&x$maH2cSHL>%(O%&G3WRvTvmpNv}$dG5Tvy36iOZgNvk zS-%~jDXTd|KKKav<`~>QQRpZfS9tr%Rd2F|BzNhV*z%2<2GVqQiJ90IqyzT%s(T-A zzgOP-NJyl(_feY&IOJ_A|I%)GBs&xOHTdW%X~FjmH1FKz2vSEGA8Y}H1NrLW@_FQ# z;_}&&INg0wpBpou74-chuvU6cdzVk!xsRn=m-P|lPK6x~aRWwL(9=$WXLlY1jL0L( z3D9vJ=l-vQfgAaL%v{ILt;?B6a;Gv5hvoqz0$6D$DYHBBz*qEK$I8H0fIbz6Lxk34 zn(O(Q=xM&f4xXX2JE*P8!~r9`*Yo|zxsI`bVz=Z@MI8=10S!25CzZ21u{ZKbanpQt z91a~@mpgCd*CNT;7pLy1!Xr$e6?X}&ux zua+f%0vKt&fKrTBm;kQk09qK?Qp0Ke1(~Ik~h1<348_ELbi1|6lft%ny-e#VQRn#8j_p`psyfFLJ8$#onzyCVV|0LG)`I({;{69^~)Y#IV60jehBI!0+-E=Q8{0|2cCjOYVs z1RV~qwl3cUS`HZMe-2uxxsLD8?!W=MZpjINxO`e>ca8%__K@U0fVq4!XLo#n0|dDb z0GP2;p)`_xeYsQMA8GsY-YKc$q1P$c@$lQ};?5}=S9(H*Jk!Bs6prydg=a}8_yiWq z+e~p!Zyj%w;v;L3Oi0ZRTP`duXCBz6bzk}1ea(p~?dmF8YuPN<>hkPF87Lx9S7>$8 zK;6Nh?Cq!L2QN6l{y|lU*1UuXj_?tq@Q5u_ttZsrS@7rXM_CLm&&IzzBzEg2Ssa{T z)Utj^(Y-c0A){41vIePR=g1jf;pp;(g^5A<`GS|RZI%Wh(i4sqjD-MUIqQLVL$>mR@1df?ShKDo7Xf3+GZYp}M zxs?0n7JDdBh^pN&J*VH*!ZC~FU`)?or2{B`FV1M>ZtTpy(oYnb^ss@6R4O7wGtbC!BeF{REzJEc7vP13Rd;fV$*;q6t^W(#oS zH*qh*;@U4v>O$GzOmFTK4PKAKk;c&CnfxLez4ym`JJwwb@iW?3T?`MpMiCYseIY;L z%=%s^#ZR_Et}Nt$Xh7gWGVfqdOTAFy$iX|Ej+sVAqZ{xcK>U&DgM-}bp2K~9LJVy@ zz?;5|Z%}Zb1QFfUuG%bGqe=ySs%LuU*~ng90x!D?;`r%%FrQ&BXeDZMXgp?0c(pho zqAYnc&EIi+WXyk7!`eL5qGSF^zn-X#*naaiD*Dg9-Ns*!M%5dVwGQdPuRd`eqo=}6AaaXgB9v=lBtRI}RSj5ZU-RbptYbucbjMr5DTl1Q6v++rbsb9SJusy+3 zLUgiKt&x$^u)&ydk$Otbwyk^6Ndk#^a+B>h9SR^C0f_Kj?b1(Im%Y0AIt1kf5b$Ye z8E!WSRXYTsSfQkb(Ft<|MPWTtGZ{kB?AK&tXoFu*E7L7|I(3U50~)j?69TyGxn~Lx7KiklLDfv>3yXb=2$D~U@-}gr9 zF1rS$?6bE^vYUr562!krJk29`h)*fe^H?Z>u?KmcJ+B`G?^8p44}LQl%N&GW0q-S$ zz^YcJQ?Jzb1b+19wV|fe4)iFj^hH@YrY>A5$Cx@HkKh{zmWAF{2m6d4`HRyzT{@X! z<%ny{CnFko8(VbvSvyxFYIRpP%=_x!;(kSV$0~>4;<3>4_~w#Sj`|0mv3*NIPLy}9 zKJJSTOfADxcdnUpa!m4cGY%M-p;u#1b|7m$B92Sc)L#&4ov&dY`S3**KbZ?vscEX3m(j~u5rna8@cBqAo!`ofHPfJf^_-un@P)%LvC&QSpOso z;is`u9eR)01T5>DbgK9q(_Or>YwqK?gH4m@m0uuXCU!|Y?)?B;7)8KtJpFW2*2??NNv`b#6`qu-1820Rc8l`&&Iz}qn{ z$Hn{>Cp}9|;Z`=tvF#|+jD|0K2Xkv|zW~fE>^o)pv|n$25k&R^^BISB;n<_5(08(O z5jd*_B-ZQIYTM%&B8`Cpn7pOwuhNYKv9vMrAE`F1-EZnUBzF(Oy31Z9U0#5Uk1H9u zR+X@_&>&jh5TSxqs?M+(Uo>RF-u&Ls(=IMA?`a-Jjf>7+6MeSd#&}_^jE#=xOty`_ zx0c+ii9F)ealK=r_yvgk*Spg$+9s`K*p`7bOH|iO-X9V7miw7oBqnO@(;^oWo4_Hp z-?|yHZoqu-{Tt*Jo6 zk08XLv{TPwf*Bf_rb#NQN#K79H&uJxKpXkvblqWW{t_Cts967@6~{?(upr`_ppfMU zPLinohTIumjC(ZvRvx3xy;~(S1=8e>sL!HwNxf{}tpgXS?Q0qPwl?6ARE>wCSQ5|Z zsh2*N)ZFGI7kw`^6SO82q`Ia=5;uZ{7d~jrYwKU}NQ0upWXW^ejFnwNL(W1lD#H$r zjB=pCwlUh><`sukB-+yi6(0ph%*c_Cxo;FtG+Dhq7aKDAxgQNG2-Ix5_|QZ&uE?ur zo>bRu7muWOOnEenG{|oxVXlrRSD);w7+a<3e;hY&yRlaiZ$V&EI!-9&u4K3aDsyD8-ui=;A)hF=X z@T;5>@zn8Cs#th^g$;G*c$V=CUnoE;KM+hKPD<(~bgmyUPkwRaZEE%&x>=fyCdu%U zUfACs&4efIjft!$b>v<7@dx*5=qAll4L%s;<9v7L4W6;mw^LtFm?6m$2q_7f4N@_Z z+RYsvRlHF(o@RYqM(R|wO1RJSOaxibOHppnvuyJqyU@jwRv*Ell*i9=>V9<;TOQXPV|y^m!w){T6D**`X6PY2d93mPo^5oy zSxS1BPOHEEP32;K-XUUBWwS{s^?2UU&d%s;TkDefs=;#ZfI5rma%N+r+SrHfnTQBi zs*D7w(KUi3ypf6KhVPu^Gk%k4iH&GcdJPtmF4d{sNDcXH@;08wDdqZ~?HYN<It7Nlfiu@eu` z>C;%UeXc+EdM9r0^sF;|R`$(aUDb>3MUp+L_a-ZjgiXyBvD53j2mD_c%gbMWY@S%9 z*f>y9dY7c*T{&d&WKvTT0l_YvpC=_Gp}W!~n{tWzEfpsX>0LYdaY~5-%!t~U0{kJ4 z06L1UFeg8!)}W)u-j7=>-Pdovd8x6w4BuS*Y@9Rg)A87tU+PP*QU>=g$DU~6Dp=*R zd_g`-k5#d(BONzth;FYk&E~Yc{B(>)cltQkCQG^xo+>U?y4T4WE~4FL!{pN^H~u7E z?y<4w;sx+->;35oD{Di8TmJP|V;?k2*rhDFPNw^|v}-|E*Jhl(fZKXjyV_@1{lY76 z`1D;V%24E;uscBQ*!-{^ny97RZTwjGarzXPFCk6Ks*G7hDRlyKxz^^<4%c+iNX%eD zRzotIvy_XHOv;F1z(P#mm~@k>%Ide(Xel`UE%=o2E{275=z|uvS6AQMe$Z`|VNk|u z&mzsxN7!e`8aQdC)>r?jqQ2j|tAt%*w8lK}#wUVPBy6La>+bEb>ywVIs+>FMZX%Wa zij;16Mn$=ex6MZL=ozKRHsQ^$>B!~K2_@yC$1bRb^?sHU%zj<^#N2mBF7^?FqI}1i z1!zgFrFmhL4MLOf-10<-LtE!7T;v{N{pazUc7EG?*0pjIPAOuJ)Fz_wpG$<)adl5N z+N%%|hnLYBTe!UszW}Sk&(hJNqiPRV8ipJ1bP?{XY|jbQ8w)Dtxk*0rzRA}`KG_(b zQ}a9qFFvz9=~er+$pdz7PD#!#>s)N^Y3C`Y>Z~+u?%Ujs_?7# z#<#&Utm|{g+z}Obj;Uc62Kb7Jx5{3;|WuNl9ZDB&xeDfQuQ zPcDj-V|_I7AiLKHYh?Z|W&&m}pF)#39V?D+i{5HCWl9Q(+0M()>~t7VPLtTuaBYlQ z{uqyWj*g)^@J*S)4f}|Zs_pvIwXkclmTt>i8FGkG>uXj8sBSmvKYGOj_l3NdP#cn} zI2l&oEW9d}^htgUMFBkyo>ubhA$9_pIsH-wwRbD8Nmm@%k-w5W!AddQ)t0;^LmSeE zOSJn>y^P%VklnhPuW55&-^Rh>q>XJ>YB1X2Ul%xWU3QP0k3vfhXX>4gb~eXvlsB~w zV0EVN1*3tRyuWNj4m9mx;zPa_hKkvzA8f>9AaLj(-*L(Sm_O>dK%{A z(bD?(KBn(nI@y*MH?`V!2uk(L6O1J+)ka(&tLCwfYh#f<)lTQl`H&~U^i^AaQ9q?VRF~vca)>a{`=&aSJjoi2SvNXu3(t_>Asy7@!=#Mlgm?5FFTy~vZ! zIl_uq*?8F{ig$1GVvA+F%kBVX%DMaCu9xML<}5=Sb^fLMp3P6bmXx7|jcJQG0tv1~ z1@@v!l3ER81l5@8RbTkm2&rI&Vu;DDm0~H&MN)Rq!*TU7&b063MOhF1bP83cg~j#b z=^m<@pqgSY@5_URBdhsINLQG?WF$9Ayd`Aa=|JX(ya)@<-Vv)*Q;(Of#7@E1j>jAN z{QbF_YW3@(M#ka&ghISw$4cMwi5e>F_cT>zrH|dL6KUK|eYBg8%-+I*@0F6v_o%(? zYwPA5iPY0>a{u%kQXGrrv?HC(zp*pe^N=j-5$LE>W0d@ZzXsp_8oWg6$A^L3`{;}$ z83V_IgV~W=;W@APiX8$1gNU$**kgD);U%g*J`}Mgh|48Tmw~6W9QIzim44cxYJ5cs zlIm$#M;ZaHqxB06mtlz~P)@fDESz-dp-k19*CN42vU>lFAFX?S_F$V-Np>ZC->%d@ z44-h72_U@>`Lj?LWt4?%M8uow5TZ@ zsRheN1~#{%)=NI!)5eg?)WOJLahyU`9xfmzu^i2{eB>7(W&5#3aK1%_0BRzsYPskf zX6_M(-o7QLCExH}6v%8Iq`5pA@L9$V;;CvlntmM+JytmdA1VL|x10&?6f% zD;9p&R}`m_BjPXZMr-7~dqC?zWeIAcSwSG(CDX>T9o8|gV_xC;{@hXaP2P1~SJkh1 zvY7_YvFl4~vM0hktwQ6`-U?!OuTIt+BpfJJR1C(K%=Nx{KXM~Ngp*mr$>E@N^%Jv~ zty60Nu#i;r!tvx*jn7EY`rAz1O4D^#&Xb+ggn3!crm17aZGHLj;yGRlL@N0A&uKfdZPW^$`Fc`!cQd}pO(_#PkoO?f|AKt|^(K>`gsYFl+vSy!v9Vp< zk3Hr|J`wYky2SqPA3Y5umEBpZ$=Tm@QW-da3gkMpO-Ys5o+?_EHauaTV4d49qDJpm zfADw$Pr{}8GjHYa(sxIup|lSV_9})9+V&O-EvcWeb1?LJt+}SEB@uz}Ph9ffkG-gI z_}&ru8u`sG&Qrn!@!W?l0@j-iB{4!aIRZ?O(k9;6BWJ+|U3<3g#|a~Zs@~roYuhvQ z_Tk+&ug|q&wcR!OE~7SbdV{77yY$z`GIRa1YwYal$)cvt#!i+Fz*G4zo~C@Zn->HY zI{(9Rl{3AkWN&L|r=TpW0+g{aGzT7=j0hHTHU{=?gSfaM%n%6c*-ol6Rc9h5 zD1?KVm4%C)`)se5sjV8Y*^ilpl^wzkoF-!EAZco8Zs7v#$%8UOSh!h%Go^qnWtPT5 zcILqE1pp6UK5Lio`BR&j*g3eES=oU?ASPB84rUHE7GS?L@O*wi3T0shL0AAG7f^r= z*ob^~8nBzo6bj-x-^>fM{p*))^=D--&iH8n(7A@2!x z+kv&AKg7Cc`)aj0I5?QOxuNHqpwG&2LIHboaB_40{G@xfDN&n^jg^^;jgx}|IQ)ki zz^0<=z~0oesxNn%bDixWzucYpZ#(B$&rbTeZ%#M9_w3Q>Vta>(Ob^83j=FLTkP8D^ zBkvTXqE$StWGFBtV?a9f*_3%KjEhP1Ddx*ql)~;)Hx8#w66zVjg%_^WFZpHy;NQ0z z5KAlBmBid@(~d1Q7amJ8OQDE*zDlEZJ1r6oZ;hzEjnkafwZe3s3Gdc8inp_Be`5Hj zPsf~5v*bx7jP?E*2)o0FZ{4mlSJrIf^)xKWZXlC`u*gG=@G)VGuKn}e?~&!0Gi ztnQQqc19d8ye->3x{}wNbnok1AbWI%*X!6HJK!T z#I1=T5Iuk+16%pI&L3z2^uCI{s-5N8Z&85G`yF;)=#OV90NXu(Y^da5I}7lC^$K>* zfBcliD|KrdqPMOmUuGtCo9h|wtgDvt5;0LZe;KSMLAD**vI>#z`G3Woc{o)6+sBnG zA+ie-*|M8`M)vF^yR1`$G?uI(gd!Sb3rPssL$dFw=xfU^+1HRYTglRMM$dN{^Zh;7 z@4x2{~Mg=ek}ei?2}7WGvJArmAntYd$@?{Fu?^%NCzLl0P~nC7)b< zbB|Z(_zAL)QK!;NmD{+)hI&q2E2WJpcd;6zjN&@5WGYxZ&Rii=qKSLWPeDfGPCl|R z>QnEN;<-X|js!kwqNKwq<=$D|J1%vw8}Q?_yvzDaIf?pnvLx?CNFHfn>R;UE5aIyW zQ&7sl)@;V!}OJa^*yqXy4aA{A9 zACWT@Z?rK)gS=Z~bu zD%tZ&9DJA${?q*OzGb<`5IG7bCJhf1%RRz0NgkA}7s>n0ZG9^A&sfRmn)+$a{VbYi z`@5>#rDVIadvx!48?2f-({IVKwf;;PuR9oO?e*X~gA8Ds88TJjR1o1wi!)*yOry=kNeAzzaZImMpjJASZ2pj$Vpck45;GFmjbr*ytW_ECGE##Vt9H}C zwbmsfJR%~LW_!Y!|JFPDoASO=G!^>dZe@-6D+l*hTGs9|`#+>x3(RR)ID2w6;l1Y6 zZb19=4>6z3U+yh81}6OjB@n)6Lbx9Jn?zSs^F(-Ea~!P_zM4Us@oCL-dPhFAh*fPb z)N^jeH=r^p*MGSx%xj7JtTVQo@ndPdv0LUBgAR3XzHR4rM0vl%qfY~dhDGmFB04@~ zxwQXr=H2?i6VNHclb+gsSH2XsCntW)EYCcp`7rZA<`4~5cbsv8)>B&snp$PD!#kFy z*l7E0s!`eo)4t(_6&!4xwnyY#x|S0~Rle4j+AmFQX^nX%U7I%{(rb#7VU_|VF?e53 zh46X4ug)mblKG^x;|;}7@jYP4(1*~R^3&~*#ASF<(tW!dmn<)NZd}M;w5F9Sff40C z8##Oab?n@Xb}LugqTxP-^PQl|EH`(_thQ6QH+j^3*PK1|9mb_5%u=(H3sr+~gURKf zb-LDvWjk6{y;?55#t}h&e#Tp6)HTo3l(l!--hS9kOLm-Xds{S=_jT56N^2)&vq_*< zbeyrM?fcBjN>T~Z(ChcK=`vW}xD;35$dI82!tr(tX8GuQclXSgIp*IE8*-8<7`o6< zy*buC$?(OQQugB{4`=vw_7X3K8xzyVA6;W-b*7KJPCNJf)v3C9MXuY|See8p8Ehw} zTe_Nsq#wCeo(L|e4EN~{JGN;hwesNJeV7g#WjC4NlG4>s)r(1f{oTxhBVnL(XYWy6 zuVnh^0VWN0vt;U>91MBuZT^18`Ekfa*6M+EUHdb++BT)K_vyneX?O>>>=&Ik=1VZr z?UrY+xEzbT&XWCB*ctQP>O7MQI7G62>Jr(zr*>+$CGV+|IU4aY98x2=B&#Td3II zdGnQ9J|>|Ue0VgE-!xr zjfOwo8yN2zKUiw#0!e=BxstM}oPgfA&(%6R!jW+G?!nLD(+(ECdluohBg$_Ibd-M;lQor;z+k*JjYU>*$*$f@tQ4)rH!4_+9;BWnMGafP z5<`($OUna!O>(J}1s(-^=HihST=~W^ox$`^6Kk%?zd2o^OaJiMm_$=HZ%K@8kA^En zMcbT!5hnS`={bS-2XV?(wd;}T2@N7=1^q?G{w82T-$^*Ru}o)xc?H9|K z=F;cAPm#Y`KAn%ZP4R+z8jEXpj#PBsG&f-|GdeI4=Q7^ue2J>1KJF0!pM_4T*j-DIv>LDN0+I5Nx8=nz^ZpD`&yM z@;22{tcQwfUz{(973$?~pJx#ibx-a6YKE*$5p>`~XJq(oJFsSW%SCQ!)nS5G%_nA*=jc?z=sPFK68KBDH?9xIV5!@OAdTg3 z%YJdiATvGv#jGiDVMj!-XFnB4VcYry>baTFOZ$7Qnx>eL6E8e-@kYaE`CWa2_ zu%`AyoZs>8j9S*{^F)6J=yHr*1(R}5mG7HbUf$(+4cw@}ol9Xs4LE&f8Hwlx=Pdb- zF-rd0Xr7j&B#$H(a``ZX(p%mo%;}3kqu5?b?vjv=(neRq_@|-?>|%Lh)pOY;t0>3m&c8h}j)nk4g3tMjw+S7)Op6 zuIPvj`gLC;@7dhXy-OhZ&$J#3@~=wWxlNn~6oPC_DahNLKuRV~-3sBeMxgg5V+Hrw z<7F=7*nH0KzgB6v+MTDCtUPPNhrAt{>mH0hfDPBl98?w@`5s@?O-n&jADmy5b z5Iwb2QBaPy!`XO$=&qAdFetUz@CydFEibmZNqwX(xcwk{wJ*o@b?TP-+j<*O>Bw!l zQ@=i)8FD;zLQ08aZm6jLIeV4X-ib~w=XuTQitAlzfmc=eC$(tN?y0nVL%n%dBG#hi zq5C?yaH|O8`qtD--*WA%s7WuDmTjZm8w5)ALuO;51$aR{_sA;^RzLSB41p?y9SzmA*X#-g(x+A}=R-nrgM>3YBG-g>Uh{ z6S-`{w6&LOo0=GjFo|y0%``=XI`#oVrdS)vgRQ%p|8-GM#kfR*{iS5$&rsWC7M9@AJ#JiE~7po)fke>(76n8Q)-9K>8E~U#mDBiqhZ5Q5<+8g=9IDx9`<|W^Kfq;f9Ki<7@O-L}{E&)h4Gvpza4X}}5fB;a}%n*mp5(*Bnz~jrzP=}@)@RBts z)Z%}QBLu+L{>wP}2U+m{vIw9^0QLM&3c&zE^sG){_k1gKsdPgcs_JP~3)3Oc!LWbf~+vK$zl zcl;r5+LryT@t@H8uz z61!7`;rj%}t&Wi)IzUi`L%3Ie3Gb+$h9{c+&jCg8A=^q5n9*{##$@kQGMg z3mt+N|L$fl0N+Ig23%pP-|sTRe*#Q41b_;(<^D^<10N6n&ji9_NMorD?$a7~G{L${SM4hqF{N(nSzalix7 z2sAJng@-i~YEYOYu?DO!N8`|NFi~zWpa@6fph&zMeB>Meyo^Xg6W0ZblmwPv!nQD= z_(wIUB=~55fMGx)N8?}+{JWoy91o6%86MGKc)BW~21nt6nS>e)MVto=i3j~1iIe=7 zJkWT+9bp_CN}LBA46N@*#QAK`fslEh;JE{R7I62<}DMx-Gi zcrqVh90H67E*{Zf#A6o$#ba}i#38{%^+UkX#2OlpRz2EQl1M{=fqjJ|aYzX9+yaIv z(X}99#C?f`;sKn5=S3oj)&nr$ITKwAu*X5vM@S?d%1C%T6qtB!A<@KR4GqN`;z!!T zfGw7z8kD$JCWo6;A)SP6(GW?Zv5N-dag|5n z{^cBK1o0dM%0e{O&}iayR}#28I(iOCF!7j_gb?*P1dPD*hzYI_0!9(l6xcJwLx2h5 zpl~?R{Ds0%cw{VL9D=w8Py~XgR!~VO9;iy#RuWEBXQ(6!55fI2&fV1#*nf1zzx-VP x<{egetRealPath(), '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)); + } +}