Skip to content

Commit

Permalink
Merge pull request #37 from kynx/file-input-visitor
Browse files Browse the repository at this point in the history
Add visitor for FileInput
  • Loading branch information
kynx authored Mar 4, 2024
2 parents bd20aa1 + 306618f commit 00dc079
Show file tree
Hide file tree
Showing 10 changed files with 584 additions and 33 deletions.
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

0 comments on commit 00dc079

Please sign in to comment.