Skip to content

Commit 7383ea7

Browse files
committed
Merge 4.3
2 parents e4684ef + 1c2f829 commit 7383ea7

17 files changed

Lines changed: 627 additions & 20 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## v4.3.6
4+
5+
### Bug fixes
6+
7+
* [080574ad3](https://github.com/api-platform/core/commit/080574ad3579d8d7d55c6e03d6da6378aa4b0f94) fix(symfony): register property_info fallback when not provided by Symfony (#7969)
8+
* [286a47e72](https://github.com/api-platform/core/commit/286a47e728926fefde957186ead322a7eff561ec) fix(jsonapi): merge flat page/itemsPerPage params with bracket filter (#8193)
9+
* [412682ede](https://github.com/api-platform/core/commit/412682edee3571e910666200e410881b7091901c) fix(serializer): translate PropertyAccess type mismatches to NotNormalizableValueException (#7967)
10+
* [44bb18ddd](https://github.com/api-platform/core/commit/44bb18ddd13d7ba2bdd25fc0c410390f814b3712) fix(state): convert BackedEnum denormalization errors into validation violations (#8195)
11+
* [53d8f5615](https://github.com/api-platform/core/commit/53d8f5615dcaa6264e37667d913f38871c87aab5) fix(metadata): :property dedup drops repeated parameters (#8196)
12+
* [84d15b1f1](https://github.com/api-platform/core/commit/84d15b1f145cf5e6f17e1f5d0049d137aa73a1db) fix(metadata): negotiate wildcard Accept with parameters (#8192)
13+
* [91f93e013](https://github.com/api-platform/core/commit/91f93e01333fc4fe1c8aee8e69cd9ba79bd4cf06) fix(laravel): set application/ld+json content-type on /contexts/{shortName} (#7973)
14+
* [ae4ea864e](https://github.com/api-platform/core/commit/ae4ea864eee1cfeff764f84b1fa70b8cec6e579e) fix(symfony,laravel): IriConverter local cache key collision between item and collection ops (#7975)
15+
* [bf3fded64](https://github.com/api-platform/core/commit/bf3fded64baa765ac006842146a2418a56b8ff2b) fix(symfony): include value-object transformers in JSON-LD streamer locator (#7968)
16+
* [f533810f7](https://github.com/api-platform/core/commit/f533810f789d032bd118742100dbad3c1ab94f35) fix(graphql): accept FilterInterface instance in QueryParameter (#7972)
17+
318
## v4.3.5
419

520
### Bug fixes

src/JsonApi/State/JsonApiProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7070
$filters = array_merge($pageParameter, $filters);
7171
}
7272

73+
foreach (['page', 'itemsPerPage', 'pagination', 'partial'] as $paginationParameter) {
74+
if (isset($queryParameters[$paginationParameter]) && !\is_array($queryParameters[$paginationParameter]) && !isset($filters[$paginationParameter])) {
75+
$filters[$paginationParameter] = $queryParameters[$paginationParameter];
76+
}
77+
}
78+
7379
[$included, $properties] = $this->transformFieldsetsParameters($queryParameters, $operation->getShortName() ?? '');
7480

7581
if ($properties) {

src/JsonApi/Tests/State/JsonApiProviderTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,25 @@ public function testProvide(): void
3737
$provider = new JsonApiProvider($decorated);
3838
$provider->provide($operation, [], $context);
3939
}
40+
41+
public function testProvideMergesFlatPaginationWithBracketFilter(): void
42+
{
43+
$request = new Request(['page' => '2', 'itemsPerPage' => '5', 'pagination' => 'true', 'filter' => ['custom' => 'true']]);
44+
$request->setRequestFormat('jsonapi');
45+
46+
$operation = new Get(class: \stdClass::class, shortName: 'dummy');
47+
$context = ['request' => $request];
48+
$decorated = $this->createMock(ProviderInterface::class);
49+
$decorated->expects($this->once())->method('provide')->with($operation, [], $context);
50+
51+
$provider = new JsonApiProvider($decorated);
52+
$provider->provide($operation, [], $context);
53+
54+
$this->assertSame([
55+
'custom' => 'true',
56+
'page' => '2',
57+
'itemsPerPage' => '5',
58+
'pagination' => 'true',
59+
], $request->attributes->get('_api_filters'));
60+
}
4061
}

src/Metadata/Parameters.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ public function __construct(array $parameters = [])
3737
$parameterName = $parameter->getKey();
3838
}
3939

40-
$key = \sprintf('%s.%s', $parameter::class, $parameterName);
40+
// `:property` is a template expanded per-property later; multiple templates with disjoint properties must coexist.
41+
if (str_contains((string) $parameterName, ':property')) {
42+
$key = \sprintf('%s.%s.%s', $parameter::class, $parameterName, self::propertyDiscriminator($parameter));
43+
} else {
44+
$key = \sprintf('%s.%s', $parameter::class, $parameterName);
45+
}
4146

4247
$this->parameters[$key] = [$parameterName, $parameter];
4348
}
@@ -61,19 +66,38 @@ public function getIterator(): \Traversable
6166

6267
public function add(string $key, Parameter $value): self
6368
{
69+
// `:property` is a template expanded per-property later; templates with disjoint properties coexist, identical ones override.
70+
$isTemplate = str_contains($key, ':property');
71+
$valueDiscriminator = $isTemplate ? self::propertyDiscriminator($value) : null;
72+
6473
foreach ($this->parameters as $i => [$parameterName, $parameter]) {
65-
if ($parameterName === $key && $value::class === $parameter::class) {
66-
$this->parameters[$i] = [$key, $value];
74+
if ($parameterName !== $key || $value::class !== $parameter::class) {
75+
continue;
76+
}
6777

68-
return $this;
78+
if ($isTemplate && self::propertyDiscriminator($parameter) !== $valueDiscriminator) {
79+
continue;
6980
}
81+
82+
$this->parameters[$i] = [$key, $value];
83+
84+
return $this;
7085
}
7186

7287
$this->parameters[] = [$key, $value];
7388

7489
return $this;
7590
}
7691

92+
private static function propertyDiscriminator(Parameter $parameter): string
93+
{
94+
if ($properties = $parameter->getProperties()) {
95+
return '['.implode(',', $properties).']';
96+
}
97+
98+
return $parameter->getProperty() ?? '';
99+
}
100+
77101
/**
78102
* @template T of Parameter
79103
*

src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ private function mergeOperationParameters(Metadata $resource, Parameters $global
288288
$parameterName = $key;
289289
}
290290

291-
if (!$parameters->has($parameterName, $parameter::class)) {
291+
// `:property` is a template expanded per-property later; multiple templates must coexist.
292+
if (str_contains((string) $parameterName, ':property') || !$parameters->has($parameterName, $parameter::class)) {
292293
$parameters->add($parameterName, $parameter);
293294
}
294295
}

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ private function getProperties(string $resourceClass, ?Parameter $parameter = nu
113113
if ($parameter) {
114114
$paramKey = $parameter->getProperties() ? ($parameter->getKey() ?? '') : ($parameter->getProperty() ?? $parameter->getKey() ?? '');
115115
}
116-
$k = $resourceClass.$paramKey.(\is_string($parameter->getFilter()) ? $parameter->getFilter() : '').$filterClass;
116+
// Include properties list so repeated `:property` templates with disjoint properties get distinct cache entries.
117+
$paramProperties = $parameter?->getProperties() ? '['.implode(',', $parameter->getProperties()).']' : '';
118+
$k = $resourceClass.$paramKey.$paramProperties.(\is_string($parameter->getFilter()) ? $parameter->getFilter() : '').$filterClass;
117119
if (isset($this->localPropertyCache[$k])) {
118120
return $this->localPropertyCache[$k];
119121
}

src/Metadata/Tests/ParametersTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,32 @@ public function testDuplicated(): void
4949
$this->assertSame($r2, $parameters->get('a'));
5050
$this->assertSame($r4, $parameters->get('a', HeaderParameter::class));
5151
}
52+
53+
public function testPropertyPlaceholderKeysAreNotDeduplicated(): void
54+
{
55+
$r1 = new QueryParameter(key: ':property', properties: ['field1', 'field2']);
56+
$r2 = new QueryParameter(key: ':property', properties: ['field3', 'field4']);
57+
$parameters = new Parameters([$r1, $r2]);
58+
59+
$this->assertCount(2, $parameters);
60+
61+
$collected = [];
62+
foreach ($parameters as $key => $parameter) {
63+
$collected[] = [$key, $parameter];
64+
}
65+
66+
$this->assertSame(':property', $collected[0][0]);
67+
$this->assertSame(':property', $collected[1][0]);
68+
$this->assertSame(['field1', 'field2'], $collected[0][1]->getProperties());
69+
$this->assertSame(['field3', 'field4'], $collected[1][1]->getProperties());
70+
}
71+
72+
public function testPropertyPlaceholderKeysAreNotDeduplicatedViaAdd(): void
73+
{
74+
$parameters = new Parameters();
75+
$parameters->add(':property', new QueryParameter(key: ':property', properties: ['field1', 'field2']));
76+
$parameters->add(':property', new QueryParameter(key: ':property', properties: ['field3', 'field4']));
77+
78+
$this->assertCount(2, $parameters);
79+
}
5280
}

src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,40 @@ public function testNestedPropertyWithNameConverter(): void
323323
$this->assertSame('search[related.nested]', $searchNestedParam->getKey());
324324
}
325325

326+
public function testRepeatedPropertyPlaceholderAttributesExpandPerPropertyFilter(): void
327+
{
328+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
329+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['field1', 'field2', 'field3', 'field4']));
330+
331+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
332+
$propertyMetadata->method('create')->willReturn(new ApiProperty(readable: true));
333+
334+
$filterLocator = $this->createStub(ContainerInterface::class);
335+
$filterLocator->method('has')->willReturn(false);
336+
337+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
338+
$nameCollection,
339+
$propertyMetadata,
340+
new AttributesResourceMetadataCollectionFactory(),
341+
$filterLocator
342+
);
343+
344+
$collection = $parameterFactory->create(HasRepeatedPropertyPlaceholderParameter::class);
345+
$operation = $collection->getOperation(forceCollection: true);
346+
$parameters = $operation->getParameters();
347+
348+
$this->assertInstanceOf(Parameters::class, $parameters);
349+
350+
foreach (['field1', 'field2', 'field3', 'field4'] as $field) {
351+
$this->assertTrue($parameters->has($field), \sprintf('Parameter "%s" should exist after :property expansion', $field));
352+
}
353+
354+
$this->assertInstanceOf(RepeatedPlaceholderExactFilter::class, $parameters->get('field1')->getFilter());
355+
$this->assertInstanceOf(RepeatedPlaceholderExactFilter::class, $parameters->get('field2')->getFilter());
356+
$this->assertInstanceOf(RepeatedPlaceholderBooleanFilter::class, $parameters->get('field3')->getFilter());
357+
$this->assertInstanceOf(RepeatedPlaceholderBooleanFilter::class, $parameters->get('field4')->getFilter());
358+
}
359+
326360
private function createNestedPropertyFactory(): ParameterResourceMetadataCollectionFactory
327361
{
328362
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
@@ -428,6 +462,34 @@ public function testSimplePropertyHasNoNestedPropertyInfo(): void
428462
}
429463
}
430464

465+
class RepeatedPlaceholderExactFilter implements FilterInterface
466+
{
467+
public function getDescription(string $resourceClass): array
468+
{
469+
return [];
470+
}
471+
}
472+
473+
class RepeatedPlaceholderBooleanFilter implements FilterInterface
474+
{
475+
public function getDescription(string $resourceClass): array
476+
{
477+
return [];
478+
}
479+
}
480+
481+
#[ApiResource]
482+
#[QueryParameter(key: ':property', filter: new RepeatedPlaceholderExactFilter(), properties: ['field1', 'field2'])]
483+
#[QueryParameter(key: ':property', filter: new RepeatedPlaceholderBooleanFilter(), properties: ['field3', 'field4'])]
484+
class HasRepeatedPropertyPlaceholderParameter
485+
{
486+
public $id;
487+
public $field1;
488+
public $field2;
489+
public $field3;
490+
public $field4;
491+
}
492+
431493
#[ApiResource(
432494
operations: [
433495
new GetCollection(

src/Metadata/Util/ContentNegotiationTrait.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ private function getRequestFormat(Request $request, array $formats, bool $throw
9797
/** @var string|null $accept */
9898
$accept = $request->headers->get('Accept');
9999
if (null !== $accept) {
100-
if ($mediaType = $this->negotiator->getBest($accept, $mimeTypes)) {
100+
$mediaType = $this->negotiator->getBest($accept, $mimeTypes);
101+
// willdurand/negotiation treats media-range parameters as match
102+
// constraints, so a wildcard carrying informational params
103+
// (e.g. `*\/*; charset=utf-8` from PhpStorm) fails negotiation.
104+
// Retry on a bare wildcard. See #1532.
105+
if (!$mediaType && str_contains($accept, '*/*')) {
106+
$mediaType = $this->negotiator->getBest('*/*', $mimeTypes);
107+
}
108+
if ($mediaType) {
101109
return $this->getMimeTypeFormat($mediaType->getType(), $formats);
102110
}
103111

src/State/Provider/DeserializeProvider.php

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,21 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
111111
if (!$exception instanceof NotNormalizableValueException) {
112112
continue;
113113
}
114-
$expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes());
115-
$parameters = [];
116-
if ($exception->canUseMessageForUser()) {
117-
$parameters['hint'] = $exception->getMessage();
118-
}
119-
if (!$expectedTypes && $exception->canUseMessageForUser()) {
120-
$violationMessage = $exception->getMessage();
121-
$violations->add(new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR));
122-
} else {
123-
$message = (new Type($expectedTypes))->message;
124-
$violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR));
125-
}
114+
$violations->add($this->createViolationFromException($exception));
126115
}
127116
if (0 !== \count($violations)) {
128117
throw new ValidationException($violations);
129118
}
119+
} catch (NotNormalizableValueException $e) {
120+
// BackedEnum denormalization errors should surface as validation violations (422)
121+
// rather than denormalization errors (400). See https://github.com/api-platform/core/issues/8183.
122+
if (!class_exists(ConstraintViolationList::class) || !$this->isBackedEnumException($e)) {
123+
throw $e;
124+
}
125+
126+
$violations = new ConstraintViolationList();
127+
$violations->add($this->createViolationFromException($e));
128+
throw new ValidationException($violations);
130129
}
131130

132131
$this->stopwatch?->stop('api_platform.provider.deserialize');
@@ -153,4 +152,45 @@ private function normalizeExpectedTypes(?array $expectedTypes = null): array
153152

154153
return $normalizedTypes;
155154
}
155+
156+
private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation
157+
{
158+
$expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes());
159+
$parameters = [];
160+
if ($exception->canUseMessageForUser()) {
161+
$parameters['hint'] = $exception->getMessage();
162+
}
163+
164+
if (!$expectedTypes && $exception->canUseMessageForUser()) {
165+
$violationMessage = $exception->getMessage();
166+
167+
return new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR);
168+
}
169+
170+
$message = (new Type($expectedTypes))->message;
171+
172+
return new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR);
173+
}
174+
175+
private function isBackedEnumException(NotNormalizableValueException $exception): bool
176+
{
177+
foreach ($exception->getExpectedTypes() ?? [] as $expectedType) {
178+
if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) {
179+
return true;
180+
}
181+
}
182+
183+
for ($previous = $exception->getPrevious(); $previous instanceof \Throwable; $previous = $previous->getPrevious()) {
184+
if (!$previous instanceof NotNormalizableValueException) {
185+
continue;
186+
}
187+
foreach ($previous->getExpectedTypes() ?? [] as $expectedType) {
188+
if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) {
189+
return true;
190+
}
191+
}
192+
}
193+
194+
return false;
195+
}
156196
}

0 commit comments

Comments
 (0)