Skip to content

Commit 6cac23a

Browse files
committed
Merge 4.3
# Conflicts: # src/JsonLd/Serializer/ItemNormalizer.php
2 parents 573df71 + 0d9bcde commit 6cac23a

86 files changed

Lines changed: 2220 additions & 72 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

3+
## v4.3.13
4+
5+
### Bug fixes
6+
7+
* [098d52766](https://github.com/api-platform/core/commit/098d527660a3c631b6639c7cd3c479b722959797) fix(elasticsearch): coerce document _id to declared int identifier type (#8296)
8+
* [20baa6180](https://github.com/api-platform/core/commit/20baa6180e585b0ca07c7879c474f555d070e20f) fix(openapi): throw clear error for openapi parameter missing name in yaml config (#8297)
9+
* [9e18fe013](https://github.com/api-platform/core/commit/9e18fe013e217ffafe7d84c1d134135627674f15) fix(jsonschema): embed relations of non-resource objects in output schema (#8294)
10+
11+
## v4.3.12
12+
13+
### Bug fixes
14+
15+
* [6bcbeb2db](https://github.com/api-platform/core/commit/6bcbeb2dbee53db5bb9b4b8e343bffdf7732de1e) fix(serializer): validate IRI target class on relation denormalization
16+
* [6b1fe1e47](https://github.com/api-platform/core/commit/6b1fe1e472ecab58e50957a4d11000ea714b5364) fix(doctrine): guard unmapped relation links in ORM handleLinks (#8293)
17+
* [cc021e4fa](https://github.com/api-platform/core/commit/cc021e4fab36315384be23417b73bcf70a77f45c) fix(graphql): honor custom mutation output class in payload type (#8300)
18+
19+
## v4.3.11
20+
21+
### Bug fixes
22+
23+
* [2726085ae](https://github.com/api-platform/core/commit/2726085ae7fedaf758db89d0c5876d6d6921b222) fix(metadata): keep explicitly set GraphQL mutation description (#8286)
24+
* [4819b5f9f](https://github.com/api-platform/core/commit/4819b5f9f0a5b076aa4dc73f2f31aa3ed287a4e0) fix(metadata): metadata mutators for resource & operations with lower priority (#8273)
25+
* [6b8bd0a3d](https://github.com/api-platform/core/commit/6b8bd0a3d5a8660e1711bc7511531c95f3b86817) fix(metadata): read ApiProperty from trait private properties inherited via parent class (#8275)
26+
* [aced52dd0](https://github.com/api-platform/core/commit/aced52dd0bef5e37369e3f221fcb5a497f545a4c) fix(metadata): preserve nested array query parameters in IriHelper (#8278)
27+
* [c37e27079](https://github.com/api-platform/core/commit/c37e27079312fcd758f99c4f247726011787345a) fix(metadata): preserve explicit class on ApiResource when propagating defaults (#8262)
28+
* [c79045718](https://github.com/api-platform/core/commit/c79045718aafdfc282af1b8e29a3ffbff9e8d5e1) fix(jsonschema): embed genId:false relations in output schema (#8272)
29+
* [ce4f6c210](https://github.com/api-platform/core/commit/ce4f6c210cdf377e46602d236033b38062e89b5e) fix(jsonschema): don't leak operation deprecation onto sub-schemas (#8289)
30+
* [134bb5cd7](https://github.com/api-platform/core/commit/134bb5cd75dc8b405766f769263a62da606e160c) fix(jsonld): replace already-populated nested relation from embedded @id on patch (#8274)
31+
* [4b50a4edc](https://github.com/api-platform/core/commit/4b50a4edc3c753043bf824ec21a4a64ccf8c1cbd) fix(hydra): declare hydra:view links as nullable in json schema (#8277)
32+
* [78538aa90](https://github.com/api-platform/core/commit/78538aa902b4551e52c67f4d07b6ae3cb9fa9706) fix(mcp): resolve $ref inside oneOf/anyOf when flattening tool outputSchema (#8268)
33+
* [a8d4b00f5](https://github.com/api-platform/core/commit/a8d4b00f5a81b1cf98554c2cf3cd56ec5a04a03e) fix(mcp): always serialize payload into TextContent when structuredContent is disabled (#8270)
34+
* [1ffe0ada8](https://github.com/api-platform/core/commit/1ffe0ada88abb1f4418d3873258ab7f9314f07bf) fix(symfony): register http cache purgers independently of invalidation flag (#8260)
35+
* [81a1307fe](https://github.com/api-platform/core/commit/81a1307fee569690c9fb06017330dc3b8efffb7b) fix(symfony): expose uri variables in security expression context (#8279)
36+
* [d6fd5bfae](https://github.com/api-platform/core/commit/d6fd5bfae855a00dc950fd3465c66f88872b5d6b) fix(httpcache): allow custom http method on SouinPurger and SurrogateKeysPurger (#8259)
37+
* [b3b376225](https://github.com/api-platform/core/commit/b3b376225a7a091967e7474bda313971774d489e) fix(openapi): ship oauth2-redirect.js with swagger-ui assets (#8261)
38+
* [fb2062465](https://github.com/api-platform/core/commit/fb20624655f0aee856c4a122a6d5570002a11c5f) fix(swagger): improve dark mode button in swaggerUI (#8265)
39+
40+
### Notes
41+
42+
* JSON-LD `PATCH`: an embedded `@id` on a nested writable relation now replaces the currently-linked relation when it points to a different resource. A dangling embedded `@id` now returns a 400 instead of being silently ignored (it previously mutated the existing relation in place). See #8274.
43+
344
## v4.3.10
445

546
### Bug fixes

phpstan.neon.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ parameters:
4646
-
4747
message: '#Symfony\\Component\\PropertyInfo\\Type#'
4848
identifier: class.notFound
49+
# mcp/sdk 0.6 renamed Mcp\Schema\Resource to ResourceDefinition; Loader resolves at runtime, only one exists per installed version
50+
-
51+
message: '#class Mcp\\Schema\\(Resource|ResourceDefinition) not found#'
52+
identifier: class.notFound
53+
path: src/Mcp/Capability/Registry/Loader.php
4954
# False positives
5055
- message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\).#'
5156
-

src/Doctrine/Orm/State/ItemProvider.php

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6060
}
6161

6262
$fetchData = $context['fetch_data'] ?? true;
63-
if (!$fetchData && \array_key_exists('id', $uriVariables)) {
64-
// todo : if uriVariables don't contain the id, this fails. This should behave like it does in the following code
65-
return $manager->getReference($entityClass, $uriVariables);
63+
if (!$fetchData && null !== ($identifiers = $this->getReferenceIdentifiers($manager, $entityClass, $operation, $uriVariables, $context))) {
64+
return $manager->getReference($entityClass, $identifiers);
6665
}
6766

6867
$repository = $manager->getRepository($entityClass);
@@ -103,4 +102,50 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
103102

104103
return $queryBuilder->getQuery()->getOneOrNullResult();
105104
}
105+
106+
/**
107+
* Builds the [identifierField => value] map for getReference() from the resource's own identifier
108+
* links, ignoring parent/relation links a subresource carries (e.g. "companyId") which are not
109+
* identifiers of the entity and would make getReference() throw UnrecognizedIdentifierFields.
110+
*
111+
* Returns null (so the caller falls through to the link-resolving query) when an own identifier
112+
* value is missing, or when a resource identifier is not a Doctrine identifier of the entity
113+
* (e.g. a uuid exposed as the API identifier while the table key is "id") and therefore cannot be
114+
* turned into a reference.
115+
*
116+
* @param array<string, mixed> $uriVariables
117+
* @param array<string, mixed> $context
118+
*
119+
* @return array<string, mixed>|null
120+
*/
121+
private function getReferenceIdentifiers(EntityManagerInterface $manager, string $entityClass, Operation $operation, array $uriVariables, array $context): ?array
122+
{
123+
$identifierFields = array_flip($manager->getClassMetadata($entityClass)->getIdentifierFieldNames());
124+
125+
$identifiers = [];
126+
foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) {
127+
// Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself.
128+
if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) {
129+
continue;
130+
}
131+
132+
$identifierProperties = $link->getIdentifiers();
133+
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
134+
foreach ($identifierProperties as $identifierProperty) {
135+
if (!isset($identifierFields[$identifierProperty])) {
136+
return null;
137+
}
138+
139+
// Composite identifiers are exploded by field name upstream; a single identifier is keyed by its uriVariable name.
140+
$key = $hasCompositeIdentifiers ? $identifierProperty : $parameterName;
141+
if (!\array_key_exists($key, $uriVariables)) {
142+
return null;
143+
}
144+
145+
$identifiers[$identifierProperty] = $uriVariables[$key];
146+
}
147+
}
148+
149+
return $identifiers ?: null;
150+
}
106151
}

src/Doctrine/Orm/State/LinksHandlerTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que
8888
$joinProperties = $doctrineClassMetadata->getIdentifierFieldNames();
8989

9090
if ($link->getFromProperty() && !$link->getToProperty()) {
91+
// The link was built from the property's native type (LinkFactory), but the
92+
// property may not be a mapped Doctrine association (e.g. a transient,
93+
// resource-typed self reference). There is nothing to join on, skip it.
94+
if (!$fromClassMetadata->hasAssociation($link->getFromProperty())) {
95+
continue;
96+
}
97+
9198
$joinAlias = $queryNameGenerator->generateJoinAlias('m');
9299
$associationMapping = $fromClassMetadata->getAssociationMapping($link->getFromProperty()); // @phpstan-ignore-line
93100
$relationType = $associationMapping['type'];

src/Doctrine/Orm/Tests/State/ItemProviderTest.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,146 @@ public function testGetItemDoubleIdentifier(): void
138138
$this->assertEquals($returnObject, $dataProvider->provide($operation, ['ida' => 1, 'idb' => 2], $context));
139139
}
140140

141+
public function testGetItemWithFetchDataFalseOnSubresourceFiltersParentLink(): void
142+
{
143+
$reference = new Employee();
144+
145+
$classMetadataMock = $this->createMock(ClassMetadata::class);
146+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
147+
148+
$managerMock = $this->createMock(EntityManagerInterface::class);
149+
$managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock);
150+
$managerMock->expects($this->once())
151+
->method('getReference')
152+
->with(Employee::class, ['id' => 2])
153+
->willReturn($reference);
154+
155+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
156+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
157+
158+
$operation = (new Get())->withUriVariables([
159+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
160+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
161+
])->withName('get')->withClass(Employee::class);
162+
163+
$dataProvider = new ItemProvider(
164+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
165+
$managerRegistryMock,
166+
);
167+
168+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'id' => 2], ['fetch_data' => false]));
169+
}
170+
171+
public function testGetItemWithFetchDataFalseMapsRenamedIdentifierUriVariable(): void
172+
{
173+
$reference = new Employee();
174+
175+
$classMetadataMock = $this->createMock(ClassMetadata::class);
176+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
177+
178+
$managerMock = $this->createMock(EntityManagerInterface::class);
179+
$managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock);
180+
$managerMock->expects($this->once())
181+
->method('getReference')
182+
->with(Employee::class, ['id' => 2])
183+
->willReturn($reference);
184+
185+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
186+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
187+
188+
// The identifier uriVariable is named "employeeId" while the entity's own identifier field is "id".
189+
$operation = (new Get())->withUriVariables([
190+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
191+
'employeeId' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id'])->withParameterName('employeeId'),
192+
])->withName('get')->withClass(Employee::class);
193+
194+
$dataProvider = new ItemProvider(
195+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
196+
$managerRegistryMock,
197+
);
198+
199+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'employeeId' => 2], ['fetch_data' => false]));
200+
}
201+
202+
public function testGetItemWithFetchDataFalseFallsBackToQueryWhenOwnIdentifierMissing(): void
203+
{
204+
$returnObject = new \stdClass();
205+
206+
$queryMock = $this->createMock($this->getQueryClass());
207+
$queryMock->method('getOneOrNullResult')->willReturn($returnObject);
208+
209+
$queryBuilderMock = $this->createMock(QueryBuilder::class);
210+
$queryBuilderMock->method('getQuery')->willReturn($queryMock);
211+
$queryBuilderMock->method('getRootAliases')->willReturn(['o']);
212+
213+
$classMetadataMock = $this->createMock(ClassMetadata::class);
214+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
215+
216+
$repositoryMock = $this->createMock(EntityRepository::class);
217+
$repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock);
218+
219+
$managerMock = $this->createMock(EntityManagerInterface::class);
220+
$managerMock->method('getClassMetadata')->willReturn($classMetadataMock);
221+
$managerMock->method('getRepository')->willReturn($repositoryMock);
222+
// Only the parent link is provided: the own identifier cannot be resolved to a reference,
223+
// so we must fall back to the query that resolves the link instead of calling getReference().
224+
$managerMock->expects($this->never())->method('getReference');
225+
226+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
227+
$managerRegistryMock->method('getManagerForClass')->willReturn($managerMock);
228+
229+
$operation = (new Get())->withUriVariables([
230+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company')->withIdentifiers(['id']),
231+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
232+
])->withName('get')->withClass(Employee::class);
233+
234+
$dataProvider = new ItemProvider(
235+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
236+
$managerRegistryMock,
237+
);
238+
239+
$this->assertSame($returnObject, $dataProvider->provide($operation, ['companyId' => 1], ['fetch_data' => false]));
240+
}
241+
242+
public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNotADoctrineIdentifier(): void
243+
{
244+
$returnObject = new \stdClass();
245+
246+
$queryMock = $this->createMock($this->getQueryClass());
247+
$queryMock->method('getOneOrNullResult')->willReturn($returnObject);
248+
249+
$queryBuilderMock = $this->createMock(QueryBuilder::class);
250+
$queryBuilderMock->method('getQuery')->willReturn($queryMock);
251+
$queryBuilderMock->method('getRootAliases')->willReturn(['o']);
252+
253+
// The Doctrine identifier is "id" while the resource exposes "uuid" as its API identifier:
254+
// getReference() cannot be built from "uuid", so we must fall back to the query.
255+
$classMetadataMock = $this->createMock(ClassMetadata::class);
256+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
257+
258+
$repositoryMock = $this->createMock(EntityRepository::class);
259+
$repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock);
260+
261+
$managerMock = $this->createMock(EntityManagerInterface::class);
262+
$managerMock->method('getClassMetadata')->willReturn($classMetadataMock);
263+
$managerMock->method('getRepository')->willReturn($repositoryMock);
264+
$managerMock->expects($this->never())->method('getReference');
265+
266+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
267+
$managerRegistryMock->method('getManagerForClass')->willReturn($managerMock);
268+
269+
$operation = (new Get())->withUriVariables([
270+
'uuid' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['uuid'])->withParameterName('uuid'),
271+
])->withName('get')->withClass(Employee::class);
272+
273+
$dataProvider = new ItemProvider(
274+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
275+
$managerRegistryMock,
276+
);
277+
278+
$this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false]));
279+
}
280+
141281
public function testQueryResultExtension(): void
142282
{
143283
$returnObject = new \stdClass();
@@ -282,6 +422,7 @@ public function testGetSubresourceFromProperty(): void
282422
$queryBuilderMock->expects($this->once())->method('setParameter')->with('id_p1', 1, Types::INTEGER);
283423

284424
$employeeClassMetadataMock = $this->createMock(ClassMetadata::class);
425+
$employeeClassMetadataMock->method('hasAssociation')->with('company')->willReturn(true);
285426
$employeeClassMetadataMock->method('getAssociationMapping')->with('company')->willReturn(
286427
class_exists(ManyToOneAssociationMapping::class) ?
287428
new ManyToOneAssociationMapping('company', Employee::class, Company::class) :

src/Elasticsearch/Serializer/DocumentNormalizer.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Elasticsearch\Serializer;
1515

1616
use ApiPlatform\Metadata\HttpOperation;
17+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
1718
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1819
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1920
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
@@ -26,6 +27,7 @@
2627
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
2728
use Symfony\Component\Serializer\SerializerAwareInterface;
2829
use Symfony\Component\Serializer\SerializerInterface;
30+
use Symfony\Component\TypeInfo\TypeIdentifier;
2931

3032
/**
3133
* Document denormalizer for Elasticsearch.
@@ -49,6 +51,7 @@ public function __construct(
4951
?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null,
5052
?callable $objectClassResolver = null,
5153
array $defaultContext = [],
54+
private readonly ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null,
5255
) {
5356
$this->decoratedNormalizer = new ObjectNormalizer($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext);
5457
}
@@ -109,15 +112,35 @@ private function populateIdentifier(array $data, string $class): array
109112
}
110113
}
111114

112-
$identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);
115+
$sourceKey = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);
113116

114-
if (!isset($data['_source'][$identifier])) {
115-
$data['_source'][$identifier] = $data['_id'];
117+
if (!isset($data['_source'][$sourceKey])) {
118+
$data['_source'][$sourceKey] = $this->coerceIdentifier($class, $identifier, $data['_id']);
116119
}
117120

118121
return $data;
119122
}
120123

124+
/**
125+
* Elasticsearch always exposes the document identifier (`_id`) as a string. When the resource
126+
* identifier is declared as an int, casting it back avoids a type mismatch in the inner
127+
* ObjectNormalizer. String identifiers (e.g. UUIDs) are left untouched.
128+
*/
129+
private function coerceIdentifier(string $class, string $identifier, string $value): int|string
130+
{
131+
if (null === $this->propertyMetadataFactory || !is_numeric($value)) {
132+
return $value;
133+
}
134+
135+
$nativeType = $this->propertyMetadataFactory->create($class, $identifier)->getNativeType();
136+
137+
if ($nativeType?->isIdentifiedBy(TypeIdentifier::INT) && !$nativeType->isIdentifiedBy(TypeIdentifier::STRING)) {
138+
return (int) $value;
139+
}
140+
141+
return $value;
142+
}
143+
121144
/**
122145
* {@inheritdoc}
123146
*/

0 commit comments

Comments
 (0)