Skip to content

Commit 5ee578e

Browse files
committed
feat(metadata): customize resource & operations
1 parent cff61ea commit 5ee578e

File tree

14 files changed

+520
-66
lines changed

14 files changed

+520
-66
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
18+
/**
19+
* Base file extractor.
20+
*
21+
* @author Loïc Frémont <[email protected]>
22+
*/
23+
abstract class AbstractClosureExtractor implements ClosureExtractorInterface
24+
{
25+
use ResolveValueTrait;
26+
27+
protected ?array $closures = null;
28+
private array $collectedParameters = [];
29+
30+
/**
31+
* @param string[] $paths
32+
*/
33+
public function __construct(protected array $paths, private readonly ?ContainerInterface $container = null)
34+
{
35+
}
36+
37+
/**
38+
* {@inheritdoc}
39+
*/
40+
public function getClosures(): array
41+
{
42+
if (null !== $this->closures) {
43+
return $this->closures;
44+
}
45+
46+
$this->closures = [];
47+
foreach ($this->paths as $path) {
48+
$closure = $this->getPHPFileClosure($path)();
49+
50+
if (!$closure instanceof \Closure || !$this->isClosureSupported($closure)) {
51+
continue;
52+
}
53+
54+
$this->closures[] = $closure;
55+
}
56+
57+
return $this->closures;
58+
}
59+
60+
/**
61+
* Check if the closure is supported
62+
*/
63+
abstract protected function isClosureSupported(\Closure $closure): bool;
64+
65+
/**
66+
* Scope isolated include.
67+
*
68+
* Prevents access to $this/self from included files.
69+
*/
70+
protected function getPHPFileClosure(string $filePath): \Closure
71+
{
72+
return \Closure::bind(function () use ($filePath): mixed {
73+
return require $filePath;
74+
}, null, null);
75+
}
76+
}

src/Metadata/Extractor/AbstractResourceExtractor.php

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
*/
2424
abstract class AbstractResourceExtractor implements ResourceExtractorInterface
2525
{
26+
use ResolveValueTrait;
27+
2628
protected ?array $resources = null;
2729
private array $collectedParameters = [];
2830

@@ -54,70 +56,4 @@ public function getResources(): array
5456
* Extracts metadata from a given path.
5557
*/
5658
abstract protected function extractPath(string $path): void;
57-
58-
/**
59-
* Recursively replaces placeholders with the service container parameters.
60-
*
61-
* @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php
62-
*
63-
* @copyright (c) Fabien Potencier <[email protected]>
64-
*
65-
* @param mixed $value The source which might contain "%placeholders%"
66-
*
67-
* @throws \RuntimeException When a container value is not a string or a numeric value
68-
*
69-
* @return mixed The source with the placeholders replaced by the container
70-
* parameters. Arrays are resolved recursively.
71-
*/
72-
protected function resolve(mixed $value): mixed
73-
{
74-
if (null === $this->container) {
75-
return $value;
76-
}
77-
78-
if (\is_array($value)) {
79-
foreach ($value as $key => $val) {
80-
$value[$key] = $this->resolve($val);
81-
}
82-
83-
return $value;
84-
}
85-
86-
if (!\is_string($value)) {
87-
return $value;
88-
}
89-
90-
$escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) {
91-
$parameter = $match[1] ?? null;
92-
93-
// skip %%
94-
if (!isset($parameter)) {
95-
return '%%';
96-
}
97-
98-
if (preg_match('/^env\(\w+\)$/', $parameter)) {
99-
throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter));
100-
}
101-
102-
if (\array_key_exists($parameter, $this->collectedParameters)) {
103-
return $this->collectedParameters[$parameter];
104-
}
105-
106-
if ($this->container instanceof SymfonyContainerInterface) {
107-
$resolved = $this->container->getParameter($parameter);
108-
} else {
109-
$resolved = $this->container->get($parameter);
110-
}
111-
112-
if (\is_string($resolved) || is_numeric($resolved)) {
113-
$this->collectedParameters[$parameter] = $resolved;
114-
115-
return (string) $resolved;
116-
}
117-
118-
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)));
119-
}, $value);
120-
121-
return str_replace('%%', '%', $escapedValue);
122-
}
12359
}
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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
* @internal
21+
*/
22+
trait ResolveValueTrait
23+
{
24+
private ?ContainerInterface $container = null;
25+
26+
/**
27+
* Recursively replaces placeholders with the service container parameters.
28+
*
29+
* @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php
30+
*
31+
* @copyright (c) Fabien Potencier <[email protected]>
32+
*
33+
* @param mixed $value The source which might contain "%placeholders%"
34+
*
35+
* @throws \RuntimeException When a container value is not a string or a numeric value
36+
*
37+
* @return mixed The source with the placeholders replaced by the container
38+
* parameters. Arrays are resolved recursively.
39+
*/
40+
protected function resolve(mixed $value): mixed
41+
{
42+
if (null === $this->container) {
43+
return $value;
44+
}
45+
46+
if (\is_array($value)) {
47+
foreach ($value as $key => $val) {
48+
$value[$key] = $this->resolve($val);
49+
}
50+
51+
return $value;
52+
}
53+
54+
if (!\is_string($value)) {
55+
return $value;
56+
}
57+
58+
$escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) {
59+
$parameter = $match[1] ?? null;
60+
61+
// skip %%
62+
if (!isset($parameter)) {
63+
return '%%';
64+
}
65+
66+
if (preg_match('/^env\(\w+\)$/', $parameter)) {
67+
throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter));
68+
}
69+
70+
if (\array_key_exists($parameter, $this->collectedParameters)) {
71+
return $this->collectedParameters[$parameter];
72+
}
73+
74+
if ($this->container instanceof SymfonyContainerInterface) {
75+
$resolved = $this->container->getParameter($parameter);
76+
} else {
77+
$resolved = $this->container->get($parameter);
78+
}
79+
80+
if (\is_string($resolved) || is_numeric($resolved)) {
81+
$this->collectedParameters[$parameter] = $resolved;
82+
83+
return (string) $resolved;
84+
}
85+
86+
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)));
87+
}, $value);
88+
89+
return str_replace('%%', '%', $escapedValue);
90+
}
91+
}
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+
}

0 commit comments

Comments
 (0)