Skip to content

Commit

Permalink
Introduce the new v2 extension system
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed May 25, 2023
1 parent 4a1235d commit b40695a
Show file tree
Hide file tree
Showing 10 changed files with 210 additions and 18 deletions.
39 changes: 31 additions & 8 deletions doc/extensions.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Extensions

You will probably have some custom tasks or event listeners that are not included in the default GrumPHP project.
You might have [a custom tasks](tasks.md#creating-a-custom-task)
or [event listeners](runner.md#events) that is not included in the default GrumPHP project.
It is possible to group this additional GrumPHP configuration in an extension.
This way you can easily create your own extension package and load it whenever you need it.
This way you can centralize this custom logic in your own extension package and load it wherever you need it.

The configuration looks like this:

Expand All @@ -13,9 +14,15 @@ grumphp:
- My\Project\GrumPHPExtension
```

The configured extension class needs to implement `ExtensionInterface`.
Now you can register the tasks and events from your own package in the service container of GrumPHP.
For example:
The configured extension class needs to implement `GrumPHP\Extension\ExtensionInterface`.
Since GrumPHP is using the [symfony/container](https://symfony.com/doc/current/service_container.html) internally to configure all resources,
a GrumPHP extension can append multiple configuration files to the container configuration.

We support following loaders: YAML, XML, INI, GLOB, DIR.
*Note:* We don't support the PHP or CLOSURE loaders to make sure your extension is compatible with our grumphp-shim PHAR distribution.
All dependencies get scoped with a random prefix in the PHAR, making these loaders not usable in there.

Example extension:

```php
<?php
Expand All @@ -24,15 +31,31 @@ namespace My\Project;
use GrumPHP\Extension\ExtensionInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class GrumPHPExtension implements ExtensionInterface
class MyAwesomeGrumPHPExtension implements ExtensionInterface
{
public function load(ContainerBuilder $container)
public function imports(): iterable
{
// Register your own stuff to the container!
$configDir = dirname(__DIR).'/config';

yield $configDir.'/my-extension.yaml';
yield $configDir.'/my-extension.xml';
yield $configDir.'/my-extension.ini';
yield $configDir.'/my-extension/*';
}
}
```

Example config file in which you enable a custom task:

```yaml
# my-extension.yaml
services:
My\CustomTask:
arguments: []
tags:
- {name: grumphp.task, task: myCustomTask}
```

# Third Party Extensions

This page lists third party extensions implementing useful GrumPHP tasks.
Expand Down
6 changes: 5 additions & 1 deletion src/Configuration/Compiler/ExtensionCompilerPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace GrumPHP\Configuration\Compiler;

use GrumPHP\Configuration\LoaderFactory;
use GrumPHP\Exception\RuntimeException;
use GrumPHP\Extension\ExtensionInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
Expand All @@ -13,6 +14,7 @@ class ExtensionCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$loader = LoaderFactory::createLoader($container);
$extensions = $container->getParameter('extensions');
$extensions = \is_array($extensions) ? $extensions : [];
foreach ($extensions as $extensionClass) {
Expand All @@ -28,7 +30,9 @@ public function process(ContainerBuilder $container): void
));
}

$extension->load($container);
foreach ($extension->imports() as $import) {
$loader->load($import);
}
}
}
}
4 changes: 1 addition & 3 deletions src/Configuration/ContainerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@
namespace GrumPHP\Configuration;

use GrumPHP\Util\Filesystem;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
use Symfony\Component\DependencyInjection\ContainerBuilder as SymfonyContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

final class ContainerBuilder
{
Expand All @@ -30,7 +28,7 @@ public static function buildFromConfiguration(string $path): SymfonyContainerBui

// Load basic service file + custom user configuration
$configDir = dirname(__DIR__, 2).$filesystem->ensureValidSlashes('/resources/config');
$loader = new YamlFileLoader($container, new FileLocator($configDir));
$loader = LoaderFactory::createLoader($container, [$configDir]);
$loader->load('config.yml');
$loader->load('console.yml');
$loader->load('fixer.yml');
Expand Down
36 changes: 36 additions & 0 deletions src/Configuration/LoaderFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);

namespace GrumPHP\Configuration;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\Config\Loader\DelegatingLoader;
use Symfony\Component\Config\Loader\LoaderResolver;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\DirectoryLoader;
use Symfony\Component\DependencyInjection\Loader\GlobFileLoader;
use Symfony\Component\DependencyInjection\Loader\IniFileLoader;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

final class LoaderFactory
{
private const ENV = 'grumphp';

/**
* @param list<string> $paths
*/
public static function createLoader(ContainerBuilder $container, array $paths = []): DelegatingLoader
{
$locator = new FileLocator($paths);
$resolver = new LoaderResolver([
new XmlFileLoader($container, $locator, self::ENV),
new YamlFileLoader($container, $locator, self::ENV),
new IniFileLoader($container, $locator, self::ENV),
new GlobFileLoader($container, $locator, self::ENV),
new DirectoryLoader($container, $locator, self::ENV),
]);

return new DelegatingLoader($resolver);
}
}
18 changes: 13 additions & 5 deletions src/Extension/ExtensionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@

namespace GrumPHP\Extension;

use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Interface ExtensionInterface is used for GrumPHP extensions to interface
* with GrumPHP through the service container.
* Registers your own GrumPHP.
*/
interface ExtensionInterface
{
public function load(ContainerBuilder $container): void;
/**
* Return a list of additional symfony/conso:e service imports that
* GrumPHP needs to perform after loading all internal configurations.
*
* We support following loaders: YAML, XML, INI, GLOB, DIR
*
* More info
* @link https://symfony.com/doc/current/service_container.html
*
* @return iterable<string>
*/
public function imports(): iterable;
}
30 changes: 29 additions & 1 deletion test/E2E/AbstractE2ETestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ private function detectCurrentGrumphpGitBranchForComposerWithFallback(): string
$version = trim($process->getOutput());
}

return 'dev-'.$version;
return 'dev-'.$version.'@dev';
}

protected function mergeComposerConfig(string $composerFile, array $config, $recursive = true)
Expand Down Expand Up @@ -295,6 +295,34 @@ protected function enableValidatePathsTask(string $grumphpFile, string $projectD
]);
}

protected function enableCustomExtension(string $grumphpFile, string $projectDir)
{
$e2eDir = $this->ensureGrumphpE2eTasksDir($projectDir);
$this->dumpFile(
$e2eDir.'/SuccessfulTask.php',
file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/tasks/SuccessfulTask.php')
);
$this->dumpFile(
$e2eDir.'/ValidateExtension.php',
file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/extension/ValidateExtension.php')
);
$this->dumpFile(
$e2eDir.'/extension.yaml',
file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/extension/extension.yaml')
);

$this->mergeGrumphpConfig($grumphpFile, [
'grumphp' => [
'extensions' => [
\GrumPHPE2E\ValidateExtension::class,
],
'tasks' => [
'success' => [],
],
],
]);
}

protected function installComposer(string $path, array $arguments = [])
{
$process = new Process(
Expand Down
23 changes: 23 additions & 0 deletions test/E2E/ExtensionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace GrumPHPTest\E2E;

class ExtensionsTest extends AbstractE2ETestCase
{
/** @test */
function it_can_configure_an_extension()
{
$this->initializeGitInRootDir();
$this->initializeComposer($this->rootDir);
$grumphpFile = $this->initializeGrumphpConfig($this->rootDir);
$this->installComposer($this->rootDir);
$this->ensureHooksExist();

$this->enableCustomExtension($grumphpFile, $this->rootDir);

$this->commitAll();
$this->runGrumphp($this->rootDir);
}
}
14 changes: 14 additions & 0 deletions test/fixtures/e2e/extension/ValidateExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);

namespace GrumPHPE2E;

use GrumPHP\Extension\ExtensionInterface;

class ValidateExtension implements ExtensionInterface
{
public function imports(): iterable
{
yield __DIR__.DIRECTORY_SEPARATOR.'extension.yaml';
}
}
6 changes: 6 additions & 0 deletions test/fixtures/e2e/extension/extension.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:
GrumPHPE2E\SuccessfulTask:
class: GrumPHPE2E\SuccessfulTask
arguments: []
tags:
- { name: grumphp.task, task: success }
52 changes: 52 additions & 0 deletions test/fixtures/e2e/tasks/SuccessfulTask.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
namespace GrumPHPE2E;

use GrumPHP\Runner\TaskResult;
use GrumPHP\Runner\TaskResultInterface;
use GrumPHP\Task\Config\ConfigOptionsResolver;
use GrumPHP\Task\Config\EmptyTaskConfig;
use GrumPHP\Task\Config\TaskConfigInterface;
use GrumPHP\Task\Context\ContextInterface;
use GrumPHP\Task\TaskInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SuccessfulTask implements TaskInterface
{
/**
* @var TaskConfigInterface
*/
private $config;

public function __construct()
{
$this->config = new EmptyTaskConfig();
}

public function getConfig(): TaskConfigInterface
{
return $this->config;
}

public function withConfig(TaskConfigInterface $config): TaskInterface
{
$new = clone $this;
$new->config = $config;

return $new;
}

public static function getConfigurableOptions(): ConfigOptionsResolver
{
return ConfigOptionsResolver::fromOptionsResolver(new OptionsResolver());
}

public function canRunInContext(ContextInterface $context): bool
{
return true;
}

public function run(ContextInterface $context): TaskResultInterface
{
return TaskResult::createPassed($this, $context);
}
}

0 comments on commit b40695a

Please sign in to comment.