-
-
Notifications
You must be signed in to change notification settings - Fork 412
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature #1491 [make:webhook] Add new command for Symfony's Webhook Co…
…mponent Co-authored-by: Jesse Rushlow <[email protected]>
- Loading branch information
1 parent
30cdf17
commit 35ef9e0
Showing
13 changed files
with
810 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony MakerBundle package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,307 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony MakerBundle package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
* | ||
* @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<class-string> */ | ||
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. <fg=yellow>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 <return> to skip this step)'; | ||
} else { | ||
$questionText = 'Add another RequestMatcher? Enter the RequestMatcher name (or press <return> to stop adding matchers)'; | ||
} | ||
|
||
$choices = array_diff($availableMatchers, $this->requestMatchers); | ||
$question = new ChoiceQuestion($questionText, array_values(['<skip>'] + $choices), 0); | ||
$matcherName = $this->io->askQuestion($question); | ||
|
||
if ('<skip>' === $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 => '[]', | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
The <info>%command.name%</info> command creates a RequestParser, a WebhookHandler and adds the necessary configuration | ||
for a new Webhook. | ||
|
||
<info>php %command.full_name% stripe</info> | ||
|
||
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. |
Oops, something went wrong.