Skip to content

Commit

Permalink
feature #1491 [make:webhook] Add new command for Symfony's Webhook Co…
Browse files Browse the repository at this point in the history
…mponent

Co-authored-by: Jesse Rushlow <jr@rushlow.dev>
  • Loading branch information
maelanleborgne and jrushlow committed Apr 6, 2024
1 parent 30cdf17 commit 35ef9e0
Show file tree
Hide file tree
Showing 13 changed files with 810 additions and 2 deletions.
37 changes: 37 additions & 0 deletions src/Maker/Common/InstallDependencyTrait.php
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 <fabien@symfony.com>
*
* 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;
}
}
307 changes: 307 additions & 0 deletions src/Maker/MakeWebhook.php
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 <fabien@symfony.com>
*
* 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 <maelan.leborgne@gmail.com>
*
* @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 => '[]',
};
}
}
6 changes: 6 additions & 0 deletions src/Resources/config/makers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,11 @@
<argument type="service" id="maker.security_controller_builder" />
<tag name="maker.command" />
</service>

<service id="maker.maker.make_webhook" class="Symfony\Bundle\MakerBundle\Maker\MakeWebhook">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
<tag name="maker.command" />
</service>
</services>
</container>
8 changes: 8 additions & 0 deletions src/Resources/help/MakeWebhook.txt
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.

0 comments on commit 35ef9e0

Please sign in to comment.