Skip to content

Commit 95d5e46

Browse files
committed
feat(metadata) customize resource & operations
1 parent 244fa17 commit 95d5e46

File tree

12 files changed

+410
-0
lines changed

12 files changed

+410
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Extractor;
15+
16+
use Psr\Container\ContainerInterface;
17+
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;
18+
19+
/**
20+
* Base file extractor.
21+
*
22+
* @author Loïc Frémont <[email protected]>
23+
*/
24+
abstract class AbstractClosureExtractor implements ClosureExtractorInterface
25+
{
26+
protected ?array $closures = null;
27+
private array $collectedParameters = [];
28+
29+
/**
30+
* @param string[] $paths
31+
*/
32+
public function __construct(protected array $paths, private readonly ?ContainerInterface $container = null)
33+
{
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function getClosures(): array
40+
{
41+
if (null !== $this->closures) {
42+
return $this->closures;
43+
}
44+
45+
$this->closures = [];
46+
foreach ($this->paths as $path) {
47+
$closure = $this->getPHPFileClosure($path)();
48+
49+
if (!$closure instanceof \Closure || !$this->isClosureSupported($closure)) {
50+
continue;
51+
}
52+
53+
$this->closures[] = $closure;
54+
}
55+
56+
return $this->closures;
57+
}
58+
59+
/**
60+
* Check if the closure is supported
61+
*/
62+
abstract protected function isClosureSupported(\Closure $closure): bool;
63+
64+
/**
65+
* Recursively replaces placeholders with the service container parameters.
66+
*
67+
* @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php
68+
*
69+
* @copyright (c) Fabien Potencier <[email protected]>
70+
*
71+
* @param mixed $value The source which might contain "%placeholders%"
72+
*
73+
* @throws \RuntimeException When a container value is not a string or a numeric value
74+
*
75+
* @return mixed The source with the placeholders replaced by the container
76+
* parameters. Arrays are resolved recursively.
77+
*/
78+
protected function resolve(mixed $value): mixed
79+
{
80+
if (null === $this->container) {
81+
return $value;
82+
}
83+
84+
if (\is_array($value)) {
85+
foreach ($value as $key => $val) {
86+
$value[$key] = $this->resolve($val);
87+
}
88+
89+
return $value;
90+
}
91+
92+
if (!\is_string($value)) {
93+
return $value;
94+
}
95+
96+
$escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) {
97+
$parameter = $match[1] ?? null;
98+
99+
// skip %%
100+
if (!isset($parameter)) {
101+
return '%%';
102+
}
103+
104+
if (preg_match('/^env\(\w+\)$/', $parameter)) {
105+
throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter));
106+
}
107+
108+
if (\array_key_exists($parameter, $this->collectedParameters)) {
109+
return $this->collectedParameters[$parameter];
110+
}
111+
112+
if ($this->container instanceof SymfonyContainerInterface) {
113+
$resolved = $this->container->getParameter($parameter);
114+
} else {
115+
$resolved = $this->container->get($parameter);
116+
}
117+
118+
if (\is_string($resolved) || is_numeric($resolved)) {
119+
$this->collectedParameters[$parameter] = $resolved;
120+
121+
return (string) $resolved;
122+
}
123+
124+
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)));
125+
}, $value);
126+
127+
return str_replace('%%', '%', $escapedValue);
128+
}
129+
130+
/**
131+
* Scope isolated include.
132+
*
133+
* Prevents access to $this/self from included files.
134+
*/
135+
protected function getPHPFileClosure(string $filePath): \Closure
136+
{
137+
return \Closure::bind(function () use ($filePath): mixed {
138+
return require $filePath;
139+
}, null, null);
140+
}
141+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Extractor;
15+
16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
17+
18+
/**
19+
* Extracts an array of closure from a file or a list of files.
20+
*
21+
* @author Loïc Frémont <[email protected]>
22+
*/
23+
interface ClosureExtractorInterface
24+
{
25+
/**
26+
* Parses all metadata files and convert them in an array.
27+
*
28+
* @throws InvalidArgumentException
29+
*/
30+
public function getClosures(): array;
31+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Extractor;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
/**
19+
* Extracts an array of closure from a list of PHP files.
20+
*
21+
* @author Loïc Frémont <[email protected]>
22+
*/
23+
final class PhpFileResourceClosureExtractor extends AbstractClosureExtractor
24+
{
25+
/**
26+
* {@inheritdoc}
27+
*/
28+
protected function isClosureSupported(\Closure $closure): bool
29+
{
30+
$resourceReflection = new \ReflectionFunction($closure);
31+
32+
if (1 !== $resourceReflection->getNumberOfParameters()) {
33+
return false;
34+
}
35+
36+
$firstParameterType = ($resourceReflection->getParameters()[0] ?? null)?->getType();
37+
38+
if (!$firstParameterType instanceof \ReflectionNamedType) {
39+
return false;
40+
}
41+
42+
// Check if the closure parameter is an API resource
43+
return is_a($firstParameterType->getName(), ApiResource::class, true);
44+
}
45+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Resource\Factory;
15+
16+
use ApiPlatform\Metadata\Extractor\ClosureExtractorInterface;
17+
use ApiPlatform\Metadata\Operations;
18+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
19+
20+
final class CustomResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
21+
{
22+
use OperationDefaultsTrait;
23+
24+
public function __construct(
25+
private readonly ClosureExtractorInterface $resourceClosureExtractor,
26+
private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null,
27+
) {
28+
}
29+
30+
public function create(string $resourceClass): ResourceMetadataCollection
31+
{
32+
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass);
33+
if ($this->decorated) {
34+
$resourceMetadataCollection = $this->decorated->create($resourceClass);
35+
}
36+
37+
$newMetadataCollection = new ResourceMetadataCollection($resourceClass);
38+
39+
foreach ($resourceMetadataCollection as $resource) {
40+
foreach ($this->resourceClosureExtractor->getClosures() as $closure) {
41+
$resource = $closure($resource);
42+
43+
$operations = [];
44+
foreach ($resource->getOperations() as $operation) {
45+
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
46+
$operations[$key] = $operation;
47+
}
48+
49+
$resource = $resource->withOperations(new Operations($operations));
50+
}
51+
52+
$newMetadataCollection[] = $resource;
53+
}
54+
55+
return $newMetadataCollection;
56+
}
57+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Tests\Extractor;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Extractor\PhpFileResourceClosureExtractor;
18+
use PHPUnit\Framework\TestCase;
19+
20+
final class PhpFileResourceClosureExtractorTest extends TestCase
21+
{
22+
public function testItGetsClosuresFromPhpFileThatReturnsAnApiResource(): void
23+
{
24+
$extractor = new PhpFileResourceClosureExtractor([__DIR__.'/php/valid_custom_resource_php_file.php']);
25+
26+
$expectedClosures = [static function (ApiResource $resource): ApiResource {
27+
return $resource->withShortName('dummy');
28+
}];
29+
30+
$this->assertEquals($expectedClosures, $extractor->getClosures());
31+
}
32+
33+
public function testItExcludesClosuresFromPhpFileThatDoesNotReturnAnApiResource(): void
34+
{
35+
$extractor = new PhpFileResourceClosureExtractor([__DIR__.'/php/invalid_custom_php_file.php']);
36+
37+
$this->assertEquals([], $extractor->getClosures());
38+
}
39+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
return static function (): void {};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
use ApiPlatform\Metadata\ApiResource;
15+
16+
return static function (ApiResource $resource): ApiResource {
17+
return $resource->withShortName('dummy');
18+
};

0 commit comments

Comments
 (0)