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 index 300c15731..3fe1d1931 100644 --- a/src/Maker/MakeWebhook.php +++ b/src/Maker/MakeWebhook.php @@ -18,6 +18,7 @@ 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; @@ -44,7 +45,6 @@ use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher; use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface; use Symfony\Component\RemoteEvent\RemoteEvent; use Symfony\Component\Webhook\Client\AbstractRequestParser; use Symfony\Component\Webhook\Exception\RejectWebhookException; @@ -57,11 +57,18 @@ */ final class MakeWebhook extends AbstractMaker implements InputAwareMakerInterface { - /** @see https://regex101.com/r/S3BWkx/1 */ + 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; + private string $name; + + /** @var array */ + private array $requestMatchers = []; public function __construct( private FileManager $fileManager, @@ -91,14 +98,6 @@ public function configureCommand(Command $command, InputConfiguration $inputConf public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void { - $dependencies->addClassDependency( - AbstractRequestParser::class, - 'webhook' - ); - $dependencies->addClassDependency( - ConsumerInterface::class, - 'remote-event' - ); $dependencies->addClassDependency( Yaml::class, 'yaml' @@ -107,7 +106,11 @@ public function configureDependencies(DependencyBuilder $dependencies, ?InputInt public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { - if ($this->name = $input->getArgument('name')) { + $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.'); } @@ -119,10 +122,25 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma $question = new Question($argument->getDescription()); $question->setValidator(Validator::notBlank(...)); - $this->name = $io->askQuestion($question); + $this->name = $this->io->askQuestion($question); + while (!$this->verifyWebhookName($this->name)) { - $io->error('A webhook name can only have alphanumeric characters, underscores, dots, and dashes.'); - $this->name = $io->askQuestion($question); + $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'); } } @@ -139,7 +157,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $this->addToYamlConfig($this->name, $requestParserDetails); - $this->generateRequestParser($io, $requestParserDetails); + $this->generateRequestParser(requestParserDetails: $requestParserDetails); $this->generator->generateClass( $remoteEventConsumerDetails->getFullName(), @@ -151,6 +169,8 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $this->generator->writeChanges(); $this->fileManager->dumpFile(self::WEBHOOK_CONFIG_PATH, $this->ysm->getContents()); + + $this->writeSuccessMessage($io); } private function verifyWebhookName(string $entityName): bool @@ -184,7 +204,7 @@ private function addToYamlConfig(string $webhookName, ClassNameDetails $requestP /** * @throws \Exception */ - private function generateRequestParser(ConsoleStyle $io, ClassNameDetails $requestParserDetails): void + private function generateRequestParser(ClassNameDetails $requestParserDetails): void { $useStatements = new UseStatementGenerator([ JsonException::class, @@ -196,30 +216,21 @@ private function generateRequestParser(ConsoleStyle $io, ClassNameDetails $reque RequestMatcherInterface::class, ]); - $requestMatchers = []; - while (true) { - $newRequestMatcher = $this->askForNextRequestMatcher($io, $requestMatchers, $requestParserDetails->getFullName(), empty($requestMatchers)); - if (null === $newRequestMatcher) { - break; - } - $requestMatchers[] = $newRequestMatcher; - } - // Use a ChainRequestMatcher if multiple matchers have been added OR if none (will be printed with an empty array) $useChainRequestsMatcher = false; - if (1 !== \count($requestMatchers)) { + + if (1 !== \count($this->requestMatchers)) { $useChainRequestsMatcher = true; $useStatements->addUseStatement(ChainRequestMatcher::class); } $requestMatcherArguments = []; - foreach ($requestMatchers as $requestMatcherClass) { + + foreach ($this->requestMatchers as $requestMatcherClass) { $useStatements->addUseStatement($requestMatcherClass); - $requestMatcherArguments[$requestMatcherClass] = $this->getRequestMatcherArguments($requestMatcherClass); + $requestMatcherArguments[$requestMatcherClass] = $this->getRequestMatcherArguments(requestMatcherClass: $requestMatcherClass); + if (ExpressionRequestMatcher::class === $requestMatcherClass) { - if (!class_exists(Expression::class)) { - throw new \Exception('The ExpressionRequestMatcher requires the symfony/expression-language package.'); - } $useStatements->addUseStatement(Expression::class); $useStatements->addUseStatement(ExpressionLanguage::class); } @@ -231,17 +242,19 @@ private function generateRequestParser(ConsoleStyle $io, ClassNameDetails $reque [ 'use_statements' => $useStatements, 'use_chained_requests_matcher' => $useChainRequestsMatcher, - 'request_matchers' => $requestMatchers, + 'request_matchers' => $this->requestMatchers, 'request_matcher_arguments' => $requestMatcherArguments, ] ); } - private function askForNextRequestMatcher(ConsoleStyle $io, array $addedMatchers, string $entityClass, bool $isFirstMatcher): ?string + private function askForNextRequestMatcher(bool $isFirstMatcher): ?string { - $io->writeln(''); + $this->io->newLine(); + $availableMatchers = $this->getAvailableRequestMatchers(); $matcherName = null; + while (null === $matcherName) { if ($isFirstMatcher) { $questionText = 'Add a RequestMatcher (press to skip this step)'; @@ -249,9 +262,9 @@ private function askForNextRequestMatcher(ConsoleStyle $io, array $addedMatchers $questionText = 'Add another RequestMatcher? Enter the RequestMatcher name (or press to stop adding matchers)'; } - $choices = array_diff($availableMatchers, $addedMatchers); + $choices = array_diff($availableMatchers, $this->requestMatchers); $question = new ChoiceQuestion($questionText, array_values([''] + $choices), 0); - $matcherName = $io->askQuestion($question); + $matcherName = $this->io->askQuestion($question); if ('' === $matcherName) { return null; @@ -261,6 +274,7 @@ private function askForNextRequestMatcher(ConsoleStyle $io, array $addedMatchers return $matcherName; } + /** @return string[] */ private function getAvailableRequestMatchers(): array { return [ @@ -276,7 +290,7 @@ private function getAvailableRequestMatchers(): array ]; } - private function getRequestMatcherArguments(string $requestMatcherClass) + private function getRequestMatcherArguments(string $requestMatcherClass): string { return match ($requestMatcherClass) { AttributesRequestMatcher::class => '[\'attributeName\' => \'regex\']', diff --git a/tests/Maker/MakeWebhookTest.php b/tests/Maker/MakeWebhookTest.php index 830386ab3..f9bc6b05e 100644 --- a/tests/Maker/MakeWebhookTest.php +++ b/tests/Maker/MakeWebhookTest.php @@ -27,27 +27,34 @@ public function getTestDetails(): \Generator yield 'it_makes_webhook_with_no_prior_config_file' => [$this->createMakerTest() ->run(function (MakerTestRunner $runner) { $output = $runner->runMaker([ - // webhook name - 'remote_service', - // skip adding matchers - '', + 'remote_service', // webhook name + '', // skip adding matchers ]); - $this->assertStringContainsString('created:', $output); - $this->assertFileExists($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); - $this->assertStringContainsString( - 'use Symfony\Component\Webhook\Client\AbstractRequestParser;', - file_get_contents($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')) - ); - $this->assertFileExists($runner->getPath('src/RemoteEvent/RemoteServiceWebhookConsumer.php')); - $this->assertStringContainsString( - '#[AsRemoteEventConsumer(\'remote_service\')]', - file_get_contents($runner->getPath('src/RemoteEvent/RemoteServiceWebhookConsumer.php')), - ); + + $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'] @@ -56,36 +63,57 @@ public function getTestDetails(): \Generator ]; 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([ - // webhook name - 'another_remote_service', - // skip adding matchers - '', + 'another_remote_service', // webhook name + '', // skip adding matchers ]); - $this->assertStringContainsString('created:', $output); - $this->assertFileExists($runner->getPath('src/Webhook/AnotherRemoteServiceRequestParser.php')); - $this->assertFileExists($runner->getPath('src/RemoteEvent/AnotherRemoteServiceWebhookConsumer.php')); + + $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'] @@ -96,22 +124,30 @@ public function getTestDetails(): \Generator yield 'it_makes_webhook_with_single_matcher' => [$this->createMakerTest() ->run(function (MakerTestRunner $runner) { $output = $runner->runMaker([ - // webhook name - 'remote_service', - // add a matcher - 'Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher', + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', ]); - $this->assertStringContainsString('created:', $output); - $this->assertFileExists($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); - $this->assertFileExists($runner->getPath('src/RemoteEvent/RemoteServiceWebhookConsumer.php')); - $requestParserSource = file_get_contents($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); - $this->assertStringContainsString( - 'use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;', - $requestParserSource - ); + + $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();', - $requestParserSource + file_get_contents($runner->getPath($parserFileName)) ); }), ]; @@ -119,28 +155,45 @@ public function getTestDetails(): \Generator yield 'it_makes_webhook_with_multiple_matchers' => [$this->createMakerTest() ->run(function (MakerTestRunner $runner) { $output = $runner->runMaker([ - // webhook name - 'remote_service', - // add matchers - 'Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher', - 'Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher', + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', + '6', // 'PortRequestMatcher', ]); - $this->assertStringContainsString('created:', $output); - $this->assertFileExists($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); - $this->assertFileExists($runner->getPath('src/RemoteEvent/RemoteServiceWebhookConsumer.php')); - $requestParserSource = file_get_contents($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); + + $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( <<addExtraDependencies('symfony/expression-language') ->run(function (MakerTestRunner $runner) { $output = $runner->runMaker([ - // webhook name - 'remote_service', - // add matchers - 'Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher', - 'Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher', + 'remote_service', // webhook name + '4', // 'IsJsonRequestMatcher', + '1', // 'ExpressionRequestMatcher', ]); - $this->assertStringContainsString('created:', $output); - $this->assertFileExists($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); - $this->assertFileExists($runner->getPath('src/RemoteEvent/RemoteServiceWebhookConsumer.php')); - $requestParserSource = file_get_contents($runner->getPath('src/Webhook/RemoteServiceRequestParser.php')); + + $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( <<