Skip to content
Open
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"Respect\\Dev\\": "src-dev/",
"Respect\\Validation\\": "tests/unit/",
"Respect\\Validation\\Test\\": "tests/src/"
}
},
"files": ["tests/src/Functions/namedFunctionWithTransformer.php"]
},
"scripts": {
"bench-profile": "vendor/bin/phpbench xdebug:profile",
Expand Down
54 changes: 54 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
SPDX-License-Identifier: MIT
SPDX-FileCopyrightText: (c) Respect Project Contributors
SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
-->

# Configuration
Expand All @@ -17,3 +18,56 @@ use Respect\Validation\ContainerRegistry;

ContainerRegistry::setContainer($yourPsr11Container);
```

## Service injection

When a validator is created — through the fluent API or through PHP attributes — the constructor parameters that are not filled by the given arguments are resolved from the container, as long as the parameter type is a class or interface the container can provide.

Some bundled validators rely on services that you might want to customize:

| Validator | Service |
| :----------------------------------------------- | :-------------------------------------------------------------------------- |
| [CountryCode](validators/CountryCode.md) | `Sokil\IsoCodes\Database\Countries` |
| [CurrencyCode](validators/CurrencyCode.md) | `Sokil\IsoCodes\Database\Currencies` |
| [Email](validators/Email.md) | `Egulias\EmailValidator\EmailValidator` |
| [LanguageCode](validators/LanguageCode.md) | `Sokil\IsoCodes\Database\Languages` |
| [Phone](validators/Phone.md) | `libphonenumber\PhoneNumberUtil`, `Sokil\IsoCodes\Database\Countries` |
| [SubdivisionCode](validators/SubdivisionCode.md) | `Sokil\IsoCodes\Database\Countries`, `Sokil\IsoCodes\Database\Subdivisions` |
| [Uuid](validators/Uuid.md) | `Ramsey\Uuid\UuidFactory` |

You do not need to define any of those services yourself: when the container does not provide a service, validators fall back to a sensible default. Define a service only when you want to customize it:

```php
use Respect\Validation\ContainerRegistry;
use Sokil\IsoCodes\Database\Countries;
use Sokil\IsoCodes\TranslationDriver\SymfonyTranslationDriver;

ContainerRegistry::setContainer(ContainerRegistry::createContainer([
Countries::class => new Countries(null, new SymfonyTranslationDriver()),
]));

v::countryCode()->assert('BR');
```

Note that a few kinds of constructor parameters are never injected:

- Parameters already filled by the given arguments.
- Variadic and union-typed parameters.
- Parameters whose type is in the unresolvable types list, which are value-like or rule-like and must come from the arguments or from the parameter default. By default, the list includes `DateTimeImmutable`, `DateTime`, `DateTimeInterface`, and `Validator`.

You can customize the unresolvable types by overriding the `ArgumentsResolver` service in the container — see [Custom arguments resolver](#custom-arguments-resolver) below.

## Custom arguments resolver

The injection behavior is defined by the `Respect\Validation\ArgumentsResolver` interface, whose default implementation is `Respect\Validation\ContainerArgumentsResolver`. If you want a different resolution strategy — creating services directly instead of going through a container, for example — define your own implementation in the container:

```php
use Respect\Validation\ArgumentsResolver;
use Respect\Validation\ContainerRegistry;

ContainerRegistry::setContainer(ContainerRegistry::createContainer([
ArgumentsResolver::class => new MyArgumentsResolver(),
]));
```

Both the validator factory and the [Attributes](validators/Attributes.md) validator use the resolver, so a custom implementation affects rules created through the fluent API and through PHP attributes alike.
1 change: 1 addition & 0 deletions docs/validators/Attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ v::attributes()->assert(new Person('', 'not a date', 'not an email', 'not a phon
- If the object has no attributes, the validation will always pass.
- When the property is nullable, this validator will wrap the validator on the property into [NullOr](NullOr.md) validator.
- This validator has no templates because it uses the templates of the validators that are applied to the properties.
- When created with `v::attributes()`, the validators used as attributes will have their service dependencies — such as `PhoneNumberUtil` for [Phone](Phone.md) — resolved according to the [container configuration](../configuration.md). When instantiated directly with `new Attributes()`, attribute validators are created with their default dependencies instead.

## Categorization

Expand Down
9 changes: 9 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,13 @@
<exclude-pattern>tests/Pest.php</exclude-pattern>
<exclude-pattern>tests/feature</exclude-pattern>
</rule>
<rule ref="Squiz.Functions.GlobalFunction.Found">
<exclude-pattern>tests/src/Functions/functionWithNonExistentType.php</exclude-pattern>
</rule>
<rule ref="Squiz.Functions.GlobalFunction.Found">
<exclude-pattern>tests/src/Functions/namedFunctionWithTransformer.php</exclude-pattern>
</rule>
<rule ref="PSR2.Files.EndFileNewline.NoneFound">
<exclude-pattern>tests/src/Functions/namedFunctionWithTransformer.php</exclude-pattern>
</rule>
</ruleset>
2 changes: 2 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ parameters:
# reading them in tests, so treat TestCall as a property bag.
- Pest\PendingCalls\TestCall
ignoreErrors:
- identifier: class.notFound
path: tests/src/Functions/functionWithNonExistentType.php
-
# Why: SimpleXMLElement is weird and doesn't implement anything ArrayAccess-like
message: '/Instanceof between mixed and SimpleXMLElement will always evaluate to false\./'
Expand Down
5 changes: 3 additions & 2 deletions src-dev/Commands/LintMixinCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Respect\Dev\CodeGen\InterfaceConfig;
use Respect\Dev\Differ\ConsoleDiffer;
use Respect\Dev\Differ\Item;
use Respect\Validation\ArgumentsResolver;
use Respect\Validation\Mixins\Chain;
use Respect\Validation\Validator;
use Respect\Validation\ValidatorBuilder;
Expand Down Expand Up @@ -70,8 +71,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$generator = new MixinGenerator(
config: $config,
methodBuilder: new MethodBuilder(
excludedTypePrefixes: ['Sokil', 'Egulias'],
excludedTypeNames: ['finfo'],
excludedTypePrefixes: ['Sokil', 'Egulias', 'libphonenumber', 'Ramsey'],
excludedTypeNames: ['finfo', ArgumentsResolver::class],
),
interfaces: [
new InterfaceConfig(
Expand Down
4 changes: 4 additions & 0 deletions src-dev/Markdown/Linters/ValidatorHeaderLinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ReflectionUnionType;
use Respect\Dev\Markdown\File;
use Respect\Dev\Markdown\Linter;
use Respect\Validation\ArgumentsResolver;

use function array_filter;
use function array_keys;
Expand Down Expand Up @@ -174,6 +175,9 @@ private function getParameter(ReflectionParameter $reflection): array|null
if (
str_starts_with($type->getName(), 'Sokil')
|| str_starts_with($type->getName(), 'Egulias')
|| str_starts_with($type->getName(), 'libphonenumber')
|| str_starts_with($type->getName(), 'Ramsey')
|| $type->getName() === ArgumentsResolver::class
|| $type->getName() === 'finfo'
) {
return null;
Expand Down
25 changes: 25 additions & 0 deletions src/ArgumentsResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation;

use ReflectionFunctionAbstract;

interface ArgumentsResolver
{
/**
* Augments the given arguments with values for the parameters they do not already fill.
*
* @param array<int|string, mixed> $arguments
*
* @return array<int|string, mixed>
*/
public function resolve(ReflectionFunctionAbstract $function, array $arguments): array;
}
128 changes: 128 additions & 0 deletions src/ContainerArgumentsResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation;

use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Psr\Container\ContainerInterface;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;

use function array_filter;
use function array_is_list;
use function array_key_exists;
use function array_keys;
use function class_exists;
use function count;
use function in_array;
use function interface_exists;
use function is_int;

final class ContainerArgumentsResolver implements ArgumentsResolver
{
/** @var array<string, list<array{int, string, class-string}>> */
private array $injectableParametersCache = [];

/** @param array<class-string<object>> $unresolvableTypes */
public function __construct(
private readonly ContainerInterface $container,
private readonly array $unresolvableTypes = [
DateTimeImmutable::class,
DateTime::class,
DateTimeInterface::class,
Validator::class,
],
) {
}

/**
* @param array<int|string, mixed> $arguments
*
* @return array<int|string, mixed>
*/
public function resolve(ReflectionFunctionAbstract $function, array $arguments): array
{
if (count($arguments) >= $function->getNumberOfParameters()) {
return $arguments;
}

$injectableParameters = $this->injectableParameters($function);
if ($injectableParameters === []) {
return $arguments;
}

$positionalArgumentsCount = count(
array_is_list($arguments) ? $arguments : array_filter(array_keys($arguments), is_int(...)),
);

foreach ($injectableParameters as [$position, $name, $type]) {
if ($position < $positionalArgumentsCount || array_key_exists($name, $arguments)) {
continue;
}

if (!$this->container->has($type)) {
continue;
}

$arguments[$name] = $this->container->get($type);
}

return $arguments;
}

/** @return list<array{int, string, class-string}> */
private function injectableParameters(ReflectionFunctionAbstract $function): array
{
$cacheKey = $this->createCacheKey($function);
if (isset($this->injectableParametersCache[$cacheKey])) {
return $this->injectableParametersCache[$cacheKey];
}

$parameters = [];
foreach ($function->getParameters() as $parameter) {
$type = $parameter->getType();
if ($parameter->isVariadic() || !$type instanceof ReflectionNamedType || $type->isBuiltin()) {
continue;
}

$typeName = $type->getName();
if (!class_exists($typeName) && !interface_exists($typeName)) {
continue;
}

if (in_array($typeName, $this->unresolvableTypes, true)) {
continue;
}

$parameters[] = [$parameter->getPosition(), $parameter->getName(), $typeName];
}

return $this->injectableParametersCache[$cacheKey] = $parameters;
}

private function createCacheKey(ReflectionFunctionAbstract $function): string
{
if ($function instanceof ReflectionMethod) {
return $function->class . '::' . $function->name;
}

if (!$function->isClosure()) {
return $function->name;
}

$file = $function->getFileName() ?: 'internal';
$line = $function->getStartLine() ?: 0;

return $function->getName() . '@' . $file . ':' . $line;
}
}
4 changes: 4 additions & 0 deletions src/ContainerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ public static function createContainer(array $definitions = []): Container
'respect.validation.formatter.messages' => autowire(NestedArrayFormatter::class),
'respect.validation.ignored_backtrace_paths' => [__DIR__ . '/ValidatorBuilder.php'],
'respect.validation.rule_factory.namespaces' => ['Respect\\Validation\\Validators'],
ArgumentsResolver::class => factory(
static fn(Container $container) => new ContainerArgumentsResolver($container),
),
ValidatorFactory::class => factory(static fn(Container $container) => new NamespacedValidatorFactory(
$container->get(Transformer::class),
$container->get('respect.validation.rule_factory.namespaces'),
$container->get(ArgumentsResolver::class),
)),
Quoter::class => create(CodeQuoter::class)->constructor(120),
Handler::class => factory(static function (Container $container) {
Expand Down
19 changes: 18 additions & 1 deletion src/NamespacedValidatorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
public function __construct(
private Transformer $transformer,
private array $rulesNamespaces,
private ArgumentsResolver|null $argumentsResolver = null,
) {
}

Expand Down Expand Up @@ -88,11 +89,27 @@ private function createRule(string $ruleName, array $arguments = []): Validator
}

try {
return $reflection->newInstanceArgs($arguments);
return $reflection->newInstanceArgs($this->resolveArguments($reflection, $arguments));
} catch (ReflectionException) {
throw new InvalidClassException(
sprintf('"%s" could not be instantiated with arguments %s', $ruleName, stringify($arguments)),
);
}
}

/**
* @param ReflectionClass<Validator> $reflection
* @param array<int, mixed> $arguments
*
* @return array<int|string, mixed>
*/
private function resolveArguments(ReflectionClass $reflection, array $arguments): array
{
$constructor = $reflection->getConstructor();
if ($constructor === null || $this->argumentsResolver === null) {
return $arguments;
}

return $this->argumentsResolver->resolve($constructor, $arguments);
}
}
Loading
Loading