Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
name: PHP ${{ matrix.php-versions }}
Expand Down Expand Up @@ -65,3 +66,52 @@ jobs:

- name: Run test suite
run: composer run-script tests

bdf_form_compatibility:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
form-versions:
- package: '1.0'
php: '8.0'
- package: '1.1'
php: '8.0'
- package: '1.2'
php: '8.0'
- package: '1.3'
php: '8.0'
- package: '1.4'
php: '8.0'
- package: '1.6'
php: '8.0'
- package: '1.7'
php: '8.4'
- package: '2.0'
php: '8.4'
name: Compatibility with b2pweb/bdf-form ${{ matrix.form-versions.package }}

steps:
- uses: actions/checkout@v2

- name: Set Timezone
uses: szenius/set-timezone@v1.0
with:
timezoneLinux: "Europe/Paris"

- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.form-versions.php }}
extensions: json
ini-values: date.timezone=Europe/Paris

- name: Set bdf-form version
run: |
sed -i "s/\"b2pweb\/bdf-form\": \".*\"/\"b2pweb\/bdf-form\": \"~${{ matrix.form-versions.package }}.0\"/g" composer.json

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Run test suite
run: composer run-script tests
8 changes: 5 additions & 3 deletions Attribute/GeneratedConfiguratorResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Bdf\Form\Bundle\Attribute;

use Bdf\Form\Attribute\AttributeForm;
use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy;

/**
Expand Down Expand Up @@ -40,9 +39,12 @@ public function __construct(string $prefix, string $suffix, string $basePath)
* Prefix and suffix will be added on the form class name
* In case of anonymous class, all forbidden chars will be removed to generate a correct class name
*/
public function resolveClassName(AttributeForm $form): string
public function resolveClassName($formClass): string
{
$formClass = get_class($form);
if (\is_object($formClass)) {
$formClass = \get_class($formClass);
}

$parts = preg_split('#[^a-z\\\\]#i', $formClass);

if (count($parts) > 1) {
Expand Down
2 changes: 1 addition & 1 deletion DependencyInjection/Compiler/CompileAttributeForms.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
class CompileAttributeForms implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
public function process(ContainerBuilder $container): void
{
/** @var CompileAttributesProcessor $compiler */
$compiler = $container->get(CompileAttributesProcessor::class);
Expand Down
2 changes: 1 addition & 1 deletion DependencyInjection/Compiler/RegisterCustomBuilders.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
class RegisterCustomBuilders implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
public function process(ContainerBuilder $container): void
{
$registry = $container->findDefinition(SymfonyRegistry::class);

Expand Down
2 changes: 1 addition & 1 deletion DependencyInjection/Compiler/RegisterCustomForms.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
class RegisterCustomForms implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
public function process(ContainerBuilder $container): void
{
foreach ($container->findTaggedServiceIds('form.custom_form') as $id => $tags) {
$container->findDefinition($id)
Expand Down
112 changes: 112 additions & 0 deletions DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace Bdf\Form\Bundle\DependencyInjection\Compiler;

use Bdf\Form\Bundle\Http\Submit\SubmitForm;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Remove controller argument service-locators generated for parameters marked with a {@see SubmitForm} attribute.
*
* When a controller argument is type-hinted with a form value (e.g. a DTO/struct handled by SubmitFormValueResolver),
* Symfony's RegisterControllerArgumentLocatorsPass still registers a reference to that type into the controller
* argument locator, so ServiceValueResolver may inject it from the container. That reference is never used at runtime
* because SubmitFormValueResolver provides the value first, but it keeps the type alive as a container service.
*
* When the type is registered as an autowired but non-instantiable service (typical for a DTO with scalar promoted
* properties), this has two consequences depending on the parameter nullability:
* - non-nullable: the reference uses RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, so the autowiring error is deferred to
* runtime (and never triggered) -> the container compiles;
* - nullable: the reference uses IGNORE_ON_INVALID_REFERENCE, which DefinitionErrorExceptionPass treats as a
* compile-time error -> the container fails to build with "Cannot autowire service ...".
*
* This pass drops those references so the behaviour is consistent (and correct) regardless of nullability: the value
* is always provided by SubmitFormValueResolver, never by the container.
*/
class RemoveSubmitFormArgumentLocators implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('argument_resolver.controller_locator') && !$container->hasAlias('argument_resolver.controller_locator')) {
return;
}

$controllerLocator = $container->findDefinition('argument_resolver.controller_locator');
$controllers = $controllerLocator->getArgument(0);

foreach ($controllers as $controller => $argument) {
$names = $this->submitFormArgumentNames($container, (string) $controller);

if (!$names) {
continue;
}

// $argument is a ServiceClosureArgument wrapping a Reference to the per-method locator
$reference = $argument instanceof ServiceClosureArgument ? $argument->getValues()[0] : $argument;
$argumentLocator = $container->getDefinition((string) $reference);

// per-consumer locators are derived from a shared prototype through a "withContext" cloning factory
if ($argumentLocator->getFactory()) {
$argumentLocator = $container->getDefinition((string) $argumentLocator->getFactory()[0]);
}

$services = $argumentLocator->getArgument(0);
$changed = false;

foreach ($names as $name) {
if (isset($services[$name])) {
unset($services[$name]);
$changed = true;
}
}

if ($changed) {
$argumentLocator->replaceArgument(0, $services);
}
}
}

/**
* Get the parameter names of the given controller that are marked with a SubmitForm attribute.
*
* @param string $controller The controller identifier, formatted as "serviceId::method"
*
* @return list<string>
*/
private function submitFormArgumentNames(ContainerBuilder $container, string $controller): array
{
if (!str_contains($controller, '::')) {
return [];
}

[$serviceId, $method] = explode('::', $controller, 2);

if (!$container->hasDefinition($serviceId) && !$container->hasAlias($serviceId)) {
return [];
}

$class = $container->findDefinition($serviceId)->getClass();

if (!$class || !method_exists($class, $method)) {
return [];
}

try {
$parameters = (new \ReflectionMethod($class, $method))->getParameters();
} catch (\ReflectionException) {
return [];
}

$names = [];

foreach ($parameters as $parameter) {
if ($parameter->getAttributes(SubmitForm::class, \ReflectionAttribute::IS_INSTANCEOF)) {
$names[] = $parameter->name;
}
}

return $names;
}
}
2 changes: 1 addition & 1 deletion DependencyInjection/Compiler/UseCsrfTokenManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
class UseCsrfTokenManager implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('security.csrf.token_manager')) {
$container->findDefinition(CsrfElementBuilder::class)
Expand Down
36 changes: 35 additions & 1 deletion DependencyInjection/FormExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@
use Bdf\Form\Attribute\Processor\CompileAttributesProcessor;
use Bdf\Form\Attribute\Processor\ReflectionProcessor;
use Bdf\Form\Bundle\Attribute\GeneratedConfiguratorResolver;
use Bdf\Form\Bundle\Registry\SymfonyRegistry;
use Bdf\Form\Custom\CustomForm;
use Bdf\Form\ElementBuilderInterface;
use Bdf\Form\Struct\StructAttributesProcessorFactory;
use Bdf\Form\Struct\StructForm;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\DependencyInjection\Reference;

class FormExtension extends Extension
{
use PriorityTaggedServiceTrait;

public function load(array $configs, ContainerBuilder $container)
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('form.yaml');
Expand Down Expand Up @@ -52,6 +57,10 @@ public function load(array $configs, ContainerBuilder $container)
if (class_exists(AttributeForm::class)) {
$this->configureAttributes($container, $config['attributes']);
}

if (class_exists(StructForm::class)) {
$this->configureStructForm($container, $config['attributes']);
}
}

/**
Expand All @@ -78,4 +87,29 @@ private function configureAttributes(ContainerBuilder $container, array $config)
$container->setAlias(AttributesProcessorInterface::class, ReflectionProcessor::class);
}
}

private function configureStructForm(ContainerBuilder $container, array $config): void
{
$container->register(StructAttributesProcessorFactory::class);

if ($config['compile']) {
$container->register(SymfonyRegistry::STRUCT_FORM_PROCESSOR_SERVICE_ID, AttributesProcessorInterface::class)
->setFactory([new Reference(StructAttributesProcessorFactory::class), 'generated'])
->setArguments([
(new Definition(\Closure::class))
->setFactory([\Closure::class, 'fromCallable'])
->setArgument(0, [new Reference(GeneratedConfiguratorResolver::class), 'resolveClassName']),
(new Definition(\Closure::class))
->setFactory([\Closure::class, 'fromCallable'])
->setArgument(0, [new Reference(GeneratedConfiguratorResolver::class), 'resolveFilename']),
])
->setPublic(true)
;
} else {
$container->register(SymfonyRegistry::STRUCT_FORM_PROCESSOR_SERVICE_ID, AttributesProcessorInterface::class)
->setFactory([new Reference(StructAttributesProcessorFactory::class), 'runtime'])
->setPublic(true)
;
}
}
}
14 changes: 13 additions & 1 deletion FormBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
use Bdf\Form\Bundle\DependencyInjection\Compiler\CompileAttributeForms;
use Bdf\Form\Bundle\DependencyInjection\Compiler\RegisterCustomBuilders;
use Bdf\Form\Bundle\DependencyInjection\Compiler\RegisterCustomForms;
use Bdf\Form\Bundle\DependencyInjection\Compiler\RemoveSubmitFormArgumentLocators;
use Bdf\Form\Bundle\DependencyInjection\Compiler\UseCsrfTokenManager;
use Bdf\Form\Bundle\Http\Submit\SubmitForm;
use Bdf\Form\Struct\StructForm;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
Expand All @@ -21,7 +26,14 @@ public function build(ContainerBuilder $container): void
$container->addCompilerPass(new RegisterCustomForms());
$container->addCompilerPass(new UseCsrfTokenManager());

if (class_exists(AttributeForm::class)) {
// Fix "Cannot autowire service" when a DTO argument (using StructForm) is present on a controller.
// Must run after Symfony's RegisterControllerArgumentLocatorsPass (beforeOptimization, priority 0),
// hence the negative priority, so the controller argument locators are already built.
if (\class_exists(ValueResolver::class) && \class_exists(SubmitForm::class)) {
$container->addCompilerPass(new RemoveSubmitFormArgumentLocators(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100);
}

if (\class_exists(AttributeForm::class)) {
$container->addCompilerPass(new CompileAttributeForms());
}
}
Expand Down
5 changes: 3 additions & 2 deletions Http/Submit/SubmitForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Bdf\Form\Aggregate\FormInterface;
use Bdf\Form\Bundle\Http\PayloadSource;
use Bdf\Form\Custom\CustomForm;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

Expand Down Expand Up @@ -71,7 +70,9 @@ public function __construct(
* The form class to use.
* If null, it will be determined based on the argument type.
*
* @var class-string<CustomForm>|null
* In case of struct form, the DTO/Struct class name will be used instead of the form class.
*
* @var class-string|null
*/
public ?string $form = null,

Expand Down
Loading
Loading