Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/code-style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ on:
jobs:
run:
runs-on: ubuntu-latest
name: PHP 8.0
name: PHP 8.1
steps:
- uses: actions/checkout@v2

- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.0"
php-version: "8.1"
extensions: json
ini-values: post_max_size=256M
coverage: xdebug
Expand Down
4 changes: 4 additions & 0 deletions DependencyInjection/FormExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public function load(array $configs, ContainerBuilder $container)
$loader->load('attribute.yaml');
}

if (PHP_VERSION_ID >= 80100) {
$loader->load('http.yaml');
}

$container
->registerForAutoconfiguration(ElementBuilderInterface::class)
->addTag('form.custom_builder')
Expand Down
16 changes: 16 additions & 0 deletions Http/InvalidFormException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Bdf\Form\Bundle\Http;

use Bdf\Form\Error\FormError;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class InvalidFormException extends BadRequestHttpException
{
public function __construct(
public readonly FormError $error,
string $message = '',
) {
parent::__construct($message ?: $this->error->global() ?? 'Invalid form data');
}
}
61 changes: 61 additions & 0 deletions Http/PayloadSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Bdf\Form\Bundle\Http;

use Symfony\Component\HttpFoundation\Request;

/**
* The source of the payload in a request.
*/
enum PayloadSource
{
/**
* Auto-detect the source based on the request method.
*
* If the method is POST, PUT, or PATCH, it will use the body.
* Otherwise, it will use the query string.
*/
case Auto;

/**
* Extract the payload from the query string.
*
* @see Request::$query
*/
case QueryString;

/**
* Extract the payload from the request body.
*
* @see Request::getPayload()
*/
case Body;

/**
* Extract the payload from the request attributes.
*
* @see Request::$attributes
*/
case Attributes;

/**
* Extract the payload from the request.
*/
public function extract(Request $request): array
{
return match ($this) {
self::Auto => self::extractFromHttpMethod($request),
self::QueryString => $request->query->all(),
self::Body => $request->getPayload()->all(),
self::Attributes => $request->attributes->all(),
};
}

private static function extractFromHttpMethod(Request $request): array
{
return match ($request->getMethod()) {
'POST', 'PUT', 'PATCH' => $request->getPayload()->all(),
default => $request->query->all(),
};
}
}
101 changes: 101 additions & 0 deletions Http/Submit/SubmitForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace Bdf\Form\Bundle\Http\Submit;

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;

/**
* Mark the controller parameter as a form to be submitted.
*
* Usage:
* ```php
* class MyController
* {
* public function simpleForm(#[SubmitForm] MyForm $form): Response
* {
* // Form is submitted using values depending on the HTTP method, and validated
* // The form class is resolved from the parameter type. You can manually set it using the `form` parameter.
* // If the form is invalid, an InvalidFormException will be thrown
*
* assert($form->valid()); // Always true
* // ...
* }
*
* public function manualValidation(#[SubmitForm(validate: false)] MyForm $form): Response
* {
* // Work like the previous example, but the form is not validated
* // So you have to call $form->validate() manually
*
* if (!$form->valid()) {
* // Handle the error
* }
*
* // ...
* }
*
* public function useValue(#[SubmitForm(form: MyForm::class)] MyValue $value): Response
* {
* // The form is submitted, validated, and it's value is generated using the FormInterface::value() method
* }
*
* public function withCustomSources(#[SubmitForm(source: [PayloadSource::Attributes, PayloadSource::Body])] MyForm $form): Response
* {
* // You can define the source of the payload using the `source` parameter, instead of relying on the HTTP method
* // Multiple sources can be defined, and all values will be merged. The first source takes the priority over following ones,
* // So field that are defined in multiple sources will not be overridden.
* }
* }
* ```
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
final class SubmitForm extends ValueResolver
{
public ArgumentMetadata $metadata;

public function __construct(
/**
* The request payload source.
*
* By default, it will be determined based on the HTTP method.
* If an array is given, all sources will be merged, with the first one taking precedence.
*
* @var PayloadSource|PayloadSource[]
*/
public PayloadSource|array $source = PayloadSource::Auto,

/**
* The form class to use.
* If null, it will be determined based on the argument type.
*
* @var class-string<CustomForm>|null
*/
public ?string $form = null,

/**
* If true, the form will be validated, and {@see InvalidFormException} will be thrown if the form is invalid.
*/
public bool $validate = true,

/**
* The error message to return if the form is invalid.
*/
public string $validateMessage = 'The JSON contains invalid data.',

/**
* Get the value instead of the form instance.
*
* 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).
*
* Note: if this flag is set to true, the form class must be defined on {@see SubmitForm::$form} parameter.
*
* @see FormInterface::value() will be called to get the value.
*/
public ?bool $value = null,
) {
parent::__construct(SubmitFormValueResolver::class);
}
}
104 changes: 104 additions & 0 deletions Http/Submit/SubmitFormValueResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace Bdf\Form\Bundle\Http\Submit;

use Bdf\Form\Bundle\Http\InvalidFormException;
use Bdf\Form\Bundle\Http\PayloadSource;
use Bdf\Form\ElementInterface;
use Bdf\Form\Registry\RegistryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Translation\TranslatorInterface;

final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface
{
public function __construct(
private readonly RegistryInterface $registry,
private readonly ?TranslatorInterface $translator = null,
) {
}

#[\Override]
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null;

if (null === $attribute) {
return [];
}

$type = $argument->getType();

$attribute->value ??= $type && !\is_subclass_of($type, ElementInterface::class);

if (null === $attribute->form) {
if (true === $attribute->value) {
throw new \LogicException('The form class must be defined when the value is requested');
}

if (null === $type) {
throw new \LogicException('The form class must be defined as controller parameter type, or in the SubmitForm attribute');
}

$attribute->form = $type;
}

$attribute->metadata = $argument;

return [$attribute];
}

public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
{
$arguments = $event->getArguments();
$hasChanged = false;

foreach ($arguments as $i => $argument) {
if (!$argument instanceof SubmitForm) {
continue;
}

$payload = $this->extractPayload($event->getRequest(), $argument->source);
$form = $this->registry->elementBuilder($argument->form)->buildElement();
$form->submit($payload);

if ($argument->validate && !$form->valid()) {
throw new InvalidFormException($form->error(), $this->translator ? $this->translator->trans($argument->validateMessage) : $argument->validateMessage);
}

$arguments[$i] = $argument->value ? $form->value() : $form;
$hasChanged = true;
}

if ($hasChanged) {
$event->setArguments($arguments);
}
}

private function extractPayload(Request $request, PayloadSource|array $sources): array
{
if (!\is_array($sources)) {
return $sources->extract($request);
}

$payload = [];

foreach ($sources as $source) {
$payload += $source->extract($request);
}

return $payload;
}

#[\Override]
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
];
}
}
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ class MyController extends AbstractController

return new Reponse('ok');
}

public function withArgumentResolver(#[SubmitForm(validate: false)] MyForm $form)
{
// You can also use symfony argument resolver to automatically inject the form and submit it
// If you want the form to be validated automatically, you can set the `validate` parameter to its default value
// In this case, a InvalidFormException will be thrown if the form is invalid
if (!$form->valid()) {
throw new FormError($form->error());
}

$this->service->save($form->value());

return new Reponse('ok');
}

public function withArgumentResolverValue(#[SubmitForm(form: MyForm::class)] MyDto $value)
{
// The argument resolver can also submit, validate and generate the value automatically
$this->service->save($value);

return new Reponse('ok');
}
}
```

Expand Down
9 changes: 9 additions & 0 deletions Resources/config/http.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
services:
Bdf\Form\Bundle\Http\Submit\SubmitFormValueResolver:
class: 'Bdf\Form\Bundle\Http\Submit\SubmitFormValueResolver'
arguments:
- '@Bdf\Form\Registry\RegistryInterface'
- '@?translator.default'
tags:
- 'controller.argument_value_resolver'
- 'kernel.event_subscriber'
10 changes: 10 additions & 0 deletions Tests/Http/Form/PersonDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Bdf\Form\Bundle\Tests\Http\Form;

class PersonDto
{
public int $id;
public string $firstName;
public string $lastName;
}
Loading