Skip to content

Commit 2682fc5

Browse files
authored
feat: defaults parameters (#7758)
1 parent 95ec407 commit 2682fc5

File tree

9 files changed

+697
-1
lines changed

9 files changed

+697
-1
lines changed

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,9 @@ private function registerCommonConfiguration(ContainerBuilder $container, array
396396
$container->setAlias('api_platform.name_converter', $config['name_converter']);
397397
}
398398
$container->setParameter('api_platform.asset_package', $config['asset_package']);
399-
$container->setParameter('api_platform.defaults', $this->normalizeDefaults($config['defaults'] ?? []));
399+
$normalizedDefaults = $this->normalizeDefaults($config['defaults'] ?? []);
400+
$container->setParameter('api_platform.defaults', $normalizedDefaults);
401+
$container->setParameter('api_platform.defaults.parameters', $config['defaults']['parameters'] ?? []);
400402

401403
if ($container->getParameter('kernel.debug')) {
402404
$container->removeDefinition('api_platform.serializer.mapping.cache_class_metadata_factory');
@@ -425,6 +427,7 @@ private function normalizeDefaults(array $defaults): array
425427
{
426428
$normalizedDefaults = ['extra_properties' => $defaults['extra_properties'] ?? []];
427429
unset($defaults['extra_properties']);
430+
unset($defaults['parameters']);
428431

429432
$rc = new \ReflectionClass(ApiResource::class);
430433
$publicProperties = [];

src/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
1717
use ApiPlatform\Metadata\ApiResource;
1818
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
19+
use ApiPlatform\Metadata\Parameter;
1920
use ApiPlatform\Metadata\Post;
2021
use ApiPlatform\Metadata\Put;
2122
use ApiPlatform\Symfony\Controller\MainController;
@@ -683,6 +684,18 @@ private function addDefaultsSection(ArrayNodeDefinition $rootNode): void
683684
$this->defineDefault($defaultsNode, new \ReflectionClass(ApiResource::class), $nameConverter);
684685
$this->defineDefault($defaultsNode, new \ReflectionClass(Put::class), $nameConverter);
685686
$this->defineDefault($defaultsNode, new \ReflectionClass(Post::class), $nameConverter);
687+
688+
$parametersNode = $defaultsNode
689+
->children()
690+
->arrayNode('parameters')
691+
->info('Global parameters applied to all resources and operations.')
692+
->useAttributeAsKey('parameter_class')
693+
->prototype('array')
694+
->ignoreExtraKeys(false);
695+
696+
$this->defineDefault($parametersNode, new \ReflectionClass(Parameter::class), $nameConverter);
697+
698+
$parametersNode->end()->end()->end();
686699
}
687700

688701
private function addMakerSection(ArrayNodeDefinition $rootNode): void

src/Symfony/Bundle/Resources/config/validator/validator.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@
3737
->args([
3838
service('api_platform.validator.metadata.resource.metadata_collection_factory.parameter.inner'),
3939
service('api_platform.filter_locator'),
40+
'%api_platform.defaults.parameters%',
4041
]);
4142
};

src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Validator\Metadata\Resource\Factory;
1515

16+
use ApiPlatform\Metadata\ApiResource;
1617
use ApiPlatform\Metadata\HttpOperation;
1718
use ApiPlatform\Metadata\Parameter;
1819
use ApiPlatform\Metadata\Parameters;
@@ -22,6 +23,7 @@
2223
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2324
use ApiPlatform\Validator\Util\ParameterValidationConstraints;
2425
use Psr\Container\ContainerInterface;
26+
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
2527

2628
final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
2729
{
@@ -30,14 +32,19 @@ final class ParameterValidationResourceMetadataCollectionFactory implements Reso
3032
public function __construct(
3133
private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null,
3234
private readonly ?ContainerInterface $filterLocator = null,
35+
private readonly array $defaultParameters = [],
3336
) {
3437
}
3538

3639
public function create(string $resourceClass): ResourceMetadataCollection
3740
{
3841
$resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass);
3942

43+
$defaultParams = $this->buildDefaultParameters();
44+
4045
foreach ($resourceMetadataCollection as $i => $resource) {
46+
$resource = $this->applyDefaults($resource, $defaultParams);
47+
4148
$operations = $resource->getOperations();
4249

4350
foreach ($operations as $operationName => $operation) {
@@ -135,4 +142,104 @@ private function addFilterValidation(HttpOperation $operation): Parameters
135142

136143
return $parameters;
137144
}
145+
146+
/**
147+
* Builds Parameter objects from the default configuration array.
148+
*
149+
* @return array<string, Parameter> Array of Parameter objects indexed by their key
150+
*/
151+
private function buildDefaultParameters(): array
152+
{
153+
$parameters = [];
154+
155+
foreach ($this->defaultParameters as $parameterClass => $config) {
156+
if (!is_subclass_of($parameterClass, Parameter::class)) {
157+
continue;
158+
}
159+
160+
$identifier = $config['key'] ?? (new \ReflectionClass($parameterClass))->getShortName();
161+
162+
$parameter = $this->createParameterFromConfig($parameterClass, $config);
163+
$parameters[$identifier] = $parameter;
164+
}
165+
166+
return $parameters;
167+
}
168+
169+
/**
170+
* Creates a Parameter instance from configuration.
171+
*
172+
* @param class-string<Parameter> $parameterClass The parameter class name
173+
* @param array<string, mixed> $config The configuration array
174+
*
175+
* @return Parameter The created parameter instance
176+
*/
177+
private function createParameterFromConfig(string $parameterClass, array $config): Parameter
178+
{
179+
$nameConverter = new CamelCaseToSnakeCaseNameConverter();
180+
$reflectionClass = new \ReflectionClass($parameterClass);
181+
$constructor = $reflectionClass->getConstructor();
182+
183+
$args = [];
184+
foreach ($constructor->getParameters() as $param) {
185+
$paramName = $param->getName();
186+
$configKey = $nameConverter->normalize($paramName);
187+
$args[$paramName] = $config[$configKey] ?? $param->getDefaultValue();
188+
}
189+
190+
return new $parameterClass(...$args);
191+
}
192+
193+
/**
194+
* Applies default parameters to the resource.
195+
*
196+
* @param array<string, Parameter> $defaultParams The default parameters to apply
197+
*/
198+
private function applyDefaults(ApiResource $resource, array $defaultParams): ApiResource
199+
{
200+
$resourceParameters = $resource->getParameters() ?? new Parameters();
201+
$mergedResourceParameters = $this->mergeParameters($resourceParameters, $defaultParams);
202+
$resource = $resource->withParameters($mergedResourceParameters);
203+
204+
foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) {
205+
$operationParameters = $operation->getParameters() ?? new Parameters();
206+
$mergedOperationParameters = $this->mergeParameters($operationParameters, $defaultParams);
207+
$operations->add((string) $operationName, $operation->withParameters($mergedOperationParameters));
208+
}
209+
210+
if ($operations) {
211+
$resource = $resource->withOperations($operations);
212+
}
213+
214+
foreach ($graphQlOperations = $resource->getGraphQlOperations() ?? [] as $operationName => $operation) {
215+
$operationParameters = $operation->getParameters() ?? new Parameters();
216+
$mergedOperationParameters = $this->mergeParameters($operationParameters, $defaultParams);
217+
$graphQlOperations[$operationName] = $operation->withParameters($mergedOperationParameters);
218+
}
219+
220+
if ($graphQlOperations) {
221+
$resource = $resource->withGraphQlOperations($graphQlOperations);
222+
}
223+
224+
return $resource;
225+
}
226+
227+
/**
228+
* Merges default parameters with operation-specific parameters.
229+
*
230+
* @param Parameters $operationParameters The parameters already defined on the operation
231+
* @param array<string, Parameter> $defaultParams The default parameters to merge
232+
*
233+
* @return Parameters The merged parameters
234+
*/
235+
private function mergeParameters(Parameters $operationParameters, array $defaultParams): Parameters
236+
{
237+
$merged = new Parameters($defaultParams);
238+
239+
foreach ($operationParameters as $key => $param) {
240+
$merged->add($key, $param);
241+
}
242+
243+
return $merged;
244+
}
138245
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional;
15+
16+
use ApiPlatform\Metadata\HeaderParameter;
17+
use Symfony\Component\Config\Loader\LoaderInterface;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
20+
/**
21+
* @author Maxence Castel <maxence.castel59@gmail.com>
22+
*/
23+
class DefaultParametersAppKernel extends \AppKernel
24+
{
25+
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
26+
{
27+
parent::configureContainer($c, $loader);
28+
29+
$loader->load(static function (ContainerBuilder $container) {
30+
if ($container->hasDefinition('phpunit_resource_name_collection')) {
31+
$container->removeDefinition('phpunit_resource_name_collection');
32+
}
33+
34+
$container->loadFromExtension('api_platform', [
35+
'defaults' => [
36+
'extra_properties' => [
37+
'deduplicate_resource_short_names' => true,
38+
],
39+
'parameters' => [
40+
HeaderParameter::class => [
41+
'key' => 'API-Key',
42+
'required' => false,
43+
'description' => 'API key for authentication',
44+
'schema' => ['type' => 'string'],
45+
],
46+
],
47+
],
48+
]);
49+
});
50+
}
51+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
18+
/**
19+
* Tests that default parameters configured via api_platform.defaults.parameters
20+
* appear in all resources and operations in the OpenAPI and JSONSchema documentation.
21+
*
22+
* @author Maxence Castel <maxence.castel59@gmail.com>
23+
*/
24+
final class DefaultParametersTest extends ApiTestCase
25+
{
26+
protected static ?bool $alwaysBootKernel = true;
27+
28+
protected static function getKernelClass(): string
29+
{
30+
return DefaultParametersAppKernel::class;
31+
}
32+
33+
/**
34+
* Test that default header parameter appears in all operations in OpenAPI documentation.
35+
*
36+
* This test verifies that when default parameters are configured via
37+
* api_platform.defaults.parameters with:
38+
* HeaderParameter:
39+
* key: 'API-Key'
40+
* required: false
41+
* description: 'API key for authentication'
42+
*
43+
* The parameter appears in ALL resources and ALL their operations in the OpenAPI output.
44+
*/
45+
public function testDefaultParameterAppearsInOpenApiForAllOperations(): void
46+
{
47+
$response = self::createClient()->request('GET', '/docs', [
48+
'headers' => ['Accept' => 'application/vnd.openapi+json'],
49+
]);
50+
51+
$this->assertResponseIsSuccessful();
52+
$content = $response->toArray();
53+
54+
$this->assertArrayHasKey('openapi', $content);
55+
$this->assertArrayHasKey('paths', $content);
56+
57+
$foundParameter = false;
58+
$operationsWithParameter = [];
59+
60+
foreach ($content['paths'] as $pathName => $pathItem) {
61+
foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) {
62+
if (!isset($pathItem[$method]['parameters'])) {
63+
continue;
64+
}
65+
66+
$parameters = $pathItem[$method]['parameters'];
67+
foreach ($parameters as $param) {
68+
if ('API-Key' === $param['name'] && 'header' === $param['in']) {
69+
$foundParameter = true;
70+
$operationsWithParameter[] = [
71+
'path' => $pathName,
72+
'method' => $method,
73+
];
74+
75+
$this->assertSame('API-Key', $param['name']);
76+
$this->assertSame('header', $param['in']);
77+
$this->assertSame('API key for authentication', $param['description']);
78+
$this->assertFalse($param['required']);
79+
$this->assertFalse($param['deprecated']);
80+
$this->assertArrayHasKey('schema', $param);
81+
$this->assertSame('string', $param['schema']['type']);
82+
break;
83+
}
84+
}
85+
}
86+
}
87+
88+
$this->assertTrue(
89+
$foundParameter,
90+
\sprintf(
91+
'Default header parameter "API-Key" not found in any operation. Operations checked: %d',
92+
\count($content['paths'] ?? [])
93+
)
94+
);
95+
96+
$this->assertGreaterThanOrEqual(2, \count($operationsWithParameter),
97+
'Default parameter should appear in multiple operations (collection and item)'
98+
);
99+
}
100+
101+
/**
102+
* Test that default parameters appear in both collection and item operations.
103+
*/
104+
public function testDefaultParameterAppearsInMultipleOperationTypes(): void
105+
{
106+
$response = self::createClient()->request('GET', '/docs', [
107+
'headers' => ['Accept' => 'application/vnd.openapi+json'],
108+
]);
109+
110+
$this->assertResponseIsSuccessful();
111+
$content = $response->toArray();
112+
113+
$operationMethodsWithParameter = [];
114+
115+
foreach ($content['paths'] as $pathName => $pathItem) {
116+
foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) {
117+
if (!isset($pathItem[$method]['parameters'])) {
118+
continue;
119+
}
120+
121+
$parameters = $pathItem[$method]['parameters'];
122+
foreach ($parameters as $param) {
123+
if ('API-Key' === $param['name'] && 'header' === $param['in']) {
124+
$operationMethodsWithParameter[$method] = true;
125+
break;
126+
}
127+
}
128+
}
129+
}
130+
131+
$this->assertGreaterThanOrEqual(2, \count($operationMethodsWithParameter),
132+
\sprintf('Default parameter should appear in at least 2 different HTTP methods, found in: %s',
133+
implode(', ', array_keys($operationMethodsWithParameter)))
134+
);
135+
}
136+
137+
public function testDefaultParametersDoNotBreakJsonLdDocumentation(): void
138+
{
139+
$response = self::createClient()->request('GET', '/docs.jsonld', [
140+
'headers' => ['Accept' => 'application/ld+json'],
141+
]);
142+
143+
$this->assertResponseIsSuccessful();
144+
$content = $response->toArray();
145+
146+
$this->assertArrayHasKey('@context', $content);
147+
148+
$this->assertTrue(
149+
isset($content['entrypoint']) || isset($content['hydra:supportedClass']),
150+
'JSON-LD response should have either "entrypoint" or "hydra:supportedClass" key'
151+
);
152+
}
153+
}

0 commit comments

Comments
 (0)