diff --git a/src/Maker/Common/InstallDependencyTrait.php b/src/Maker/Common/InstallDependencyTrait.php new file mode 100644 index 000000000..f74a7f10d --- /dev/null +++ b/src/Maker/Common/InstallDependencyTrait.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker\Common; + +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Component\Process\Process; + +trait InstallDependencyTrait +{ + /** + * @param string $composerPackage Fully qualified composer package to install e.g. symfony/maker-bundle + */ + public function installDependencyIfNeeded(ConsoleStyle $io, string $expectedClassToExist, string $composerPackage): ConsoleStyle + { + if (class_exists($expectedClassToExist)) { + return $io; + } + + $io->writeln(sprintf('Running: composer require %s', $composerPackage)); + + Process::fromShellCommandline(sprintf('composer require %s', $composerPackage))->run(); + + $io->writeln(sprintf('%s successfully installed!', $composerPackage)); + $io->newLine(); + + return $io; + } +} diff --git a/src/Maker/MakeWebhook.php b/src/Maker/MakeWebhook.php new file mode 100644 index 000000000..3fe1d1931 --- /dev/null +++ b/src/Maker/MakeWebhook.php @@ -0,0 +1,307 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\FileManager; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputAwareMakerInterface; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Maker\Common\InstallDependencyTrait; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; +use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\ChainRequestMatcher; +use Symfony\Component\HttpFoundation\Exception\JsonException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\AttributesRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\HostRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\PathRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\RemoteEvent\RemoteEvent; +use Symfony\Component\Webhook\Client\AbstractRequestParser; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Yaml\Yaml; + +/** + * @author Maelan LE BORGNE + * + * @internal + */ +final class MakeWebhook extends AbstractMaker implements InputAwareMakerInterface +{ + use InstallDependencyTrait; + + public const WEBHOOK_NAME_PATTERN = '/^[a-zA-Z_.\-\x80-\xff][a-zA-Z0-9_.\-\x80-\xff]*$/u'; + private const WEBHOOK_CONFIG_PATH = 'config/packages/webhook.yaml'; + + private ConsoleStyle $io; + + private YamlSourceManipulator $ysm; + private string $name; + + /** @var array */ + private array $requestMatchers = []; + + public function __construct( + private FileManager $fileManager, + private Generator $generator, + ) { + } + + public static function getCommandName(): string + { + return 'make:webhook'; + } + + public static function getCommandDescription(): string + { + return 'Create a new Webhook'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('name', InputArgument::OPTIONAL, 'Name of the webhook to create (e.g. github, stripe, ...)') + ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeWebhook.txt')) + ; + + $inputConfig->setArgumentAsNonInteractive('name'); + } + + public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void + { + $dependencies->addClassDependency( + Yaml::class, + 'yaml' + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + $this->io = $io; + + $this->installDependencyIfNeeded($io, AbstractRequestParser::class, 'symfony/webhook'); + + if ($this->name = $input->getArgument('name') ?? '') { + if (!$this->verifyWebhookName($this->name)) { + throw new RuntimeCommandException('A webhook name can only have alphanumeric characters, underscores, dots, and dashes.'); + } + + return; + } + + $argument = $command->getDefinition()->getArgument('name'); + $question = new Question($argument->getDescription()); + $question->setValidator(Validator::notBlank(...)); + + $this->name = $this->io->askQuestion($question); + + while (!$this->verifyWebhookName($this->name)) { + $this->io->error('A webhook name can only have alphanumeric characters, underscores, dots, and dashes.'); + $this->name = $this->io->askQuestion($question); + } + + while (true) { + $newRequestMatcher = $this->askForNextRequestMatcher(isFirstMatcher: empty($this->requestMatchers)); + + if (null === $newRequestMatcher) { + break; + } + + $this->requestMatchers[] = $newRequestMatcher; + } + + if (\in_array(ExpressionRequestMatcher::class, $this->requestMatchers, true)) { + $this->installDependencyIfNeeded($this->io, Expression::class, 'symfony/expression-language'); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $requestParserDetails = $this->generator->createClassNameDetails( + Str::asClassName($this->name.'RequestParser'), + 'Webhook\\' + ); + $remoteEventConsumerDetails = $this->generator->createClassNameDetails( + Str::asClassName($this->name.'WebhookConsumer'), + 'RemoteEvent\\' + ); + + $this->addToYamlConfig($this->name, $requestParserDetails); + + $this->generateRequestParser(requestParserDetails: $requestParserDetails); + + $this->generator->generateClass( + $remoteEventConsumerDetails->getFullName(), + 'webhook/WebhookConsumer.tpl.php', + [ + 'webhook_name' => $this->name, + ] + ); + + $this->generator->writeChanges(); + $this->fileManager->dumpFile(self::WEBHOOK_CONFIG_PATH, $this->ysm->getContents()); + + $this->writeSuccessMessage($io); + } + + private function verifyWebhookName(string $entityName): bool + { + return preg_match(self::WEBHOOK_NAME_PATTERN, $entityName); + } + + private function addToYamlConfig(string $webhookName, ClassNameDetails $requestParserDetails): void + { + $yamlConfig = Yaml::dump(['framework' => ['webhook' => ['routing' => []]]], 4, 2); + if ($this->fileManager->fileExists(self::WEBHOOK_CONFIG_PATH)) { + $yamlConfig = $this->fileManager->getFileContents(self::WEBHOOK_CONFIG_PATH); + } + + $this->ysm = new YamlSourceManipulator($yamlConfig); + $arrayConfig = $this->ysm->getData(); + + if (\array_key_exists($webhookName, $arrayConfig['framework']['webhook']['routing'] ?? [])) { + throw new \InvalidArgumentException('A webhook with this name already exists'); + } + + $arrayConfig['framework']['webhook']['routing'][$webhookName] = [ + 'service' => $requestParserDetails->getFullName(), + 'secret' => 'your_secret_here', + ]; + $this->ysm->setData( + $arrayConfig + ); + } + + /** + * @throws \Exception + */ + private function generateRequestParser(ClassNameDetails $requestParserDetails): void + { + $useStatements = new UseStatementGenerator([ + JsonException::class, + Request::class, + Response::class, + RemoteEvent::class, + AbstractRequestParser::class, + RejectWebhookException::class, + RequestMatcherInterface::class, + ]); + + // Use a ChainRequestMatcher if multiple matchers have been added OR if none (will be printed with an empty array) + $useChainRequestsMatcher = false; + + if (1 !== \count($this->requestMatchers)) { + $useChainRequestsMatcher = true; + $useStatements->addUseStatement(ChainRequestMatcher::class); + } + + $requestMatcherArguments = []; + + foreach ($this->requestMatchers as $requestMatcherClass) { + $useStatements->addUseStatement($requestMatcherClass); + $requestMatcherArguments[$requestMatcherClass] = $this->getRequestMatcherArguments(requestMatcherClass: $requestMatcherClass); + + if (ExpressionRequestMatcher::class === $requestMatcherClass) { + $useStatements->addUseStatement(Expression::class); + $useStatements->addUseStatement(ExpressionLanguage::class); + } + } + + $this->generator->generateClass( + $requestParserDetails->getFullName(), + 'webhook/RequestParser.tpl.php', + [ + 'use_statements' => $useStatements, + 'use_chained_requests_matcher' => $useChainRequestsMatcher, + 'request_matchers' => $this->requestMatchers, + 'request_matcher_arguments' => $requestMatcherArguments, + ] + ); + } + + private function askForNextRequestMatcher(bool $isFirstMatcher): ?string + { + $this->io->newLine(); + + $availableMatchers = $this->getAvailableRequestMatchers(); + $matcherName = null; + + while (null === $matcherName) { + if ($isFirstMatcher) { + $questionText = 'Add a RequestMatcher (press to skip this step)'; + } else { + $questionText = 'Add another RequestMatcher? Enter the RequestMatcher name (or press to stop adding matchers)'; + } + + $choices = array_diff($availableMatchers, $this->requestMatchers); + $question = new ChoiceQuestion($questionText, array_values([''] + $choices), 0); + $matcherName = $this->io->askQuestion($question); + + if ('' === $matcherName) { + return null; + } + } + + return $matcherName; + } + + /** @return string[] */ + private function getAvailableRequestMatchers(): array + { + return [ + AttributesRequestMatcher::class, + ExpressionRequestMatcher::class, + HostRequestMatcher::class, + IpsRequestMatcher::class, + IsJsonRequestMatcher::class, + MethodRequestMatcher::class, + PathRequestMatcher::class, + PortRequestMatcher::class, + SchemeRequestMatcher::class, + ]; + } + + private function getRequestMatcherArguments(string $requestMatcherClass): string + { + return match ($requestMatcherClass) { + AttributesRequestMatcher::class => '[\'attributeName\' => \'regex\']', + ExpressionRequestMatcher::class => 'new ExpressionLanguage(), new Expression(\'expression\')', + HostRequestMatcher::class, PathRequestMatcher::class => '\'regex\'', + IpsRequestMatcher::class => '[\'127.0.0.1\']', + IsJsonRequestMatcher::class => '', + MethodRequestMatcher::class => '\'POST\'', + PortRequestMatcher::class => '443', + SchemeRequestMatcher::class => 'https', + default => '[]', + }; + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 6575767af..7062f826e 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -157,5 +157,11 @@ + + + + + + diff --git a/src/Resources/help/MakeWebhook.txt b/src/Resources/help/MakeWebhook.txt new file mode 100644 index 000000000..52096f221 --- /dev/null +++ b/src/Resources/help/MakeWebhook.txt @@ -0,0 +1,8 @@ +The %command.name% command creates a RequestParser, a WebhookHandler and adds the necessary configuration +for a new Webhook. + +php %command.full_name% stripe + +If the argument is missing, the command will ask for the webhook name interactively. + +It will also interactively ask for the RequestMatchers to use for the RequestParser's getRequestMatcher function. diff --git a/src/Resources/skeleton/webhook/RequestParser.tpl.php b/src/Resources/skeleton/webhook/RequestParser.tpl.php new file mode 100644 index 000000000..9a60fc972 --- /dev/null +++ b/src/Resources/skeleton/webhook/RequestParser.tpl.php @@ -0,0 +1,52 @@ + + +namespace ; + + + +final class extends AbstractRequestParser +{ + protected function getRequestMatcher(): RequestMatcherInterface + { + + return new ChainRequestMatcher([ + + + + new (), + + ]); + + return new (); + + } + + /** + * @throws JsonException + */ + protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent + { + // Implement your own logic to validate and parse the request, and return a RemoteEvent object. + + // Validate the request against $secret. + $authToken = $request->headers->get('X-Authentication-Token'); + if (is_null($authToken) || $authToken !== $secret) { + throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid authentication token.'); + } + + // Validate the request payload. + if (!$request->getPayload()->has('name') + || !$request->getPayload()->has('id')) { + throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request payload does not contain required fields.'); + } + + // Parse the request payload and return a RemoteEvent object. + $payload = $request->getPayload()->all(); + + return new RemoteEvent( + $payload['name'], + $payload['id'], + $payload, + ); + } +} diff --git a/src/Resources/skeleton/webhook/WebhookConsumer.tpl.php b/src/Resources/skeleton/webhook/WebhookConsumer.tpl.php new file mode 100644 index 000000000..ab627124b --- /dev/null +++ b/src/Resources/skeleton/webhook/WebhookConsumer.tpl.php @@ -0,0 +1,20 @@ + + +namespace ; + +use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; +use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface; +use Symfony\Component\RemoteEvent\RemoteEvent; + +#[AsRemoteEventConsumer('')] +final class implements ConsumerInterface +{ + public function __construct() + { + } + + public function consume(RemoteEvent $event): void + { + // Implement your own logic here + } +} diff --git a/src/Test/MakerTestEnvironment.php b/src/Test/MakerTestEnvironment.php index 2e224feec..635c031f9 100644 --- a/src/Test/MakerTestEnvironment.php +++ b/src/Test/MakerTestEnvironment.php @@ -336,7 +336,7 @@ public function createInteractiveCommandProcess(string $commandName, array $user commandLine: sprintf('php bin/console %s %s --no-ansi', $commandName, $argumentsString), cwd: $this->path, envVars: $envVars, - timeout: 10 + timeout: 30 ); if ($userInputs) { diff --git a/tests/Maker/MakeWebhookTest.php b/tests/Maker/MakeWebhookTest.php new file mode 100644 index 000000000..f9bc6b05e --- /dev/null +++ b/tests/Maker/MakeWebhookTest.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeWebhook; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +class MakeWebhookTest extends MakerTestCase +{ + protected function getMakerClass(): string + { + return MakeWebhook::class; + } + + public function getTestDetails(): \Generator + { + yield 'it_makes_webhook_with_no_prior_config_file' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker([ + 'remote_service', // webhook name + '', // skip adding matchers + ]); + + $this->assertStringContainsString('Success', $output); + + $outputExpectations = [ + 'src/Webhook/RemoteServiceRequestParser.php' => 'use Symfony\Component\Webhook\Client\AbstractRequestParser;', + 'src/RemoteEvent/RemoteServiceWebhookConsumer.php' => '#[AsRemoteEventConsumer(\'remote_service\')]', + ]; + + $this->assertStringContainsString('created: ', $output); + + foreach ($outputExpectations as $expectedFileName => $expectedContent) { + $path = $runner->getPath($expectedFileName); + + $this->assertStringContainsString($expectedFileName, $output); + $this->assertFileExists($runner->getPath($expectedFileName)); + $this->assertStringContainsString($expectedContent, file_get_contents($path)); + } + + $securityConfig = $runner->readYaml('config/packages/webhook.yaml'); + + $this->assertEquals( + 'App\\Webhook\\RemoteServiceRequestParser', + $securityConfig['framework']['webhook']['routing']['remote_service']['service'] + ); + + $this->assertEquals( + 'your_secret_here', + $securityConfig['framework']['webhook']['routing']['remote_service']['secret'] + ); + }), + ]; + + yield 'it_makes_webhook_with_prior_webhook' => [$this->createMakerTest() + ->addExtraDependencies('symfony/webhook') + ->run(function (MakerTestRunner $runner) { + $runner->copy('make-webhook/webhook.yaml', 'config/packages/webhook.yaml'); + $runner->copy('make-webhook/RemoteServiceRequestParser.php', 'src/Webhook/RemoteServiceRequestParser.php'); + $runner->copy('make-webhook/RemoteServiceWebhookConsumer.php', 'src/RemoteEvent/RemoteServiceWebhookConsumer.php'); + + $output = $runner->runMaker([ + 'another_remote_service', // webhook name + '', // skip adding matchers + ]); + + $this->assertStringContainsString('Success', $output); + + $outputExpectations = [ + 'src/Webhook/AnotherRemoteServiceRequestParser.php' => 'use Symfony\Component\Webhook\Client\AbstractRequestParser;', + 'src/RemoteEvent/AnotherRemoteServiceWebhookConsumer.php' => '#[AsRemoteEventConsumer(\'another_remote_service\')]', + ]; + + $this->assertStringContainsString('created: ', $output); + + foreach ($outputExpectations as $expectedFileName => $expectedContent) { + $path = $runner->getPath($expectedFileName); + + $this->assertStringContainsString($expectedFileName, $output); + $this->assertFileExists($runner->getPath($expectedFileName)); + $this->assertStringContainsString($expectedContent, file_get_contents($path)); + } + + $securityConfig = $runner->readYaml('config/packages/webhook.yaml'); + + // original config should not be modified + $this->assertArrayHasKey('remote_service', $securityConfig['framework']['webhook']['routing']); + + $this->assertEquals( + 'App\\Webhook\\RemoteServiceRequestParser', + $securityConfig['framework']['webhook']['routing']['remote_service']['service'] + ); + + $this->assertEquals( + '%env(REMOTE_SERVICE_WEBHOOK_SECRET)%', + $securityConfig['framework']['webhook']['routing']['remote_service']['secret'] + ); + + // new config should be added + $this->assertArrayHasKey('another_remote_service', $securityConfig['framework']['webhook']['routing']); + + $this->assertEquals( + 'App\\Webhook\\AnotherRemoteServiceRequestParser', + $securityConfig['framework']['webhook']['routing']['another_remote_service']['service'] + ); + + $this->assertEquals( + 'your_secret_here', + $securityConfig['framework']['webhook']['routing']['another_remote_service']['secret'] + ); + }), + ]; + + yield 'it_makes_webhook_with_single_matcher' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker([ + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', + ]); + + $this->assertStringContainsString('Success', $output); + + $outputExpectations = [ + $parserFileName = 'src/Webhook/RemoteServiceRequestParser.php' => 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', + 'src/RemoteEvent/RemoteServiceWebhookConsumer.php' => '#[AsRemoteEventConsumer(\'remote_service\')]', + ]; + + $this->assertStringContainsString('created: ', $output); + + foreach ($outputExpectations as $expectedFileName => $expectedContent) { + $path = $runner->getPath($expectedFileName); + + $this->assertStringContainsString($expectedFileName, $output); + $this->assertFileExists($runner->getPath($expectedFileName)); + $this->assertStringContainsString($expectedContent, file_get_contents($path)); + } + + $this->assertStringContainsString( + 'return new IsJsonRequestMatcher();', + file_get_contents($runner->getPath($parserFileName)) + ); + }), + ]; + + yield 'it_makes_webhook_with_multiple_matchers' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker([ + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', + '6', // 'PortRequestMatcher', + ]); + + $this->assertStringContainsString('Success', $output); + + $outputExpectations = [ + $parserFileName = 'src/Webhook/RemoteServiceRequestParser.php' => 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', + 'src/RemoteEvent/RemoteServiceWebhookConsumer.php' => '#[AsRemoteEventConsumer(\'remote_service\')]', + ]; + + $this->assertStringContainsString('created: ', $output); + + foreach ($outputExpectations as $expectedFileName => $expectedContent) { + $path = $runner->getPath($expectedFileName); + + $this->assertStringContainsString($expectedFileName, $output); + $this->assertFileExists($runner->getPath($expectedFileName)); + $this->assertStringContainsString($expectedContent, file_get_contents($path)); + } + + $requestParserSource = file_get_contents($runner->getPath($parserFileName)); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\ChainRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + << [$this->createMakerTest() + ->addExtraDependencies('symfony/expression-language') + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker([ + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', + '1', // 'ExpressionRequestMatcher', + ]); + + $this->assertStringContainsString('Success', $output); + + $outputExpectations = [ + $parserFileName = 'src/Webhook/RemoteServiceRequestParser.php' => 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', + 'src/RemoteEvent/RemoteServiceWebhookConsumer.php' => '#[AsRemoteEventConsumer(\'remote_service\')]', + ]; + + $this->assertStringContainsString('created: ', $output); + + foreach ($outputExpectations as $expectedFileName => $expectedContent) { + $path = $runner->getPath($expectedFileName); + + $this->assertStringContainsString($expectedFileName, $output); + $this->assertFileExists($runner->getPath($expectedFileName)); + $this->assertStringContainsString($expectedContent, file_get_contents($path)); + } + + $requestParserSource = file_get_contents($runner->getPath($parserFileName)); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\HttpFoundation\ChainRequestMatcher;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\ExpressionLanguage\Expression;', + $requestParserSource + ); + + $this->assertStringContainsString( + 'use Symfony\Component\ExpressionLanguage\ExpressionLanguage;', + $requestParserSource + ); + + $this->assertStringContainsString( + << ['mywebhook', true]; + yield 'With underscore' => ['my_webhook', true]; + yield 'With hyphen' => ['my-webhook', true]; + yield 'With extend ascii chars' => ['éÿù', true]; + yield 'With numbers' => ['mywebh00k', true]; + + // Invalid cases + yield 'Leading number' => ['1mywebh00k', false]; + yield 'With space' => ['my webhook', false]; + yield 'With non-ascii characters' => ['web🪝', false]; + } } diff --git a/tests/fixtures/make-webhook/RemoteServiceRequestParser.php b/tests/fixtures/make-webhook/RemoteServiceRequestParser.php new file mode 100644 index 000000000..b8da4fd71 --- /dev/null +++ b/tests/fixtures/make-webhook/RemoteServiceRequestParser.php @@ -0,0 +1,52 @@ +headers->get('X-Authentication-Token'); + + if (null === $authToken || $authToken !== $secret) { + throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid authentication token.'); + } + + // Validate the request payload. + if (!$request->getPayload()->has('name') + || !$request->getPayload()->has('id')) { + throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request payload does not contain required fields.'); + } + + // Parse the request payload and return a RemoteEvent object. + $payload = $request->getPayload()->getIterator()->getArrayCopy(); + + return new RemoteEvent( + $payload['name'], + $payload['id'], + $payload, + ); + } +} diff --git a/tests/fixtures/make-webhook/RemoteServiceWebhookConsumer.php b/tests/fixtures/make-webhook/RemoteServiceWebhookConsumer.php new file mode 100644 index 000000000..fe9dd0c4c --- /dev/null +++ b/tests/fixtures/make-webhook/RemoteServiceWebhookConsumer.php @@ -0,0 +1,20 @@ +