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
75 changes: 14 additions & 61 deletions src/Metadata/Extractor/AbstractResourceExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

namespace ApiPlatform\Metadata\Extractor;

use ApiPlatform\Metadata\Util\ContainerParameterResolver;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;

/**
* Base file extractor.
Expand All @@ -24,13 +24,14 @@
abstract class AbstractResourceExtractor implements ResourceExtractorInterface
{
protected ?array $resources = null;
private array $collectedParameters = [];
private readonly ContainerParameterResolver $parameterResolver;

/**
* @param string[] $paths
*/
public function __construct(protected array $paths, private readonly ?ContainerInterface $container = null)
{
$this->parameterResolver = new ContainerParameterResolver($container);
}

/**
Expand All @@ -56,68 +57,20 @@ public function getResources(): array
abstract protected function extractPath(string $path): void;

/**
* Recursively replaces placeholders with the service container parameters.
*
* @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php
*
* @copyright (c) Fabien Potencier <fabien@symfony.com>
*
* @param mixed $value The source which might contain "%placeholders%"
*
* @throws \RuntimeException When a container value is not a string or a numeric value
*
* @return mixed The source with the placeholders replaced by the container
* parameters. Arrays are resolved recursively.
* Recursively replaces %param% placeholders with the service container parameters.
*/
protected function resolve(mixed $value): mixed
{
if (null === $this->container) {
return $value;
}

if (\is_array($value)) {
foreach ($value as $key => $val) {
$value[$key] = $this->resolve($val);
}

return $value;
}

if (!\is_string($value)) {
return $value;
}

$escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) {
$parameter = $match[1] ?? null;

// skip %%
if (!isset($parameter)) {
return '%%';
}

if (preg_match('/^env\(\w+\)$/', $parameter)) {
throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter));
}

if (\array_key_exists($parameter, $this->collectedParameters)) {
return $this->collectedParameters[$parameter];
}

if ($this->container instanceof SymfonyContainerInterface) {
$resolved = $this->container->getParameter($parameter);
} else {
$resolved = $this->container->get($parameter);
}

if (\is_string($resolved) || is_numeric($resolved)) {
$this->collectedParameters[$parameter] = $resolved;

return (string) $resolved;
}

throw new \RuntimeException(\sprintf('The container parameter "%s", used in the resource configuration value "%s", must be a string or numeric, but it is of type %s.', $parameter, $value, \gettype($resolved)));
}, $value);
return $this->parameterResolver->resolve($value);
}

return str_replace('%%', '%', $escapedValue);
/**
* Resolves a container parameter in an ExpressionLanguage field (security, condition, …) only
* when the whole trimmed value is a single %param% reference, leaving real expressions (and
* their modulo "%") untouched.
*/
protected function resolveExpressionPlaceholder(mixed $value): mixed
{
return $this->parameterResolver->resolveExpressionPlaceholder($value);
}
}
13 changes: 10 additions & 3 deletions src/Metadata/Extractor/ResourceExtractorTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,25 @@ private function buildArrayValue(\SimpleXMLElement|array|null $resource, string

/**
* Transforms an attribute's value in a PHP value.
*
* Container parameters (%param%) found in plain string values are resolved against the
* service container. ExpressionLanguage fields (security, conditions, …) must opt out via
* $resolve = false so their %param% tokens reach the expression engine untouched, then route
* the raw value through resolveExpressionPlaceholder() to recover whole-string %param% refs.
*/
private function phpize(\SimpleXMLElement|array|null $resource, string $key, string $type, mixed $default = null): array|bool|int|string|null
private function phpize(\SimpleXMLElement|array|null $resource, string $key, string $type, mixed $default = null, bool $resolve = true): array|bool|int|string|null
{
if (!isset($resource[$key])) {
return $default;
}

switch ($type) {
case 'bool|string':
return \is_bool($resource[$key]) || \in_array((string) $resource[$key], ['1', '0', 'true', 'false'], true) ? $this->phpize($resource, $key, 'bool') : $this->phpize($resource, $key, 'string');
return \is_bool($resource[$key]) || \in_array((string) $resource[$key], ['1', '0', 'true', 'false'], true) ? $this->phpize($resource, $key, 'bool') : $this->phpize($resource, $key, 'string', resolve: $resolve);
case 'string':
return (string) $resource[$key];
$value = (string) $resource[$key];

return $resolve ? $this->resolve($value) : $value;
case 'integer':
return (int) $resource[$key];
case 'bool':
Expand Down
8 changes: 4 additions & 4 deletions src/Metadata/Extractor/XmlResourceExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array
'acceptPatch' => $this->phpize($resource, 'acceptPatch', 'string'),
'status' => $this->phpize($resource, 'status', 'integer'),
'host' => $this->phpize($resource, 'host', 'string'),
'condition' => $this->phpize($resource, 'condition', 'string'),
'condition' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'condition', 'string', resolve: false)),
'controller' => $this->phpize($resource, 'controller', 'string'),
'types' => $this->buildArrayValue($resource, 'type'),
'formats' => $this->buildFormats($resource, 'formats'),
Expand Down Expand Up @@ -132,11 +132,11 @@ private function buildBase(\SimpleXMLElement $resource): array
'paginationType' => $this->phpize($resource, 'paginationType', 'string'),
'processor' => $this->phpize($resource, 'processor', 'string'),
'provider' => $this->phpize($resource, 'provider', 'string'),
'security' => $this->phpize($resource, 'security', 'string'),
'security' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'security', 'string', resolve: false)),
'securityMessage' => $this->phpize($resource, 'securityMessage', 'string'),
'securityPostDenormalize' => $this->phpize($resource, 'securityPostDenormalize', 'string'),
'securityPostDenormalize' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'securityPostDenormalize', 'string', resolve: false)),
'securityPostDenormalizeMessage' => $this->phpize($resource, 'securityPostDenormalizeMessage', 'string'),
'securityPostValidation' => $this->phpize($resource, 'securityPostValidation', 'string'),
'securityPostValidation' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'securityPostValidation', 'string', resolve: false)),
'securityPostValidationMessage' => $this->phpize($resource, 'securityPostValidationMessage', 'string'),
'normalizationContext' => isset($resource->normalizationContext->values) ? $this->buildValues($resource->normalizationContext->values) : null,
'denormalizationContext' => isset($resource->denormalizationContext->values) ? $this->buildValues($resource->denormalizationContext->values) : null,
Expand Down
8 changes: 4 additions & 4 deletions src/Metadata/Extractor/YamlResourceExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ private function buildExtendedBase(array $resource): array
'sunset' => $this->phpize($resource, 'sunset', 'string'),
'acceptPatch' => $this->phpize($resource, 'acceptPatch', 'string'),
'host' => $this->phpize($resource, 'host', 'string'),
'condition' => $this->phpize($resource, 'condition', 'string'),
'condition' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'condition', 'string', resolve: false)),
'controller' => $this->phpize($resource, 'controller', 'string'),
'queryParameterValidationEnabled' => $this->phpize($resource, 'queryParameterValidationEnabled', 'bool'),
'types' => $this->buildArrayValue($resource, 'types'),
Expand Down Expand Up @@ -155,11 +155,11 @@ private function buildBase(array $resource): array
'paginationType' => $this->phpize($resource, 'paginationType', 'string'),
'processor' => $this->phpize($resource, 'processor', 'string'),
'provider' => $this->phpize($resource, 'provider', 'string'),
'security' => $this->phpize($resource, 'security', 'string'),
'security' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'security', 'string', resolve: false)),
'securityMessage' => $this->phpize($resource, 'securityMessage', 'string'),
'securityPostDenormalize' => $this->phpize($resource, 'securityPostDenormalize', 'string'),
'securityPostDenormalize' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'securityPostDenormalize', 'string', resolve: false)),
'securityPostDenormalizeMessage' => $this->phpize($resource, 'securityPostDenormalizeMessage', 'string'),
'securityPostValidation' => $this->phpize($resource, 'securityPostValidation', 'string'),
'securityPostValidation' => $this->resolveExpressionPlaceholder($this->phpize($resource, 'securityPostValidation', 'string', resolve: false)),
'securityPostValidationMessage' => $this->phpize($resource, 'securityPostValidationMessage', 'string'),
'input' => $this->phpize($resource, 'input', 'bool|string'),
'output' => $this->phpize($resource, 'output', 'bool|string'),
Expand Down
27 changes: 27 additions & 0 deletions src/Metadata/Tests/Extractor/XmlExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\User;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Symfony\Component\Serializer\Exception\ExceptionInterface;

/**
Expand Down Expand Up @@ -429,6 +430,32 @@ public function testValidXML(): void
], $extractor->getResources());
}

public function testContainerParametersAreResolved(): void
{
$parameters = [
'user.class' => User::class,
'user.route_prefix' => '/admin',
'user.security' => 'is_granted("ROLE_ADMIN")',
];
$container = $this->createStub(ContainerInterface::class);
$container->method('get')->willReturnCallback(static fn (string $id): string => $parameters[$id]);

$extractor = new XmlResourceExtractor([__DIR__.'/xml/parameters.xml'], $container);
$resources = $extractor->getResources();

$this->assertArrayHasKey(User::class, $resources);

// scalar string field: %param% resolved anywhere in the string
$this->assertSame('/admin', $resources[User::class][0]['routePrefix']);

// expression field, whole-string %param%: resolved to the stored expression so it reaches
// ExpressionLanguage (the literal #8104 case, which throws if left as "%user.security%")
$this->assertSame('is_granted("ROLE_ADMIN")', $resources[User::class][0]['security']);

// expression field with a real expression (no whole-string param): left untouched
$this->assertSame('is_granted("ROLE_USER")', $resources[User::class][0]['operations'][0]['security']);
}

#[DataProvider('getInvalidPaths')]
public function testInvalidXML(string $path, string $error): void
{
Expand Down
27 changes: 27 additions & 0 deletions src/Metadata/Tests/Extractor/YamlExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\User;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;

/**
* @author Vincent Chalamon <vincentchalamon@gmail.com>
Expand Down Expand Up @@ -593,6 +594,32 @@ public function testExtendingYamlResourcesByMultipleYamlFiles(): void
$this->assertCount(1, $resources[User::class][1]['operations']);
}

public function testContainerParametersAreResolved(): void
{
$parameters = [
'user.class' => User::class,
'user.route_prefix' => '/admin',
'user.security' => 'is_granted("ROLE_ADMIN")',
];
$container = $this->createStub(ContainerInterface::class);
$container->method('get')->willReturnCallback(static fn (string $id): string => $parameters[$id]);

$extractor = new YamlResourceExtractor([__DIR__.'/yaml/parameters.yaml'], $container);
$resources = $extractor->getResources();

$this->assertArrayHasKey(User::class, $resources);

// scalar string field: %param% resolved anywhere in the string
$this->assertSame('/admin', $resources[User::class][0]['routePrefix']);

// expression field, whole-string %param%: resolved to the stored expression so it reaches
// ExpressionLanguage (the literal #8104 case, which throws if left as "%user.security%")
$this->assertSame('is_granted("ROLE_ADMIN")', $resources[User::class][0]['security']);

// expression field with a real expression (no whole-string param): left untouched
$this->assertSame('is_granted("ROLE_USER")', $resources[User::class][0]['operations'][0]['security']);
}

#[DataProvider('getInvalidPaths')]
public function testInvalidYaml(string $path, string $error): void
{
Expand Down
12 changes: 12 additions & 0 deletions src/Metadata/Tests/Extractor/xml/parameters.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>

<resources xmlns="https://api-platform.com/schema/metadata/resources-3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
https://api-platform.com/schema/metadata/resources-3.0.xsd">
<resource class="%user.class%" routePrefix="%user.route_prefix%" security="%user.security%">
<operations>
<operation class="ApiPlatform\Metadata\GetCollection" security="is_granted(&quot;ROLE_USER&quot;)"/>
</operations>
</resource>
</resources>
7 changes: 7 additions & 0 deletions src/Metadata/Tests/Extractor/yaml/parameters.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
resources:
'%user.class%':
- routePrefix: '%user.route_prefix%'
security: '%user.security%'
operations:
ApiPlatform\Metadata\GetCollection:
security: 'is_granted("ROLE_USER")'
110 changes: 110 additions & 0 deletions src/Metadata/Util/ContainerParameterResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Metadata\Util;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;

/**
* Replaces Symfony container parameters (%param%) found in resource configuration values.
*
* The substitution logic mirrors Symfony's router (and the YAML/XML resource extractors):
* %% escapes a literal %, env() parameters are forbidden, and a parameter must resolve to a
* scalar. It is intentionally kept free of any symfony/dependency-injection requirement so the
* standalone metadata component can rely on it through a PSR ContainerInterface.
*
* @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php
*
* @copyright (c) Fabien Potencier <fabien@symfony.com>
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ContainerParameterResolver
{
private array $collectedParameters = [];

public function __construct(private readonly ?ContainerInterface $container = null)
{
}

/**
* Resolves every %param% reference found anywhere in $value (router-style substitution).
*
* @throws \RuntimeException When a container value is not a string or a numeric value
*/
public function resolve(mixed $value): mixed
{
if (null === $this->container) {
return $value;
}

if (\is_array($value)) {
foreach ($value as $key => $val) {
$value[$key] = $this->resolve($val);
}

return $value;
}

if (!\is_string($value)) {
return $value;
}

$escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) {
$parameter = $match[1] ?? null;

// skip %%
if (!isset($parameter)) {
return '%%';
}

if (preg_match('/^env\(\w+\)$/', $parameter)) {
throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in resource configuration.', $parameter));
}

if (\array_key_exists($parameter, $this->collectedParameters)) {
return $this->collectedParameters[$parameter];
}

if ($this->container instanceof SymfonyContainerInterface) {
$resolved = $this->container->getParameter($parameter);
} else {
$resolved = $this->container->get($parameter);
}

if (\is_string($resolved) || is_numeric($resolved)) {
return $this->collectedParameters[$parameter] = (string) $resolved;
}

throw new \RuntimeException(\sprintf('The container parameter "%s", used in the resource configuration value "%s", must be a string or numeric, but it is of type %s.', $parameter, $value, get_debug_type($resolved)));
}, $value);

return str_replace('%%', '%', $escapedValue);
}

/**
* Resolves a container parameter in an ExpressionLanguage field (security, condition, …) only
* when the whole trimmed value is a single %param% reference. Such a value is invalid
* ExpressionLanguage on its own, so resolving it cannot break a working expression. Any other
* use of "%" — partial, or a real modulo like "object.value % 2" — is left untouched so it
* reaches the expression engine verbatim.
*/
public function resolveExpressionPlaceholder(mixed $value): mixed
{
if (!\is_string($value) || !preg_match('/^%[^%\s]+%$/', trim($value))) {
return $value;
}

return $this->resolve(trim($value));
}
}
Loading
Loading