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

Add visitor for FileInput #37

Merged
merged 1 commit into from
Mar 4, 2024
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
66 changes: 45 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,27 +54,6 @@ validators attached to the `InputFilter`?

This command introspects your form's input filter and generates the most specific array shape possible.

## Beware

If your code changes forms at run time - adding and removing elements, changing `required` properties or populating
`<select>` options - this tool won't know. The array shape it generates can serve as a good starting point, but will
need manual tweaks.

This tool aims to cover all the filters or validators installed when you `composer require laminas/laminas-form`. If it
encounters one it doesn't know about, it silently ignores it. See the [Customisation] section for pointers on handling
these.

### `Cannot get type for 'foo'`

It is possible to build an input filter with a combination of filters and validators that can never produce a result.
For instance, a `Boolean` filter with the `casting` option set to `true` will ony ever output a `bool` type. If you
follow that with a `Barcode` validator, the element can never validate. When the command encounters a situation like that
it will report a "Cannot get type" error.

If you see this error when parsing an existing form that has been functioning fine for years, you've hit a bug. Please
raise an issue with a _small_ example form that reproduces the error. Or, better yet, create a PR with a failing
test :smiley:

## Configuration

All configuration is stored under the `laminas-form-shape` configuration key. The examples below assume you are using
Expand Down Expand Up @@ -110,6 +89,30 @@ return [
];
```

### File elements

File elements are used for handling [form uploads]. The `FileInput` validates both the array notation used by
`Laminas\Http\PhpEnvironment\Request` and the PSR-7 `UploadFileInterface` used by applications such as Mezzio. It's
highly likely your controllers / handlers will only be using one of these. If so, specify which one in the
configuration:

```php
return [
'laminas-form-shape' => [
'input' => [
'file' => [
// Laminas MVC applications
'laminas' => true,
'psr-7' => false,
// Mezzio applications
// 'laminas' => false,
// 'psr-7' => true,
],
],
],
];
```

### Filters

Each filter defined in your form's `InputFilter` will be processed by a number of [visitors]. Each visitor takes the
Expand Down Expand Up @@ -262,6 +265,27 @@ return [
];
```

## Beware

If your code changes forms at run time - adding and removing elements, changing `required` properties or populating
`<select>` options - this tool won't know. The array shape it generates can serve as a good starting point, but will
need manual tweaks.

This tool aims to cover all the filters or validators installed when you `composer require laminas/laminas-form`. If it
encounters one it doesn't know about, it silently ignores it. See the [Customisation] section for pointers on handling
these.

### `Cannot get type for 'foo'`

It is possible to build an input filter with a combination of filters and validators that can never produce a result.
For instance, a `Boolean` filter with the `casting` option set to `true` will ony ever output a `bool` type. If you
follow that with a `Barcode` validator, the element can never validate. When the command encounters a situation like that
it will report a "Cannot get type" error.

If you see this error when parsing an existing form that has been functioning fine for years, you've hit a bug. Please
raise an issue with a _small_ example form that reproduces the error. Or, better yet, create a PR with a failing
test :smiley:

## Custom filters and validators

To come, once things settle down...
Expand Down
11 changes: 11 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@
<code><![CDATA[addNotEmptyProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/InputFilter/FileInputVisitorFactoryTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[configProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/InputFilter/FileInputVisitorTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[styleProvider]]></code>
<code><![CDATA[widenTypeProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/InputFilter/InputFilterVisitorTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[collectionProvider]]></code>
Expand Down
19 changes: 15 additions & 4 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use Kynx\Laminas\FormShape\Form\FormVisitorFactory;
use Kynx\Laminas\FormShape\InputFilter\ArrayInputVisitor;
use Kynx\Laminas\FormShape\InputFilter\ArrayInputVisitorFactory;
use Kynx\Laminas\FormShape\InputFilter\FileInputVisitor;
use Kynx\Laminas\FormShape\InputFilter\FileInputVisitorFactory;
use Kynx\Laminas\FormShape\InputFilter\InputFilterVisitor;
use Kynx\Laminas\FormShape\InputFilter\InputFilterVisitorFactory;
use Kynx\Laminas\FormShape\InputFilter\InputVisitor;
Expand Down Expand Up @@ -86,6 +88,7 @@
* input-visitors: InputVisitorArray,
* filter?: array<string, mixed>,
* validator: array<string, mixed>,
* input: array<string, mixed>,
* }
* @psalm-type FormShapeConfigurationArray = array{
* laminas-cli: array,
Expand Down Expand Up @@ -158,6 +161,7 @@ private function getLaminasFormShapeConfig(): array
],
'input-visitors' => [
ArrayInputVisitor::class,
FileInputVisitor::class,
InputVisitor::class,
],
'filter' => [
Expand Down Expand Up @@ -204,6 +208,12 @@ private function getLaminasFormShapeConfig(): array
],
],
],
'input' => [
'file' => [
'laminas' => true,
'psr-7' => true,
],
],
];
}

Expand All @@ -224,18 +234,19 @@ private function getDependencyConfig(): array
AllowListVisitor::class => AllowListVisitorFactory::class,
ArrayInputVisitor::class => ArrayInputVisitorFactory::class,
ExplodeVisitor::class => ExplodeVisitorFactory::class,
FileInputVisitor::class => FileInputVisitorFactory::class,
FileValidatorVisitor::class => FileValidatorVisitorFactory::class,
FileWriter::class => FileWriterFactory::class,
FormLocator::class => FormLocatorFactory::class,
FormProcessor::class => FormProcessorFactory::class,
NetteCodeGenerator::class => NetteCodeGeneratorFactory::class,
PrettyPrinter::class => PrettyPrinterFactory::class,
PsalmTypeCommand::class => PsalmTypeCommandFactory::class,
FileWriter::class => FileWriterFactory::class,
FormVisitor::class => FormVisitorFactory::class,
InArrayVisitor::class => InArrayVisitorFactory::class,
InputFilterVisitor::class => InputFilterVisitorFactory::class,
InputVisitor::class => InputVisitorFactory::class,
NetteCodeGenerator::class => NetteCodeGeneratorFactory::class,
NonEmptyStringVisitor::class => NonEmptyStringVisitorFactory::class,
PrettyPrinter::class => PrettyPrinterFactory::class,
PsalmTypeCommand::class => PsalmTypeCommandFactory::class,
RegexVisitor::class => RegexVisitorFactory::class,
TypeNamer::class => TypeNamerFactory::class,
],
Expand Down
3 changes: 2 additions & 1 deletion src/InputFilter/AbstractInputVisitorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Kynx\Laminas\FormShape\ConfigProvider;
use Kynx\Laminas\FormShape\FilterVisitorInterface;
use Kynx\Laminas\FormShape\InputVisitorInterface;
use Kynx\Laminas\FormShape\ValidatorVisitorInterface;
use Psr\Container\ContainerInterface;

Expand All @@ -14,7 +15,7 @@
*/
abstract readonly class AbstractInputVisitorFactory
{
abstract public function __invoke(ContainerInterface $container): AbstractInputVisitor;
abstract public function __invoke(ContainerInterface $container): InputVisitorInterface;

/**
* @return array<FilterVisitorInterface>
Expand Down
12 changes: 12 additions & 0 deletions src/InputFilter/FileInputStyle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kynx\Laminas\FormShape\InputFilter;

enum FileInputStyle
{
case Laminas;
case Psr7;
case Both;
}
144 changes: 144 additions & 0 deletions src/InputFilter/FileInputVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace Kynx\Laminas\FormShape\InputFilter;

use Kynx\Laminas\FormShape\FilterVisitorInterface;
use Kynx\Laminas\FormShape\InputVisitorInterface;
use Kynx\Laminas\FormShape\Psalm\TypeUtil;
use Kynx\Laminas\FormShape\Validator\FileValidatorVisitor;
use Kynx\Laminas\FormShape\ValidatorVisitorInterface;
use Laminas\Filter\Callback;
use Laminas\Filter\FilterInterface;
use Laminas\InputFilter\FileInput;
use Laminas\InputFilter\InputInterface;
use Laminas\Validator\File\UploadFile;
use Laminas\Validator\ValidatorInterface;
use Psalm\Type;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Atomic\TString;
use Psalm\Type\Union;
use Psr\Http\Message\UploadedFileInterface;

use function array_filter;
use function array_map;
use function array_unshift;
use function is_callable;

final readonly class FileInputVisitor implements InputVisitorInterface
{
/**
* @param array<FilterVisitorInterface> $filterVisitors
* @param array<ValidatorVisitorInterface> $validatorVisitors
*/
public function __construct(
private FileInputStyle $style,
private array $filterVisitors,
private array $validatorVisitors
) {
}

public function visit(InputInterface $input): ?Union
{
if (! $input instanceof FileInput) {
return null;
}

$initial = new Union($this->getInitialTypes());
$union = $initial->getBuilder()->freeze();

$validators = $this->prependUploadValidator($input, array_map(
static fn (array $queueItem): ValidatorInterface => $queueItem['instance'],
$input->getValidatorChain()->getValidators()
));

foreach ($validators as $validator) {
$union = $this->visitValidators($validator, $union);
}

if (! $input->continueIfEmpty() && ($input->allowEmpty() || ! $input->isRequired())) {
$union = TypeUtil::widen($union, $initial);
}

foreach ($input->getFilterChain()->getIterator() as $filter) {
if (is_callable($filter) && ! $filter instanceof FilterInterface) {
$filter = new Callback($filter);
}

$union = $this->visitFilters($filter, $union);
}

if ($input->hasFallback()) {
$union = Type::combineUnionTypes($union, TypeUtil::toStrictUnion($input->getFallbackValue()));
}

if ($union->getAtomicTypes() === []) {
throw InputVisitorException::cannotGetInputType($input);
}

return $union;
}

/**
* @return non-empty-array<Atomic>
*/
private function getInitialTypes(): array
{
$types = [new TNull(), new TString()];
if ($this->style === FileInputStyle::Laminas || $this->style === FileInputStyle::Both) {
$types[] = FileValidatorVisitor::getUploadArray();
}
if ($this->style === FileInputStyle::Psr7 || $this->style === FileInputStyle::Both) {
$types[] = new TNamedObject(UploadedFileInterface::class);
}

return $types;
}

/**
* @param array<ValidatorInterface> $validators
* @return array<ValidatorInterface>
*/
private function prependUploadValidator(FileInput $input, array $validators): array
{
if (! $input->getAutoPrependUploadValidator()) {
return $validators;
}

$hasUploadValidator = (bool) array_filter(
$validators,
static fn (ValidatorInterface $validator): bool => $validator instanceof UploadFile
);

if ($hasUploadValidator) {
return $validators;
}

if (! $input->continueIfEmpty() && $input->isRequired() && ! $input->allowEmpty()) {
array_unshift($validators, new UploadFile());
}

return $validators;
}

private function visitValidators(ValidatorInterface $validator, Union $union): Union
{
foreach ($this->validatorVisitors as $visitor) {
$union = $visitor->visit($validator, $union);
}

return $union;
}

private function visitFilters(FilterInterface $filter, Union $union): Union
{
foreach ($this->filterVisitors as $visitor) {
$union = $visitor->visit($filter, $union);
}

return $union;
}
}
38 changes: 38 additions & 0 deletions src/InputFilter/FileInputVisitorFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Kynx\Laminas\FormShape\InputFilter;

use Kynx\Laminas\FormShape\ConfigProvider;
use Psr\Container\ContainerInterface;

use function array_merge;

/**
* @psalm-import-type FormShapeConfigurationArray from ConfigProvider
*/
final readonly class FileInputVisitorFactory extends AbstractInputVisitorFactory
{
public function __invoke(ContainerInterface $container): FileInputVisitor
{
/** @var FormShapeConfigurationArray $config */
$config = $container->get('config') ?? [];
/** @var array<string, bool> $inputConfig */
$inputConfig = $config['laminas-form-shape']['input']['file'] ?? [];
$fileConfig = array_merge(['laminas' => true, 'psr-7' => true], $inputConfig);

$style = FileInputStyle::Both;
if ($fileConfig['laminas'] && ! $fileConfig['psr-7']) {
$style = FileInputStyle::Laminas;
} elseif (! $fileConfig['laminas'] && $fileConfig['psr-7']) {
$style = FileInputStyle::Psr7;
}

return new FileInputVisitor(
$style,
$this->getFilterVisitors($container),
$this->getValidatorVisitors($container),
);
}
}
Loading