Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autocomplete] Allow passing extra options to the autocomplete fields #1322

Merged
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
2 changes: 2 additions & 0 deletions src/Autocomplete/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
- Added `tom-select/dist/css/tom-select.bootstrap4.css` to `autoimport` - this
will cause this to appear in your `controllers.json` file by default, but disabled
see.
- Allow passing `extra_options` key in an array passed as a `3rd` argument of the `->add()` method.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compared to symfony/form changelog, i'd write it maybe more like

  • Add extra_options to XXX and YYY types (those options are .... to ...)

It will be used during the Ajax call to fetch results.

## 2.13.2

Expand Down
39 changes: 39 additions & 0 deletions src/Autocomplete/src/Checksum/ChecksumCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not usually set in symfony components AFAIK

(sorry for the c/c)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weaverryan should I remove it? I've added it as I have it in my PHP class template in the IDE (as I consider it as a good practice) 🤔.


/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\Autocomplete\Checksum;

/** @internal */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** @internal */
/**
* @author .... ?
*
* @internal
*/

(same thing on OptionsAware..Interface ?)

class ChecksumCalculator
{
public function __construct(private readonly string $secret)
{
}

public function calculateForArray(array $data): string
{
$this->sortKeysRecursively($data);

return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use some lighter hash algo?

Suggested change
return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
return base64_encode(hash_hmac('xxh128', json_encode($data), $this->secret, true));

Ref: https://php.watch/articles/php-hash-benchmark

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the base64 still be necessary ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I guess the base64_encode is kind of a leftover before I started using hash_hmac (or I used it to get a shorter string) 🤔. We can remove it and increase the string length from 44 to 64.

In terms of the algorithm:
CleanShot 2023-12-25 at 13 42 35@2x

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question here would be how we treat that data, for me it's checksum which is not a password but should be kinda of cryptographic, but it doesn't need to be perfect as a password and it doesn't need to be as safe as it is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're right. I thought (idk why :D) that we encrypt data here, but the data is not encrypted at all.

So the answer here is simple, I used sha256, because that's exactly the same way as we calculate the checksum in the LiveComponent. I'm open to changing it, I just copied the code from the LiveComponent and didn't bother to think whether sha256 is the correct one here :D.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I'm thinking about extracting checksum calculating inside the LiveComponent component, and using it in the autocomplete component. Autocomplete depends on the LiveComponent so it might make sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would make perfect sense to me.

Feel free to do it in a second/later PR if you want to release this one first... in all maner this should be "internal" so there is no BC involved there :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was 100% sure LiveComponent is required by the Autocomplete, but it is not.

So, for now, I leave the Calculator where it lives. I can mark the class as internal but I'll leave the interface as it is, to allow people to create their own implementation of checksum calculator. If anyone finds they need a lighter version of hash algorithm, it'll make sense to create their own.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I'll leave the interface as it is,

I'd prefer to remove the interface. I can't see a legit reason to need to write your own. If there is a problem with our class, then we want them to tell us so we can fix it :)

Copy link
Contributor Author

@jakubtobiasz jakubtobiasz Feb 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weaverryan I've removed it!

}

private function sortKeysRecursively(array &$data): void
{
foreach ($data as &$value) {
if (\is_array($value)) {
$this->sortKeysRecursively($value);
}
}
ksort($data);
}
}
56 changes: 56 additions & 0 deletions src/Autocomplete/src/Controller/EntityAutocompleteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,27 @@
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
use Symfony\UX\Autocomplete\AutocompleterRegistry;
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
use Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension;
use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface;

/**
* @author Ryan Weaver <[email protected]>
*/
final class EntityAutocompleteController
{
public const EXTRA_OPTIONS = 'extra_options';

public function __construct(
private AutocompleterRegistry $autocompleteFieldRegistry,
private AutocompleteResultsExecutor $autocompleteResultsExecutor,
private UrlGeneratorInterface $urlGenerator,
private ChecksumCalculator $checksumCalculator,
) {
}

Expand All @@ -38,6 +45,11 @@ public function __invoke(string $alias, Request $request): Response
throw new NotFoundHttpException(sprintf('No autocompleter found for "%s". Available autocompleters are: (%s)', $alias, implode(', ', $this->autocompleteFieldRegistry->getAutocompleterNames())));
}

if ($autocompleter instanceof OptionsAwareEntityAutocompleterInterface) {
$extraOptions = $this->getExtraOptions($request);
$autocompleter->setOptions([self::EXTRA_OPTIONS => $extraOptions]);
}

$page = $request->query->getInt('page', 1);
$nextPage = null;

Expand All @@ -54,4 +66,48 @@ public function __invoke(string $alias, Request $request): Response
'next_page' => $nextPage,
]);
}

/**
* @return array<string, scalar|array|null>
*/
private function getExtraOptions(Request $request): array
{
if (!$request->query->has(self::EXTRA_OPTIONS)) {
return [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still detect if anyone removes the extra options ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It simplifies the code. The method always returns an array, so no additional checks are required method "above".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My scenario was: if someone manually removes the extra_options paramters in the query string, and we expect some to be defined, what would happen ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷🏼‍♂️

I believe it's not a big deal to not check whether an option was set (and I guess it might be a little bit problematic to check that), but maybe I'm too lax on this topic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok !

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we used Symfony's UriSigner instead of the checksum? If extra options exist, we sign the URL when generating it and passing it to the frontend. Here, we validate the signed URL.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've started reimplementing this feature, and... I noticed it computes the hash in the same way as we calculate the checksum :D.

private function computeHash(string $uri): string
    {
        return base64_encode(hash_hmac('sha256', $uri, $this->secret, true));
    }

I have troubles with testing UriSigner as (at least from what I know) it requires the full URL, so it's harder to simulate passing the _hash in the tests. If you really want to switch to UriSigner I'll rewrite it after the confirmation :P.

}

$extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS));

if (!\array_key_exists(AutocompleteChoiceTypeExtension::CHECKSUM_KEY, $extraOptions)) {
throw new BadRequestHttpException('The extra options are missing the checksum.');
}

$this->validateChecksum($extraOptions[AutocompleteChoiceTypeExtension::CHECKSUM_KEY], $extraOptions);

return $extraOptions;
}

/**
* @return array<string, scalar>
*/
private function getDecodedExtraOptions(string $extraOptions): array
{
return json_decode(base64_decode($extraOptions), true, flags: \JSON_THROW_ON_ERROR);
}

/**
* @param array<string, scalar> $extraOptions
*/
private function validateChecksum(string $checksum, array $extraOptions): void
{
$extraOptionsWithoutChecksum = array_filter(
$extraOptions,
fn (string $key) => AutocompleteChoiceTypeExtension::CHECKSUM_KEY !== $key,
\ARRAY_FILTER_USE_KEY,
);

if ($checksum !== $this->checksumCalculator->calculateForArray($extraOptionsWithoutChecksum)) {
throw new BadRequestHttpException('The extra options have been tampered with.');
}
}
}
10 changes: 10 additions & 0 deletions src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
use Symfony\UX\Autocomplete\AutocompleterRegistry;
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
use Symfony\UX\Autocomplete\Controller\EntityAutocompleteController;
use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
Expand Down Expand Up @@ -116,6 +117,7 @@ private function registerBasicServices(ContainerBuilder $container): void
new Reference('ux.autocomplete.autocompleter_registry'),
new Reference('ux.autocomplete.results_executor'),
new Reference('router'),
new Reference('ux.autocomplete.checksum_calculator'),
])
->addTag('controller.service_arguments')
;
Expand All @@ -127,6 +129,13 @@ private function registerBasicServices(ContainerBuilder $container): void
])
->addTag('maker.command')
;

$container
->register('ux.autocomplete.checksum_calculator', ChecksumCalculator::class)
->setArguments([
'%kernel.secret%',
])
;
}

private function registerFormServices(ContainerBuilder $container): void
Expand All @@ -149,6 +158,7 @@ private function registerFormServices(ContainerBuilder $container): void
$container
->register('ux.autocomplete.choice_type_extension', AutocompleteChoiceTypeExtension::class)
->setArguments([
new Reference('ux.autocomplete.checksum_calculator'),
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved
new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
])
->addTag('form.type_extension');
Expand Down
49 changes: 47 additions & 2 deletions src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;

/**
* Initializes the autocomplete Stimulus controller for any fields with the "autocomplete" option.
Expand All @@ -27,8 +28,12 @@
*/
final class AutocompleteChoiceTypeExtension extends AbstractTypeExtension
{
public function __construct(private ?TranslatorInterface $translator = null)
{
public const CHECKSUM_KEY = '@checksum';
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved

public function __construct(
private readonly ChecksumCalculator $checksumCalculator,
private readonly ?TranslatorInterface $translator = null,
) {
}

public static function getExtendedTypes(): iterable
Expand Down Expand Up @@ -79,6 +84,10 @@ public function finishView(FormView $view, FormInterface $form, array $options):
$values['min-characters'] = $options['min_characters'];
}

if ($options['extra_options']) {
weaverryan marked this conversation as resolved.
Show resolved Hide resolved
$values['url'] = $this->getUrlWithExtraOptions($values['url'], $options['extra_options']);
}

$values['loading-more-text'] = $this->trans($options['loading_more_text']);
$values['no-results-found-text'] = $this->trans($options['no_results_found_text']);
$values['no-more-results-text'] = $this->trans($options['no_more_results_text']);
Expand All @@ -92,6 +101,41 @@ public function finishView(FormView $view, FormInterface $form, array $options):
$view->vars['attr'] = $attr;
}

private function getUrlWithExtraOptions(string $url, array $extraOptions): string
{
$this->validateExtraOptions($extraOptions);

$extraOptions[self::CHECKSUM_KEY] = $this->checksumCalculator->calculateForArray($extraOptions);
$extraOptions = base64_encode(json_encode($extraOptions));

return sprintf(
'%s%s%s',
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved
$url,
$this->hasUrlParameters($url) ? '&' : '?',
http_build_query(['extra_options' => $extraOptions]),
);
}

private function hasUrlParameters(string $url): bool
{
$parsedUrl = parse_url($url);

return isset($parsedUrl['query']);
}

private function validateExtraOptions(array $extraOptions): void
{
foreach ($extraOptions as $optionKey => $option) {
if (!\is_scalar($option) && !\is_array($option) && null !== $option) {
throw new \InvalidArgumentException(sprintf('Extra option with key "%s" must be a scalar value, an array or null. Got "%s".', $optionKey, get_debug_type($option)));
}

if (\is_array($option)) {
$this->validateExtraOptions($option);
}
}
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
Expand All @@ -106,6 +150,7 @@ public function configureOptions(OptionsResolver $resolver): void
'min_characters' => null,
'max_results' => 10,
'preload' => 'focus',
'extra_options' => [],
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved
]);

// if autocomplete_url is passed, then HTML options are already supported
Expand Down
16 changes: 13 additions & 3 deletions src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@
use Symfony\UX\Autocomplete\Doctrine\EntityMetadata;
use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface;

/**
* An entity auto-completer that wraps a form type to get its information.
*
* @internal
*/
final class WrappedEntityTypeAutocompleter implements EntityAutocompleterInterface
final class WrappedEntityTypeAutocompleter implements OptionsAwareEntityAutocompleterInterface
{
private ?FormInterface $form = null;
private ?EntityMetadata $entityMetadata = null;
private array $options = [];

public function __construct(
private string $formType,
Expand Down Expand Up @@ -139,7 +140,7 @@ private function getFormOption(string $name): mixed
private function getForm(): FormInterface
{
if (null === $this->form) {
$this->form = $this->formFactory->create($this->formType);
$this->form = $this->formFactory->create($this->formType, options: $this->options);
}

return $this->form;
Expand Down Expand Up @@ -168,4 +169,13 @@ private function getEntityMetadata(): EntityMetadata

return $this->entityMetadata;
}

public function setOptions(array $options): void
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved
{
if (null !== $this->form) {
throw new \LogicException('The options can only be set before the form is created.');
}

$this->options = $options;
jakubtobiasz marked this conversation as resolved.
Show resolved Hide resolved
}
}
20 changes: 20 additions & 0 deletions src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\Autocomplete;

/**
* Interface for classes that will have an "autocomplete" endpoint exposed with a possibility to pass additional form options.
*/
interface OptionsAwareEntityAutocompleterInterface extends EntityAutocompleterInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have separate Interfaces, what do you think ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like creating "subtype" interfaces like this, but it's not a thing I'd fight for :D. I'm open for changing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No strong opinion on this.

i'm often more concerned about "what could happen later" than i probably should... i'm working on it but habits are hard to change :)

That's why i ask / raise questions but would never "require" changes :))

{
public function setOptions(array $options): void;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<?php

namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;

class CustomGroupByProductAutocompleter extends CustomProductAutocompleter
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
Expand All @@ -14,9 +23,8 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface
{
public function __construct(
private RequestStack $requestStack,
private EntitySearchUtil $entitySearchUtil
)
{
private EntitySearchUtil $entitySearchUtil,
) {
}

public function getEntityClass(): string
Expand Down
Loading
Loading