Skip to content

Commit

Permalink
Add TemplateDirectoryCompilerPass
Browse files Browse the repository at this point in the history
Resolves #3
  • Loading branch information
benr77 committed Jun 28, 2024
1 parent 910bd0a commit 0f5e3cc
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 24 deletions.
50 changes: 38 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ return [
## Features

- [Apply rate limiters using attributes](#apply-rate-limiters-using-attributes)
- [Store Twig templates next to production code](#store-twig-templates-next-to-production-code)
- [Use empty strings by default on text-based form fields](#empty-string-default-for-text-based-form-fields)
- [Set various attributes on <form> elements](#set-attributes-on-form-elements)

Expand All @@ -45,11 +46,11 @@ First, define the rate limiter that you want to use:
# config/packages/framework.yaml

framework:
rate_limiter:
anonymous_api:
policy: 'sliding_window'
limit: 100
interval: '60 seconds'
rate_limiter:
anonymous_api:
policy: 'sliding_window'
limit: 100
interval: '60 seconds'
```
Then add the annotation to the controller you want to protect, specifying the name of the rate limiter in the attribute:
Expand Down Expand Up @@ -78,12 +79,37 @@ This can be disabled in the bundle configuration if desired:

```yaml
headsnet_symfony_tools:
rate_limiting:
use_headers: false
rate_limiting:
use_headers: false
```
Thanks to [this JoliCode article](https://jolicode.com/blog/rate-limit-your-symfony-apis) for the inspiration!
### Store Twig templates next to production code
As [discussed on our blog](https://headsnet.com/blog/move-templates-closer-to-the-code), often it is desirable to store
related things together (cohesion). You may want to apply to this your Twig templates.
This bundle provides a compiler pass that will search for multiple Twig `tpl` directories and add them to the Twig
configuration automatically.

This behaviour must be enabled in the configuration:

```yaml
headsnet_symfony_tools:
twig:
import_feature_dirs:
base_dir: 'src/'
separator: '->'
tpl_dir_name: tpl
```
You can then refer to your Twig templates that live in your production code directories using the following syntax:

```
@SendRegistrationEmail/hello.html.twig
@Billing->Invoicing->Create/invoice.html.twig
```

### Empty string default for text-based form fields

By default Symfony uses `null` as the default value for text-based form fields. This results in `null` values being all
Expand All @@ -97,8 +123,8 @@ This is an opinionated solution, so must be enabled in the bundle configuration:

```yaml
headsnet_symfony_tools:
forms:
default_empty_string: true
forms:
default_empty_string: true
```

### Set attributes on <form> elements
Expand All @@ -109,9 +135,9 @@ These must be explicitly enabled in the configuration.

```yaml
headsnet_symfony_tools:
forms:
disable_autocomplete: true # autocomplete="off"
disable_validation: true # novalidate="novalidate"
forms:
disable_autocomplete: true # autocomplete="off"
disable_validation: true # novalidate="novalidate"
```

## License
Expand Down
48 changes: 39 additions & 9 deletions src/HeadsnetSymfonyToolsBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Headsnet\SymfonyToolsBundle\Form\Extension\FormAttributesExtension;
use Headsnet\SymfonyToolsBundle\Form\Extension\TextTypeDefaultStringExtension;
use Headsnet\SymfonyToolsBundle\RateLimiting\RateLimitingCompilerPass;
use Headsnet\SymfonyToolsBundle\Twig\TemplateDirectoryCompilerPass;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
Expand All @@ -22,14 +23,27 @@ public function configure(DefinitionConfigurator $definition): void
->booleanNode('use_headers')->end()
->end()
->end() // End rate_limiting
->arrayNode('forms')
->addDefaultsIfNotSet()
->children()
->booleanNode('default_empty_string')->defaultFalse()->end()
->booleanNode('disable_autocomplete')->defaultFalse()->end()
->booleanNode('disable_validation')->defaultFalse()->end()
->end()
->end() // End forms
->arrayNode('forms')
->addDefaultsIfNotSet()
->children()
->booleanNode('default_empty_string')->defaultFalse()->end()
->booleanNode('disable_autocomplete')->defaultFalse()->end()
->booleanNode('disable_validation')->defaultFalse()->end()
->end()
->end() // End forms
->arrayNode('twig')
->addDefaultsIfNotSet()
->children()
->arrayNode('import_feature_dirs')
->canBeEnabled()
->children()
->scalarNode('base_dir')->defaultValue('')->end()
->scalarNode('separator')->defaultValue('->')->end()
->scalarNode('tpl_dir_name')->defaultValue('tpl')->end()
->end()
->end() // End import_feature_dirs
->end()
->end() // End twig
->end()
;
}
Expand All @@ -42,7 +56,16 @@ public function configure(DefinitionConfigurator $definition): void
* disable_autocomplete: bool,
* disable_validation: bool,
* },
* rate_limiting: array{use_headers: bool}
* rate_limiting: array{
* use_headers: bool
* },
* twig: array{
* import_feature_dirs: array{
* base_dir: string,
* separator: string,
* tpl_dir_name: string,
* }
* }
* } $config
*/
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
Expand All @@ -52,6 +75,9 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$container->parameters()
->set('headsnet_symfony_tools.root_namespace', $config['root_namespace'])
->set('headsnet_symfony_tools.rate_limiting.use_headers', $config['rate_limiting']['use_headers'])
->set('headsnet_symfony_tools.twig.import_feature_dirs.base_dir', $config['twig']['import_feature_dirs']['base_dir'])
->set('headsnet_symfony_tools.twig.import_feature_dirs.separator', $config['twig']['import_feature_dirs']['separator'])
->set('headsnet_symfony_tools.twig.import_feature_dirs.tpl_dir_name', $config['twig']['import_feature_dirs']['tpl_dir_name'])
;

if ($config['forms']['default_empty_string']) {
Expand Down Expand Up @@ -82,5 +108,9 @@ public function build(ContainerBuilder $container): void
$container->addCompilerPass(
new RateLimitingCompilerPass()
);

$container->addCompilerPass(
new TemplateDirectoryCompilerPass()
);
}
}
76 changes: 76 additions & 0 deletions src/Twig/TemplateDirectoryCompilerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);

namespace Headsnet\SymfonyToolsBundle\Twig;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Finder\Finder;

/**
* Locate all "tpl" directories inside the $baseDir directory, and
* add them to the Twig configuration as namespaced paths.
*
* A template such as "src/UI/Feature/Foo/Bar/tpl/test.twig.html" will
* be added with namespace "Foo->Bar", and therefore can be referenced
* using "@Foo->Bar/test.twig.html".
*/
final class TemplateDirectoryCompilerPass implements CompilerPassInterface
{
#[\Override]
public function process(ContainerBuilder $container): void
{
$baseDir = $this->getBundleParam($container, 'headsnet_symfony_tools.twig.import_feature_dirs.base_dir');

if (!strlen($baseDir)) {
return;
}

if ($container->hasDefinition('twig.loader.native_filesystem')) {
$twigFilesystemLoaderDefinition = $container->getDefinition('twig.loader.native_filesystem');
/** @var string $projectDir */
$projectDir = $container->getParameter('kernel.project_dir');
$separator = $this->getBundleParam($container, 'headsnet_symfony_tools.twig.import_feature_dirs.separator');
$tplDirName = $this->getBundleParam($container, 'headsnet_symfony_tools.twig.import_feature_dirs.tpl_dir_name');
$featureDir = sprintf('%s/%s', $projectDir, $baseDir);

foreach ($this->tplDirPaths($featureDir, $tplDirName) as $file) {
$tplDirToAdd = str_replace($projectDir . '/', '', $file->getPathname());

$namespaceToAdd = str_replace(
[rtrim($baseDir, '/') . '/', '/' . trim($tplDirName, '/'), '/'],
['', '', $separator],
$tplDirToAdd
);

$twigFilesystemLoaderDefinition->addMethodCall('addPath', [$tplDirToAdd, $namespaceToAdd]);
}
}
}

private function tplDirPaths(string $featureDir, string $tplDirName): Finder
{
$finder = new Finder();
$finder->directories()
->in($featureDir)
->name($tplDirName)
->sortByName()
;

return $finder;
}

/**
* Slightly convoluted method to obtain the bundle configuration, as the parameters
* do not seem to be ready in the container if we access them directly.
*/
private function getBundleParam(ContainerBuilder $container, string $paramName): string
{
/** @var string $parameter */
$parameter = $container->getParameterBag()->resolveValue(
$container->getParameter($paramName)
);

return $parameter;
}
}
11 changes: 8 additions & 3 deletions tests/Fixtures/config.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
headsnet_symfony_tools:
root_namespace: App
forms:
default_empty_string: true
disable_validation: true
disable_autocomplete: true
default_empty_string: true
disable_validation: true
disable_autocomplete: true
rate_limiting:
use_headers: true
twig:
import_feature_dirs:
base_dir: src/UI/Feature
separator: '->'
tpl_dir_name: tpl

Empty file.
Empty file.
59 changes: 59 additions & 0 deletions tests/Twig/TemplateDirectoryCompilerPassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Headsnet\SymfonyToolsBundle\Tests\Twig;

use Headsnet\SymfonyToolsBundle\Twig\TemplateDirectoryCompilerPass;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;

#[CoversClass(TemplateDirectoryCompilerPass::class)]
class TemplateDirectoryCompilerPassTest extends TestCase
{
private TemplateDirectoryCompilerPass $compilerPass;

private ContainerBuilder $container;

#[\Override]
protected function setUp(): void
{
$this->compilerPass = new TemplateDirectoryCompilerPass();
$this->container = new ContainerBuilder(new ParameterBag([
'headsnet_symfony_tools.twig.import_feature_dirs.base_dir' => 'tests/Twig/Fixtures/',
'headsnet_symfony_tools.twig.import_feature_dirs.separator' => '->',
'headsnet_symfony_tools.twig.import_feature_dirs.tpl_dir_name' => 'tpl',
]));
$this->container->setParameter('kernel.project_dir', '.');

$twigFilesystemLoaderDefinition = new Definition();
$this->container->setDefinition('twig.loader.native_filesystem', $twigFilesystemLoaderDefinition);
}

#[Test]
public function process_with_duration_file_paths(): void
{
$this->compilerPass->process($this->container);

$definition = $this->container->getDefinition('twig.loader.native_filesystem');
$calls = $definition->getMethodCalls();
$this->assertCount(2, $calls);

$expected = [
['addPath', ['tests/Twig/Fixtures/FeatureA/tpl', 'FeatureA']],
['addPath', ['tests/Twig/Fixtures/FeatureB/tpl', 'FeatureB']],
];

$this->assertSame($expected, $calls);
}

#[Test]
public function process_with_nonexistent_definition(): void
{
$this->container->removeDefinition('twig.loader.native_filesystem');
$this->compilerPass->process($this->container);
$this->assertFalse($this->container->hasDefinition('twig.loader.native_filesystem'));
}
}

0 comments on commit 0f5e3cc

Please sign in to comment.