Skip to content

Commit 4dc90cd

Browse files
committed
[FEATURE] Introduce ArgumentProcessor API
Fluid allows ViewHelpers to specify their API by using `AbstractViewHelper::registerArgument()`, which is usually called in `initializeArguments()` of the ViewHelper. Internally, `ArgumentDefinition` is used to represent the provided argument information and constraints. Among those is the ability to specify the type of an argument as well as if the argument should be a required argument. While the required status is already validated at the parser level, the argument types are validated by the ViewHelper implementation. However, in most cases, Fluid's built-in validation method by `AbstractViewHelper` is used. Unfortunately, this implementation has many flaws: * it only covers a subset of possible types (e. g. it covers "boolean", but not "bool") * It doesn't ensure the correct argument type for scalar arguments (e. g. you might receive an integer in your ViewHelper implementation, even if the argument has been defined as "string") * There are several small inconsistencies In order to fix this in a future Fluid version without introducing too many hard-to-debug breaking changes, a new API is introduced which extracts validation functionality from the `AbstractViewHelper` and allows alternative implementations in the future. It also adds the possibility to pre-process arguments before their validity is checked, which will allow implicit type conversions in the future (e. g. convert "int" to "string"). As a first step, the existing helper methods `isValidType()` and `getFirstElementOfNonEmpty()` are deprecated and extracted into a `LenientArgumentProcessor`, which mimicks the current validation behavior. Like the current implementation, it doesn't modify the argument types, but rather passes them along unmodified. The new API is then called for the pre-processing step in `ViewHelperInvoker` as well as the validation step in `AbstractViewHelper`. In this first implementation, it isn't possible yet to replace the default implementation with an alternative.
1 parent 2431e10 commit 4dc90cd

File tree

7 files changed

+343
-44
lines changed

7 files changed

+343
-44
lines changed

src/Core/ViewHelper/AbstractViewHelper.php

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -333,20 +333,20 @@ public function prepareArguments()
333333
*/
334334
public function validateArguments()
335335
{
336+
// @todo make configurable with Fluid v5
337+
$argumentProcessor = new LenientArgumentProcessor();
336338
$argumentDefinitions = $this->prepareArguments();
337339
foreach ($argumentDefinitions as $argumentName => $registeredArgument) {
340+
// Note: This relies on the TemplateParser to check for missing required arguments
338341
if ($this->hasArgument($argumentName)) {
339342
$value = $this->arguments[$argumentName];
340-
$type = $registeredArgument->getType();
341-
if ($value !== $registeredArgument->getDefaultValue() && $type !== 'mixed') {
343+
if (!$argumentProcessor->isValid($value, $registeredArgument)) {
342344
$givenType = is_object($value) ? get_class($value) : gettype($value);
343-
if (!$this->isValidType($type, $value)) {
344-
throw new \InvalidArgumentException(
345-
'The argument "' . $argumentName . '" was registered with type "' . $type . '", but is of type "' .
346-
$givenType . '" in view helper "' . get_class($this) . '".',
347-
1256475113,
348-
);
349-
}
345+
throw new \InvalidArgumentException(
346+
'The argument "' . $argumentName . '" was registered with type "' . $registeredArgument->getType() . '", but is of type "' .
347+
$givenType . '" in view helper "' . get_class($this) . '".',
348+
1256475113,
349+
);
350350
}
351351
}
352352
}
@@ -358,9 +358,11 @@ public function validateArguments()
358358
* @param string $type
359359
* @param mixed $value
360360
* @return bool
361+
* @deprecated Will be removed in v5. Use the new argument processing API to validate ViewHelper arguments.
361362
*/
362363
protected function isValidType($type, $value)
363364
{
365+
trigger_error('AbstractViewHelper::isValidType() has been deprecated and will be removed in Fluid v5.', E_USER_DEPRECATED);
364366
if ($type === 'object') {
365367
if (!is_object($value)) {
366368
return false;
@@ -396,9 +398,11 @@ protected function isValidType($type, $value)
396398
*
397399
* @param mixed $value
398400
* @return mixed
401+
* @deprecated Will be removed in v5. Use the new argument processing API to validate ViewHelper arguments.
399402
*/
400403
protected function getFirstElementOfNonEmpty($value)
401404
{
405+
trigger_error('AbstractViewHelper::getFirstElementOfNonEmpty() has been deprecated and will be removed in Fluid v5.', E_USER_DEPRECATED);
402406
if (is_array($value)) {
403407
return reset($value);
404408
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file belongs to the package "TYPO3 Fluid".
7+
* See LICENSE.txt that was shipped with this package.
8+
*/
9+
10+
namespace TYPO3Fluid\Fluid\Core\ViewHelper;
11+
12+
interface ArgumentProcessorInterface
13+
{
14+
public function process(mixed $value, ArgumentDefinition $definition): mixed;
15+
public function isValid(mixed $value, ArgumentDefinition $definition): bool;
16+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file belongs to the package "TYPO3 Fluid".
7+
* See LICENSE.txt that was shipped with this package.
8+
*/
9+
10+
namespace TYPO3Fluid\Fluid\Core\ViewHelper;
11+
12+
use ArrayAccess;
13+
use Traversable;
14+
15+
/**
16+
* The LenientArgumentProcessor mimicks the ViewHelper argument validation
17+
* logic used by Fluid up to (and including) 4.x:
18+
*
19+
* 1. Argument types are validated against the isValidateType() implementation
20+
* which previously was part of AbstractViewHelper. For clear violations
21+
* (e. g. a string instead of an array), validation fails.
22+
* 2. Valid types are NOT guaranteed to ViewHelpers in all cases, since scalar
23+
* types are not converted implicitly (meaning: You might receive an integer,
24+
* even if the ViewHelper specifies string as argument type)
25+
* 3. ArrayAccess and Traversible are valid array arguments
26+
* 4. Objects implementing Stringable are valid string arguments
27+
* 5. In addition to PHP's internal types, class names can be specified as well
28+
* 6. Collection types (e. g. "string[]") are possible. Only the first item
29+
* of a collection is validated.
30+
*/
31+
final class LenientArgumentProcessor implements ArgumentProcessorInterface
32+
{
33+
public function process(mixed $value, ArgumentDefinition $definition): mixed
34+
{
35+
// No processing, argument values are passed on unmodified
36+
return $value;
37+
}
38+
39+
public function isValid(mixed $value, ArgumentDefinition $definition): bool
40+
{
41+
// Allow everything for mixed type
42+
if ($definition->getType() === 'mixed') {
43+
return true;
44+
}
45+
46+
// Always allow default value if argument is not required
47+
if (!$definition->isRequired() && $value === $definition->getDefaultValue()) {
48+
return true;
49+
}
50+
51+
// Perform type validation
52+
return $this->isValidType($definition->getType(), $value);
53+
}
54+
55+
/**
56+
* Check whether the defined type matches the value type
57+
*/
58+
private function isValidType(string $type, mixed $value): bool
59+
{
60+
if ($type === 'object') {
61+
if (!is_object($value)) {
62+
return false;
63+
}
64+
} elseif ($type === 'array' || substr($type, -2) === '[]') {
65+
if (!is_array($value) && !$value instanceof ArrayAccess && !$value instanceof Traversable && !empty($value)) {
66+
return false;
67+
}
68+
if (substr($type, -2) === '[]') {
69+
$firstElement = $this->getFirstElementOfNonEmpty($value);
70+
if ($firstElement === null) {
71+
return true;
72+
}
73+
return $this->isValidType(substr($type, 0, -2), $firstElement);
74+
}
75+
} elseif ($type === 'string') {
76+
if (is_object($value) && !method_exists($value, '__toString')) {
77+
return false;
78+
}
79+
} elseif ($type === 'boolean' && !is_bool($value)) {
80+
return false;
81+
} elseif (class_exists($type) && $value !== null && !$value instanceof $type) {
82+
return false;
83+
} elseif (is_object($value) && !is_a($value, $type, true)) {
84+
return false;
85+
}
86+
return true;
87+
}
88+
89+
/**
90+
* Return the first element of the given array, ArrayAccess or Traversable
91+
* that is not empty
92+
*/
93+
private function getFirstElementOfNonEmpty(mixed $value): mixed
94+
{
95+
if (is_array($value)) {
96+
return reset($value);
97+
}
98+
if ($value instanceof Traversable) {
99+
foreach ($value as $element) {
100+
return $element;
101+
}
102+
}
103+
return null;
104+
}
105+
}

src/Core/ViewHelper/ViewHelperInvoker.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public function invoke(string|ViewHelperInterface $viewHelperClassNameOrInstance
4646
}
4747
$argumentDefinitions = $viewHelperResolver->getArgumentDefinitionsForViewHelper($viewHelper);
4848

49+
// @todo make configurable with Fluid v5
50+
$argumentProcessor = new LenientArgumentProcessor();
4951
try {
5052
// Convert nodes to actual values (in uncached context)
5153
$arguments = array_map(
@@ -56,7 +58,9 @@ public function invoke(string|ViewHelperInterface $viewHelperClassNameOrInstance
5658
// Determine arguments defined by the ViewHelper API
5759
$registeredArguments = [];
5860
foreach ($argumentDefinitions as $argumentName => $argumentDefinition) {
59-
$registeredArguments[$argumentName] = $arguments[$argumentName] ?? $argumentDefinition->getDefaultValue();
61+
$registeredArguments[$argumentName] = isset($arguments[$argumentName])
62+
? $argumentProcessor->process($arguments[$argumentName], $argumentDefinition)
63+
: $argumentDefinition->getDefaultValue();
6064
unset($arguments[$argumentName]);
6165
}
6266

tests/Functional/Core/ViewHelper/ViewHelperArgumentTypesTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@
1717

1818
final class ViewHelperArgumentTypesTest extends AbstractFunctionalTestCase
1919
{
20+
#[Test]
21+
public function invalidArgumentTypeUncached(): void
22+
{
23+
$source = '<f:count subject="test" />';
24+
25+
self::expectExceptionCode(1256475113);
26+
27+
$view = new TemplateView();
28+
$view->getRenderingContext()->setCache(self::$cache);
29+
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
30+
$view->render();
31+
}
32+
33+
#[Test]
34+
public function invalidArgumentTypeCached(): void
35+
{
36+
$source = '<f:count subject="test" />';
37+
38+
self::expectExceptionCode(1256475113);
39+
40+
try {
41+
$view = new TemplateView();
42+
$view->getRenderingContext()->setCache(self::$cache);
43+
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
44+
$view->render();
45+
} catch (\Exception) {}
46+
47+
$view = new TemplateView();
48+
$view->getRenderingContext()->setCache(self::$cache);
49+
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
50+
$view->render();
51+
}
52+
2053
public static function scalarArgumentsDataProvider(): array
2154
{
2255
return [

tests/Unit/Core/ViewHelper/AbstractViewHelperTest.php

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public static function getFirstElementOfNonEmptyTestValues(): array
4646

4747
#[DataProvider('getFirstElementOfNonEmptyTestValues')]
4848
#[Test]
49+
#[IgnoreDeprecations]
4950
public function getFirstElementOfNonEmptyReturnsExpectedValue(mixed $input, ?string $expected): void
5051
{
5152
$subject = $this->getMockBuilder(AbstractViewHelper::class)->onlyMethods([])->getMock();
@@ -66,15 +67,6 @@ public function registeringTheSameArgumentNameAgainOverridesArgument(): void
6667
self::assertEquals($expected, $subject->prepareArguments());
6768
}
6869

69-
#[Test]
70-
public function validateArgumentsAcceptsAllObjectsImplementingArrayAccessAsAnArray(): void
71-
{
72-
$subject = $this->getMockBuilder(AbstractViewHelper::class)->onlyMethods(['prepareArguments'])->getMock();
73-
$subject->setArguments(['test' => new \ArrayObject()]);
74-
$subject->expects(self::once())->method('prepareArguments')->willReturn(['test' => new ArgumentDefinition('test', 'array', 'documentation', false)]);
75-
$subject->validateArguments();
76-
}
77-
7870
#[Test]
7971
public function setRenderingContextShouldSetInnerVariables(): void
8072
{
@@ -102,31 +94,6 @@ public function renderChildrenCallsRenderChildrenClosureIfSet(): void
10294
self::assertEquals('foobar', $result);
10395
}
10496

105-
public static function validateArgumentsErrorsDataProvider(): array
106-
{
107-
return [
108-
[new ArgumentDefinition('test', 'boolean', '', true), ['bad']],
109-
[new ArgumentDefinition('test', 'string', '', true), new \ArrayIterator(['bar'])],
110-
[new ArgumentDefinition('test', 'DateTime', '', true), new \ArrayIterator(['bar'])],
111-
[new ArgumentDefinition('test', 'DateTime', '', true), 'test'],
112-
[new ArgumentDefinition('test', 'integer', '', true), new \ArrayIterator(['bar'])],
113-
[new ArgumentDefinition('test', 'object', '', true), 'test'],
114-
[new ArgumentDefinition('test', 'string[]', '', true), [new \DateTime('now'), 'test']],
115-
];
116-
}
117-
118-
#[DataProvider('validateArgumentsErrorsDataProvider')]
119-
#[Test]
120-
public function validateArgumentsErrors(ArgumentDefinition $argument, array|string|object $value): void
121-
{
122-
$this->expectException(\InvalidArgumentException::class);
123-
$subject = $this->getMockBuilder(AbstractViewHelper::class)->onlyMethods(['hasArgument', 'prepareArguments'])->getMock();
124-
$subject->expects(self::once())->method('prepareArguments')->willReturn([$argument->getName() => $argument]);
125-
$subject->expects(self::once())->method('hasArgument')->with($argument->getName())->willReturn(true);
126-
$subject->setArguments([$argument->getName() => $value]);
127-
$subject->validateArguments();
128-
}
129-
13097
#[Test]
13198
public function validateAdditionalArgumentsThrowsExceptionIfNotEmpty(): void
13299
{

0 commit comments

Comments
 (0)