Skip to content

Commit cd8b046

Browse files
authored
[FRAM-191] Add argument resolver for automatically submit form (#4)
* feat: Add argument resolver for automatically submit form (#FRAM-191) * ci: run php-cs-fixer on php 8.1
1 parent 437eb48 commit cd8b046

17 files changed

Lines changed: 888 additions & 2 deletions

.github/workflows/code-style.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ on:
99
jobs:
1010
run:
1111
runs-on: ubuntu-latest
12-
name: PHP 8.0
12+
name: PHP 8.1
1313
steps:
1414
- uses: actions/checkout@v2
1515

1616
- name: Install PHP
1717
uses: shivammathur/setup-php@v2
1818
with:
19-
php-version: "8.0"
19+
php-version: "8.1"
2020
extensions: json
2121
ini-values: post_max_size=256M
2222
coverage: xdebug

DependencyInjection/FormExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ public function load(array $configs, ContainerBuilder $container)
3131
$loader->load('attribute.yaml');
3232
}
3333

34+
if (PHP_VERSION_ID >= 80100) {
35+
$loader->load('http.yaml');
36+
}
37+
3438
$container
3539
->registerForAutoconfiguration(ElementBuilderInterface::class)
3640
->addTag('form.custom_builder')

Http/InvalidFormException.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Bdf\Form\Bundle\Http;
4+
5+
use Bdf\Form\Error\FormError;
6+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
7+
8+
class InvalidFormException extends BadRequestHttpException
9+
{
10+
public function __construct(
11+
public readonly FormError $error,
12+
string $message = '',
13+
) {
14+
parent::__construct($message ?: $this->error->global() ?? 'Invalid form data');
15+
}
16+
}

Http/PayloadSource.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace Bdf\Form\Bundle\Http;
4+
5+
use Symfony\Component\HttpFoundation\Request;
6+
7+
/**
8+
* The source of the payload in a request.
9+
*/
10+
enum PayloadSource
11+
{
12+
/**
13+
* Auto-detect the source based on the request method.
14+
*
15+
* If the method is POST, PUT, or PATCH, it will use the body.
16+
* Otherwise, it will use the query string.
17+
*/
18+
case Auto;
19+
20+
/**
21+
* Extract the payload from the query string.
22+
*
23+
* @see Request::$query
24+
*/
25+
case QueryString;
26+
27+
/**
28+
* Extract the payload from the request body.
29+
*
30+
* @see Request::getPayload()
31+
*/
32+
case Body;
33+
34+
/**
35+
* Extract the payload from the request attributes.
36+
*
37+
* @see Request::$attributes
38+
*/
39+
case Attributes;
40+
41+
/**
42+
* Extract the payload from the request.
43+
*/
44+
public function extract(Request $request): array
45+
{
46+
return match ($this) {
47+
self::Auto => self::extractFromHttpMethod($request),
48+
self::QueryString => $request->query->all(),
49+
self::Body => $request->getPayload()->all(),
50+
self::Attributes => $request->attributes->all(),
51+
};
52+
}
53+
54+
private static function extractFromHttpMethod(Request $request): array
55+
{
56+
return match ($request->getMethod()) {
57+
'POST', 'PUT', 'PATCH' => $request->getPayload()->all(),
58+
default => $request->query->all(),
59+
};
60+
}
61+
}

Http/Submit/SubmitForm.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace Bdf\Form\Bundle\Http\Submit;
4+
5+
use Bdf\Form\Aggregate\FormInterface;
6+
use Bdf\Form\Bundle\Http\PayloadSource;
7+
use Bdf\Form\Custom\CustomForm;
8+
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
9+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
10+
11+
/**
12+
* Mark the controller parameter as a form to be submitted.
13+
*
14+
* Usage:
15+
* ```php
16+
* class MyController
17+
* {
18+
* public function simpleForm(#[SubmitForm] MyForm $form): Response
19+
* {
20+
* // Form is submitted using values depending on the HTTP method, and validated
21+
* // The form class is resolved from the parameter type. You can manually set it using the `form` parameter.
22+
* // If the form is invalid, an InvalidFormException will be thrown
23+
*
24+
* assert($form->valid()); // Always true
25+
* // ...
26+
* }
27+
*
28+
* public function manualValidation(#[SubmitForm(validate: false)] MyForm $form): Response
29+
* {
30+
* // Work like the previous example, but the form is not validated
31+
* // So you have to call $form->validate() manually
32+
*
33+
* if (!$form->valid()) {
34+
* // Handle the error
35+
* }
36+
*
37+
* // ...
38+
* }
39+
*
40+
* public function useValue(#[SubmitForm(form: MyForm::class)] MyValue $value): Response
41+
* {
42+
* // The form is submitted, validated, and it's value is generated using the FormInterface::value() method
43+
* }
44+
*
45+
* public function withCustomSources(#[SubmitForm(source: [PayloadSource::Attributes, PayloadSource::Body])] MyForm $form): Response
46+
* {
47+
* // You can define the source of the payload using the `source` parameter, instead of relying on the HTTP method
48+
* // Multiple sources can be defined, and all values will be merged. The first source takes the priority over following ones,
49+
* // So field that are defined in multiple sources will not be overridden.
50+
* }
51+
* }
52+
* ```
53+
*/
54+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
55+
final class SubmitForm extends ValueResolver
56+
{
57+
public ArgumentMetadata $metadata;
58+
59+
public function __construct(
60+
/**
61+
* The request payload source.
62+
*
63+
* By default, it will be determined based on the HTTP method.
64+
* If an array is given, all sources will be merged, with the first one taking precedence.
65+
*
66+
* @var PayloadSource|PayloadSource[]
67+
*/
68+
public PayloadSource|array $source = PayloadSource::Auto,
69+
70+
/**
71+
* The form class to use.
72+
* If null, it will be determined based on the argument type.
73+
*
74+
* @var class-string<CustomForm>|null
75+
*/
76+
public ?string $form = null,
77+
78+
/**
79+
* If true, the form will be validated, and {@see InvalidFormException} will be thrown if the form is invalid.
80+
*/
81+
public bool $validate = true,
82+
83+
/**
84+
* The error message to return if the form is invalid.
85+
*/
86+
public string $validateMessage = 'The JSON contains invalid data.',
87+
88+
/**
89+
* Get the value instead of the form instance.
90+
*
91+
* If null, this flag will be resolved based on the parameter type (i.e. if the controller parameter type is `FormInterface`, it will be set to false).
92+
*
93+
* Note: if this flag is set to true, the form class must be defined on {@see SubmitForm::$form} parameter.
94+
*
95+
* @see FormInterface::value() will be called to get the value.
96+
*/
97+
public ?bool $value = null,
98+
) {
99+
parent::__construct(SubmitFormValueResolver::class);
100+
}
101+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace Bdf\Form\Bundle\Http\Submit;
4+
5+
use Bdf\Form\Bundle\Http\InvalidFormException;
6+
use Bdf\Form\Bundle\Http\PayloadSource;
7+
use Bdf\Form\ElementInterface;
8+
use Bdf\Form\Registry\RegistryInterface;
9+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
10+
use Symfony\Component\HttpFoundation\Request;
11+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
12+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
13+
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
14+
use Symfony\Component\HttpKernel\KernelEvents;
15+
use Symfony\Contracts\Translation\TranslatorInterface;
16+
17+
final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface
18+
{
19+
public function __construct(
20+
private readonly RegistryInterface $registry,
21+
private readonly ?TranslatorInterface $translator = null,
22+
) {
23+
}
24+
25+
#[\Override]
26+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
27+
{
28+
$attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null;
29+
30+
if (null === $attribute) {
31+
return [];
32+
}
33+
34+
$type = $argument->getType();
35+
36+
$attribute->value ??= $type && !\is_subclass_of($type, ElementInterface::class);
37+
38+
if (null === $attribute->form) {
39+
if (true === $attribute->value) {
40+
throw new \LogicException('The form class must be defined when the value is requested');
41+
}
42+
43+
if (null === $type) {
44+
throw new \LogicException('The form class must be defined as controller parameter type, or in the SubmitForm attribute');
45+
}
46+
47+
$attribute->form = $type;
48+
}
49+
50+
$attribute->metadata = $argument;
51+
52+
return [$attribute];
53+
}
54+
55+
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
56+
{
57+
$arguments = $event->getArguments();
58+
$hasChanged = false;
59+
60+
foreach ($arguments as $i => $argument) {
61+
if (!$argument instanceof SubmitForm) {
62+
continue;
63+
}
64+
65+
$payload = $this->extractPayload($event->getRequest(), $argument->source);
66+
$form = $this->registry->elementBuilder($argument->form)->buildElement();
67+
$form->submit($payload);
68+
69+
if ($argument->validate && !$form->valid()) {
70+
throw new InvalidFormException($form->error(), $this->translator ? $this->translator->trans($argument->validateMessage) : $argument->validateMessage);
71+
}
72+
73+
$arguments[$i] = $argument->value ? $form->value() : $form;
74+
$hasChanged = true;
75+
}
76+
77+
if ($hasChanged) {
78+
$event->setArguments($arguments);
79+
}
80+
}
81+
82+
private function extractPayload(Request $request, PayloadSource|array $sources): array
83+
{
84+
if (!\is_array($sources)) {
85+
return $sources->extract($request);
86+
}
87+
88+
$payload = [];
89+
90+
foreach ($sources as $source) {
91+
$payload += $source->extract($request);
92+
}
93+
94+
return $payload;
95+
}
96+
97+
#[\Override]
98+
public static function getSubscribedEvents(): array
99+
{
100+
return [
101+
KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
102+
];
103+
}
104+
}

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,28 @@ class MyController extends AbstractController
9191

9292
return new Reponse('ok');
9393
}
94+
95+
public function withArgumentResolver(#[SubmitForm(validate: false)] MyForm $form)
96+
{
97+
// You can also use symfony argument resolver to automatically inject the form and submit it
98+
// If you want the form to be validated automatically, you can set the `validate` parameter to its default value
99+
// In this case, a InvalidFormException will be thrown if the form is invalid
100+
if (!$form->valid()) {
101+
throw new FormError($form->error());
102+
}
103+
104+
$this->service->save($form->value());
105+
106+
return new Reponse('ok');
107+
}
108+
109+
public function withArgumentResolverValue(#[SubmitForm(form: MyForm::class)] MyDto $value)
110+
{
111+
// The argument resolver can also submit, validate and generate the value automatically
112+
$this->service->save($value);
113+
114+
return new Reponse('ok');
115+
}
94116
}
95117
```
96118

Resources/config/http.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
Bdf\Form\Bundle\Http\Submit\SubmitFormValueResolver:
3+
class: 'Bdf\Form\Bundle\Http\Submit\SubmitFormValueResolver'
4+
arguments:
5+
- '@Bdf\Form\Registry\RegistryInterface'
6+
- '@?translator.default'
7+
tags:
8+
- 'controller.argument_value_resolver'
9+
- 'kernel.event_subscriber'

Tests/Http/Form/PersonDto.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Bdf\Form\Bundle\Tests\Http\Form;
4+
5+
class PersonDto
6+
{
7+
public int $id;
8+
public string $firstName;
9+
public string $lastName;
10+
}

0 commit comments

Comments
 (0)