diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 841bf16..4527b8c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -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 }} @@ -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 diff --git a/Attribute/GeneratedConfiguratorResolver.php b/Attribute/GeneratedConfiguratorResolver.php index 078ff88..41e731d 100644 --- a/Attribute/GeneratedConfiguratorResolver.php +++ b/Attribute/GeneratedConfiguratorResolver.php @@ -2,7 +2,6 @@ namespace Bdf\Form\Bundle\Attribute; -use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; /** @@ -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) { diff --git a/DependencyInjection/Compiler/CompileAttributeForms.php b/DependencyInjection/Compiler/CompileAttributeForms.php index 238dccc..1aa164c 100644 --- a/DependencyInjection/Compiler/CompileAttributeForms.php +++ b/DependencyInjection/Compiler/CompileAttributeForms.php @@ -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); diff --git a/DependencyInjection/Compiler/RegisterCustomBuilders.php b/DependencyInjection/Compiler/RegisterCustomBuilders.php index 0ba85e8..c840417 100644 --- a/DependencyInjection/Compiler/RegisterCustomBuilders.php +++ b/DependencyInjection/Compiler/RegisterCustomBuilders.php @@ -11,7 +11,7 @@ */ class RegisterCustomBuilders implements CompilerPassInterface { - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { $registry = $container->findDefinition(SymfonyRegistry::class); diff --git a/DependencyInjection/Compiler/RegisterCustomForms.php b/DependencyInjection/Compiler/RegisterCustomForms.php index b014c98..05fdcc1 100644 --- a/DependencyInjection/Compiler/RegisterCustomForms.php +++ b/DependencyInjection/Compiler/RegisterCustomForms.php @@ -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) diff --git a/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php new file mode 100644 index 0000000..690df4b --- /dev/null +++ b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php @@ -0,0 +1,112 @@ + 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 + */ + 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; + } +} diff --git a/DependencyInjection/Compiler/UseCsrfTokenManager.php b/DependencyInjection/Compiler/UseCsrfTokenManager.php index 96ec650..c312de2 100644 --- a/DependencyInjection/Compiler/UseCsrfTokenManager.php +++ b/DependencyInjection/Compiler/UseCsrfTokenManager.php @@ -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) diff --git a/DependencyInjection/FormExtension.php b/DependencyInjection/FormExtension.php index 6b10f40..bc37301 100644 --- a/DependencyInjection/FormExtension.php +++ b/DependencyInjection/FormExtension.php @@ -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'); @@ -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']); + } } /** @@ -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) + ; + } + } } diff --git a/FormBundle.php b/FormBundle.php index 39c9db1..35ea714 100644 --- a/FormBundle.php +++ b/FormBundle.php @@ -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; /** @@ -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()); } } diff --git a/Http/Submit/SubmitForm.php b/Http/Submit/SubmitForm.php index 61a242d..9b6d92e 100644 --- a/Http/Submit/SubmitForm.php +++ b/Http/Submit/SubmitForm.php @@ -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; @@ -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|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, diff --git a/Http/Submit/SubmitFormValueResolver.php b/Http/Submit/SubmitFormValueResolver.php index 210b65e..442037b 100644 --- a/Http/Submit/SubmitFormValueResolver.php +++ b/Http/Submit/SubmitFormValueResolver.php @@ -2,10 +2,12 @@ namespace Bdf\Form\Bundle\Http\Submit; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Bundle\Http\InvalidFormException; use Bdf\Form\Bundle\Http\PayloadSource; use Bdf\Form\ElementInterface; use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; @@ -28,6 +30,12 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null; if (null === $attribute) { + $type = $argument->getType(); + + if (null !== $type && \is_a($type, FormInterface::class, true)) { + return [new FormParameter($type)]; + } + return []; } @@ -36,7 +44,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute->value ??= $type && !\is_subclass_of($type, ElementInterface::class); if (null === $attribute->form) { - if (true === $attribute->value) { + if (true === $attribute->value && !\class_exists(StructForm::class)) { throw new \LogicException('The form class must be defined when the value is requested'); } @@ -56,6 +64,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo { $arguments = $event->getArguments(); $hasChanged = false; + $form = null; foreach ($arguments as $i => $argument) { if (!$argument instanceof SubmitForm) { @@ -63,17 +72,37 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } $payload = $this->extractPayload($event->getRequest(), $argument->source); - $form = $this->registry->elementBuilder($argument->form)->buildElement(); + $form = \is_subclass_of($argument->form, ElementInterface::class) + ? $this->registry->elementBuilder($argument->form)->buildElement() + : $this->registry->elementBuilder(StructForm::class)->class($argument->form)->buildElement() + ; $form->submit($payload); + $valid = $form->valid(); - if ($argument->validate && !$form->valid()) { + if ($argument->validate && !$valid) { throw new InvalidFormException($form->error(), $this->translator ? $this->translator->trans($argument->validateMessage) : $argument->validateMessage); } - $arguments[$i] = $argument->value ? $form->value() : $form; + if ($argument->value) { + try { + $arguments[$i] = $valid ? $form->value() : null; + } catch (\Throwable) { + $arguments[$i] = null; + } + } else { + $arguments[$i] = $form; + } + $hasChanged = true; } + foreach ($arguments as $i => $argument) { + if ($argument instanceof FormParameter) { + $arguments[$i] = $form ?? $this->registry->elementBuilder($argument->type)->buildElement(); + $hasChanged = true; + } + } + if ($hasChanged) { $event->setArguments($arguments); } @@ -102,3 +131,17 @@ public static function getSubscribedEvents(): array ]; } } + +/** + * @internal + */ +final class FormParameter +{ + public function __construct( + /** + * @var class-string + */ + public readonly string $type, + ) { + } +} diff --git a/Registry/SymfonyRegistry.php b/Registry/SymfonyRegistry.php index de2a050..f0a2dac 100644 --- a/Registry/SymfonyRegistry.php +++ b/Registry/SymfonyRegistry.php @@ -12,6 +12,8 @@ use Bdf\Form\Filter\FilterInterface; use Bdf\Form\Registry\Registry; use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; +use Bdf\Form\Struct\StructFormBuilder; use Bdf\Form\Transformer\TransformerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; @@ -21,6 +23,8 @@ */ class SymfonyRegistry implements RegistryInterface { + public const STRUCT_FORM_PROCESSOR_SERVICE_ID = 'bdf_form.struct.processor'; + /** * @var Registry */ @@ -44,7 +48,12 @@ public function __construct(Registry $registry, ContainerInterface $container) $this->registry = $registry; $this->container = $container; + $registry->register(Form::class, function () { return $this->container->get('bdf_form.inner_form_builder'); }); $registry->register(CustomForm::class, [$this, 'customFormBuilder']); + + if (\class_exists(StructForm::class)) { + $registry->register(StructForm::class, [$this, 'structFormBuilder']); + } } public function filter($filter): FilterInterface @@ -103,6 +112,19 @@ function ($builder) use ($formClass) { ); } + /** + * Create the form builder instance. + * + * @internal + */ + public function structFormBuilder(RegistryInterface $registry, string $formClass): StructFormBuilder + { + return new StructFormBuilder( + $this->container->get(self::STRUCT_FORM_PROCESSOR_SERVICE_ID), + $registry->elementBuilder(Form::class) + ); + } + /** * Register the element builder to the related element if possible. * diff --git a/Resources/config/form.yaml b/Resources/config/form.yaml index a1a8ab9..6bb052a 100644 --- a/Resources/config/form.yaml +++ b/Resources/config/form.yaml @@ -21,6 +21,14 @@ services: calls: - validator: ['@validator'] + bdf_form.inner_form_builder: + class: 'Bdf\Form\Aggregate\FormBuilder' + arguments: ['@Bdf\Form\Bundle\Registry\SymfonyRegistry'] + public: true + shared: false + calls: + - validator: ['@validator'] + Bdf\Form\Aggregate\FormBuilderInterface: alias: 'Bdf\Form\Aggregate\FormBuilder' public: true diff --git a/Tests/BdfFormBundleTest.php b/Tests/BdfFormBundleTest.php index 3b916e1..9e2738f 100644 --- a/Tests/BdfFormBundleTest.php +++ b/Tests/BdfFormBundleTest.php @@ -9,16 +9,19 @@ use Bdf\Form\Bundle\FormBundle; use Bdf\Form\Bundle\Registry\SymfonyRegistry; use Bdf\Form\Bundle\Tests\Forms\A; +use Bdf\Form\Bundle\Tests\Forms\CustomFormWithDependentConstraint; use Bdf\Form\Bundle\Tests\Forms\FooElement; use Bdf\Form\Bundle\Tests\Forms\FooElementBuilder; use Bdf\Form\Bundle\Tests\Forms\MyConstraintValidator; use Bdf\Form\Bundle\Tests\Forms\MyCustomForm; +use Bdf\Form\Bundle\Tests\Forms\NoForbiddenValueValidator; use Bdf\Form\Csrf\CsrfElement; use Bdf\Form\Csrf\CsrfElementBuilder; use Bdf\Form\Registry\RegistryInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\HttpKernelBrowser; /** * BdfSerializerBundleTest. @@ -88,6 +91,29 @@ public function testCustomFormShouldInstantiateConstraintValidatorFromContainer( $this->assertEquals(new A('foo'), MyConstraintValidator::$injectedParameter); } + public function testCustomFormWithConstraintUsingValidatorWithDependencies() + { + $kernel = new \TestKernel(); + $kernel->boot(); + + NoForbiddenValueValidator::$dependency = null; + + $form = $kernel->getContainer()->get(RegistryInterface::class)->elementBuilder(CustomFormWithDependentConstraint::class)->buildElement(); + + // 'foo' is the value carried by the injected A service, so it must be rejected by the custom constraint + $form->submit(['value' => 'foo']); + + $this->assertFalse($form->valid()); + $this->assertEquals(['value' => 'This value is forbidden.'], $form->error()->toArray()); + // the validator has been instantiated from the container, with its dependency injected + $this->assertEquals(new A('foo'), NoForbiddenValueValidator::$dependency); + + $form->submit(['value' => 'bar']); + + $this->assertTrue($form->valid()); + $this->assertSame('bar', $form['value']->element()->value()); + } + public function testCustomFormShouldUseCurrentElementBuilderInstance() { $kernel = new \TestKernel(); @@ -119,6 +145,23 @@ public function testCustomElement() $this->assertSame($kernel->getContainer()->get(A::class), $builder->a); } + public function testInjectFormAsControllerArgumentWithoutSubmitForm() + { + $kernel = new \TestKernel(); + $client = new HttpKernelBrowser($kernel); + + $client->request('POST', '/form-only', [ + 'foo' => 'bar', + 'other' => 'baz', + ]); + + // The MyCustomForm service is autowired and injected directly by the container, + // then submitted by the controller: the request succeeds. + $response = $client->getResponse(); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['value' => [], 'errors' => []], \json_decode($response->getContent(), true)); + } + public function testCsrfElement() { $kernel = new \TestKernel(); diff --git a/Tests/FormBundleWithAttributeTest.php b/Tests/FormBundleWithAttributeTest.php index 480d7c4..2682684 100644 --- a/Tests/FormBundleWithAttributeTest.php +++ b/Tests/FormBundleWithAttributeTest.php @@ -5,11 +5,18 @@ require_once __DIR__.'/TestKernel.php'; use Bdf\Form\Aggregate\FormBuilder; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\CompileAttributesProcessor; use Bdf\Form\Attribute\Processor\ReflectionProcessor; +use Bdf\Form\Bundle\Tests\Forms\A; +use Bdf\Form\Bundle\Tests\Forms\NoForbiddenValueValidator; +use Bdf\Form\Bundle\Tests\FormsAttributes\StructDto; +use Bdf\Form\Bundle\Tests\FormsAttributes\StructWithDependentConstraint; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAnonymousFormClass; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAttributes; +use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; @@ -20,7 +27,7 @@ class FormBundleWithAttributeTest extends TestCase { protected function setUp(): void { - if (!class_exists(AttributeForm::class)) { + if (!\class_exists(AttributeForm::class)) { $this->markTestSkipped(); } @@ -50,7 +57,7 @@ public function testShouldUseCompileAttributesProcessor() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); @@ -79,6 +86,74 @@ public function testFunctional() $this->assertSame(['foo' => 'azerty', 'bar' => 5], $form->value()); } + /** + * @return void + */ + public function testFunctionalStruct() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped('Struct not supported'); + } + + $kernel = new \TestKernel(['conf_php8.yaml']); + $kernel->boot(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get(RegistryInterface::class) + ->elementBuilder(StructForm::class) + ->class(StructDto::class) + ->buildElement(); + + $form->submit([ + 'id' => -2, + 'name' => 'azerty', + ]); + + $this->assertFalse($form->valid()); + $this->assertEquals(['id' => 'This value should be positive.'], $form->error()->toArray()); + + $form->submit([ + 'id' => 1, + 'name' => 'azerty', + ]); + $this->assertTrue($form->valid()); + $this->assertEquals(new StructDto(1, 'azerty'), $form->value()); + } + + /** + * @return void + */ + public function testFunctionalStructWithConstraintUsingValidatorWithDependencies() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped('Struct not supported'); + } + + $kernel = new \TestKernel(['conf_php8.yaml']); + $kernel->boot(); + + NoForbiddenValueValidator::$dependency = null; + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get(RegistryInterface::class) + ->elementBuilder(StructForm::class) + ->class(StructWithDependentConstraint::class) + ->buildElement(); + + // 'foo' is the value carried by the injected A service, so it must be rejected by the custom constraint + $form->submit(['value' => 'foo']); + + $this->assertFalse($form->valid()); + $this->assertEquals(['value' => 'This value is forbidden.'], $form->error()->toArray()); + // the validator has been instantiated from the container, with its dependency injected + $this->assertEquals(new A('foo'), NoForbiddenValueValidator::$dependency); + + $form->submit(['value' => 'bar']); + + $this->assertTrue($form->valid()); + $this->assertEquals(new StructWithDependentConstraint('bar'), $form->value()); + } + public function testDisableCompilation() { $kernel = new \TestKernel(['conf_php8.yaml', 'conf_disable_compilation.yaml']); @@ -88,7 +163,7 @@ public function testDisableCompilation() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(ReflectionProcessor::class, $processor); @@ -103,7 +178,7 @@ public function testWithCustomResolverConfig() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); @@ -136,7 +211,7 @@ public function testWithAnonymousFormClass() $this->assertSame(['foo' => 'BAR'], $o->process(['foo' => 'bar'])); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($o->form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); diff --git a/Tests/Forms/CustomFormWithDependentConstraint.php b/Tests/Forms/CustomFormWithDependentConstraint.php new file mode 100644 index 0000000..23f4f93 --- /dev/null +++ b/Tests/Forms/CustomFormWithDependentConstraint.php @@ -0,0 +1,17 @@ +string('value')->satisfy(new NoForbiddenValue()); + } +} diff --git a/Tests/Forms/MyConstraintValidator.php b/Tests/Forms/MyConstraintValidator.php index 59719d3..af78342 100644 --- a/Tests/Forms/MyConstraintValidator.php +++ b/Tests/Forms/MyConstraintValidator.php @@ -19,7 +19,7 @@ public function __construct(A $a) $this->a = $a; } - public function validate($value, Constraint $constraint) + public function validate($value, Constraint $constraint): void { self::$injectedParameter = $this->a; } diff --git a/Tests/Forms/NoForbiddenValue.php b/Tests/Forms/NoForbiddenValue.php new file mode 100644 index 0000000..85c22b2 --- /dev/null +++ b/Tests/Forms/NoForbiddenValue.php @@ -0,0 +1,16 @@ +a = $a; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof NoForbiddenValue) { + return; + } + + self::$dependency = $this->a; + + if ($value === $this->a->foo) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } +} diff --git a/Tests/FormsAttributes/StructDto.php b/Tests/FormsAttributes/StructDto.php new file mode 100644 index 0000000..6045946 --- /dev/null +++ b/Tests/FormsAttributes/StructDto.php @@ -0,0 +1,18 @@ +client->request('POST', '/form2', [ + 'id' => 1, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => 1, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'errors' => [], + ], $content); + } + + public function testInjectFormAndValueError() + { + $this->client->request('POST', '/form2', [ + 'firstName' => '#####', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => null, + 'firstName' => '#####', + 'lastName' => 'Doe', + ], + 'errors' => [ + 'id' => 'This value should not be blank.', + 'firstName' => 'This value is not valid.', + ], + ], $content); + } + public function testMultiplePayloadSource() { $this->client->request('PUT', '/person/42', [ @@ -127,4 +168,79 @@ public function testMultiplePayloadSourcePriority() 'lastName' => 'Doe', ], $content); } + + public function testWithStruct() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped(); + } + + $this->client->request('POST', '/struct', [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], $content); + + $this->client->request('POST', '/struct', [ + 'id' => -5, + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'message' => 'The JSON contains invalid data.', + 'fields' => [ + 'firstName' => 'This value is not valid.', + ], + ], $content); + } + + public function testWithStructAndForm() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped(); + } + + $this->client->request('POST', '/struct2', [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'errors' => [], + ], $content); + + $this->client->request('POST', '/struct2', [ + 'id' => -5, + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => '-5', + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ], + 'errors' => [ + 'firstName' => 'This value is not valid.', + ], + ], $content); + } } diff --git a/Tests/Http/Submit/SubmitFormValueResolverTest.php b/Tests/Http/Submit/SubmitFormValueResolverTest.php index 71f1fc6..155d0a3 100644 --- a/Tests/Http/Submit/SubmitFormValueResolverTest.php +++ b/Tests/Http/Submit/SubmitFormValueResolverTest.php @@ -9,6 +9,7 @@ use Bdf\Form\Bundle\Tests\Http\Form\PersonDto; use Bdf\Form\Bundle\Tests\Http\Form\PersonForm; use Bdf\Form\Registry\Registry; +use Bdf\Form\Struct\StructForm; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; @@ -56,6 +57,10 @@ public function testResolveWithValueArgument() public function testResolveWithValueArgumentMissingForm() { + if (\class_exists(StructForm::class)) { + $this->markTestSkipped('This error is not more triggered with struct form'); + } + $this->expectException(\LogicException::class); $this->expectExceptionMessage('The form class must be defined when the value is requested'); diff --git a/Tests/Http/TestKernel.php b/Tests/Http/TestKernel.php index fd3914a..6acd08e 100644 --- a/Tests/Http/TestKernel.php +++ b/Tests/Http/TestKernel.php @@ -3,11 +3,13 @@ namespace Bdf\Form\Bundle\Tests\Http; use Bdf; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Bundle\Http\InvalidFormException; use Bdf\Form\Bundle\Http\PayloadSource; use Bdf\Form\Bundle\Http\Submit\SubmitForm; use Bdf\Form\Bundle\Tests\Http\Form\PersonDto; use Bdf\Form\Bundle\Tests\Http\Form\PersonForm; +use Bdf\Form\Bundle\Tests\Http\Form\PersonStruct; use Symfony; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -75,6 +77,21 @@ public function value(#[SubmitForm(form: PersonForm::class)] PersonDto $dto): Js return new JsonResponse($dto); } + #[Route('/struct', methods: ['POST'])] + public function struct(#[SubmitForm] PersonStruct $struct): JsonResponse + { + return new JsonResponse($struct); + } + + #[Route('/struct2', methods: ['POST'])] + public function struct2(#[SubmitForm(validate: false)] ?PersonStruct $struct, FormInterface $form): JsonResponse + { + return new JsonResponse([ + 'value' => $form->valid() ? $struct : $form->httpValue(), + 'errors' => $form->error()->toArray(), + ]); + } + #[Route('/form', methods: ['POST'])] public function form(#[SubmitForm(validate: false)] PersonForm $form): JsonResponse { @@ -84,6 +101,15 @@ public function form(#[SubmitForm(validate: false)] PersonForm $form): JsonRespo ]); } + #[Route('/form2', methods: ['POST'])] + public function form2(#[SubmitForm(form: PersonForm::class, validate: false)] ?PersonDto $person, PersonForm $form): JsonResponse + { + return new JsonResponse([ + 'value' => $person ?? $form->httpValue(), + 'errors' => $form->error()->toArray(), + ]); + } + #[Route('/person/{id}', methods: ['PUT'])] public function person(#[SubmitForm(source: [PayloadSource::Attributes, PayloadSource::Body], form: PersonForm::class)] PersonDto $dto): JsonResponse { diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index 42447f5..4428d33 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -1,8 +1,11 @@ add('form_only', '/form-only') + ->controller('kernel::formOnly') + ->methods(['POST']) + ; } protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader) @@ -37,4 +44,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__.'/'.$config); } } + + public function formOnly(MyCustomForm $form, Request $request): JsonResponse + { + $form->submit($request->request->all()); + + return new JsonResponse([ + 'value' => $form->value(), + 'errors' => $form->error()->toArray(), + ]); + } } diff --git a/composer.json b/composer.json index bb70106..ed1dbb3 100755 --- a/composer.json +++ b/composer.json @@ -17,17 +17,17 @@ "minimum-stability": "dev", "require": { "php": "~7.2 | ~8.0", - "b2pweb/bdf-form": "~1.0", - "symfony/config": "~5.0|~6.0|~7.0", - "symfony/dependency-injection": "~5.0|~6.0|~7.0", - "symfony/framework-bundle": "~5.0|~6.0|~7.0" + "b2pweb/bdf-form": "~1.0|~2.0", + "symfony/config": "~5.0|~6.0|~7.0|~8.0", + "symfony/dependency-injection": "~5.0|~6.0|~7.0|~8.0", + "symfony/framework-bundle": "~5.0|~6.0|~7.0|~8.0" }, "require-dev": { "phpunit/phpunit": "~7.0|~8.0|~9.0", - "symfony/phpunit-bridge": "~5.0|~6.0|~7.0", - "symfony/yaml": "~5.0|~6.0|~7.0", - "symfony/security-csrf": "~5.0|~6.0|~7.0", - "symfony/browser-kit": "~5.0|~6.0|~7.0", + "symfony/phpunit-bridge": "~5.0|~6.0|~7.0|~8.0", + "symfony/yaml": "~5.0|~6.0|~7.0|~8.0", + "symfony/security-csrf": "~5.0|~6.0|~7.0|~8.0", + "symfony/browser-kit": "~5.0|~6.0|~7.0|~8.0", "friendsofphp/php-cs-fixer": "~3.0" }, "suggest": {