Skip to content

Commit 552de83

Browse files
committed
Merge 4.2
2 parents 9f98aff + 31289b8 commit 552de83

File tree

24 files changed

+602
-9
lines changed

24 files changed

+602
-9
lines changed

.github/workflows/commitlint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
pull_request_target:
55
types: [opened, reopened, synchronize]
66

7+
permissions:
8+
contents: read
9+
710
jobs:
811
commitlint:
912
if: github.event_name == 'pull_request_target'

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ tests/Fixtures/app/console
3737
# Clear cache (Critical when switching branches/modes)
3838
rm -rf tests/Fixtures/app/var/cache/test
3939
40+
# Remove stale vendor dirs inside src/ (if PHPUnit hangs)
41+
# Component packages under src/ may have leftover vendor/ directories
42+
# from previous `composer link` runs. These cause PHPUnit to scan them
43+
# indefinitely. Remove them before running tests:
44+
find src -name vendor -exec rm -rf {} +
45+
4046
# PHPUnit (Preferred)
4147
vendor/bin/phpunit --filter testMethodName
4248

CHANGELOG.md

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

6868
* When using `output` with `itemUriTemplate` on a collection operation, the JSON-LD `@type` will now use the resource class name instead of the output DTO class name for semantic consistency with `itemUriTemplate` behavior.
6969

70+
## v4.2.19
71+
72+
### Bug fixes
73+
74+
* [04c30b7ee](https://github.com/api-platform/core/commit/04c30b7eee4af443ad16ec6dd2135a4511dc3138) fix(jsonapi): prevent double unwrapping of data.attributes with input DTOs
75+
* [c6236f313](https://github.com/api-platform/core/commit/c6236f313864661a4cab0caa926a2520500c0257) fix(serializer): report all missing constructor arguments in instantiateObject
76+
7077
## v4.2.18
7178

7279
### Bug fixes

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
201201
*/
202202
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
203203
{
204+
// When re-entering for input DTO denormalization, data has already been
205+
// unwrapped from the JSON:API structure by the first pass. Skip extraction.
206+
if (isset($context['api_platform_input'])) {
207+
return parent::denormalize($data, $class, $format, $context);
208+
}
209+
204210
// Avoid issues with proxies if we populated the object
205211
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
206212
if (true !== ($context['api_allow_update'] ?? true)) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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\JsonApi\Tests\Fixtures;
15+
16+
final class InputDto
17+
{
18+
public string $title = '';
19+
public string $body = '';
20+
}

src/JsonApi/Tests/Serializer/ItemNormalizerTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
1818
use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference;
1919
use ApiPlatform\JsonApi\Tests\Fixtures\Dummy;
20+
use ApiPlatform\JsonApi\Tests\Fixtures\InputDto;
2021
use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy;
2122
use ApiPlatform\Metadata\ApiProperty;
2223
use ApiPlatform\Metadata\ApiResource;
@@ -41,6 +42,7 @@
4142
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
4243
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
4344
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
45+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
4446
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
4547
use Symfony\Component\Serializer\Serializer;
4648
use Symfony\Component\Serializer\SerializerInterface;
@@ -904,4 +906,87 @@ public function testDenormalizeRelationWithEntityIdentifier(): void
904906

905907
$this->assertInstanceOf(Dummy::class, $result);
906908
}
909+
910+
/**
911+
* Reproducer for https://github.com/api-platform/core/issues/7794.
912+
*
913+
* When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters
914+
* the serializer with the already-unwrapped (flat) data plus an 'api_platform_input'
915+
* context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second
916+
* time on the flat data, tries to read $data['data']['attributes'] and gets null,
917+
* which nulls every DTO property.
918+
*/
919+
public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void
920+
{
921+
$jsonApiData = [
922+
'data' => [
923+
'type' => 'dummy',
924+
'attributes' => [
925+
'title' => 'Hello',
926+
'body' => 'World',
927+
],
928+
],
929+
];
930+
931+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
932+
$propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([]));
933+
$propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body']));
934+
935+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
936+
$propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
937+
$propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
938+
939+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
940+
941+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
942+
$propertyAccessorProphecy->setValue(Argument::type(InputDto::class), Argument::type('string'), Argument::any())
943+
->will(static function ($args): void {
944+
$args[0]->{$args[1]} = $args[2];
945+
});
946+
947+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
948+
$resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class);
949+
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);
950+
$resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false);
951+
$resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class);
952+
953+
$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
954+
$resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [
955+
(new ApiResource())->withOperations(new Operations([new Get(name: 'get')])),
956+
]));
957+
958+
$normalizer = new ItemNormalizer(
959+
$propertyNameCollectionFactoryProphecy->reveal(),
960+
$propertyMetadataFactoryProphecy->reveal(),
961+
$iriConverterProphecy->reveal(),
962+
$resourceClassResolverProphecy->reveal(),
963+
$propertyAccessorProphecy->reveal(),
964+
new ReservedAttributeNameConverter(),
965+
null,
966+
[],
967+
$resourceMetadataCollectionFactory->reveal(),
968+
);
969+
970+
// Create a mock serializer that simulates the real serializer chain:
971+
// when re-entering for the input DTO, it calls back into the normalizer.
972+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
973+
$serializerProphecy->willImplement(DenormalizerInterface::class);
974+
$serializerProphecy->willImplement(NormalizerInterface::class);
975+
$serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array'))
976+
->will(static function ($args) use ($normalizer) {
977+
// This simulates the serializer re-entering the normalizer chain
978+
return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]);
979+
});
980+
981+
$normalizer->setSerializer($serializerProphecy->reveal());
982+
983+
$result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [
984+
'input' => ['class' => InputDto::class],
985+
'resource_class' => Dummy::class,
986+
]);
987+
988+
$this->assertInstanceOf(InputDto::class, $result);
989+
$this->assertSame('Hello', $result->title);
990+
$this->assertSame('World', $result->body);
991+
}
907992
}

src/JsonApi/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"api-platform/documentation": "^4.2",
2626
"api-platform/json-schema": "^4.2",
2727
"api-platform/metadata": "^4.2",
28-
"api-platform/serializer": "^4.2.4",
28+
"api-platform/serializer": "^4.2.18",
2929
"api-platform/state": "^4.2.4",
3030
"symfony/error-handler": "^6.4 || ^7.0 || ^8.0",
3131
"symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0",

src/Laravel/ApiPlatformDeferredProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class ApiPlatformDeferredProvider extends ServiceProvider implements DeferrableP
104104
public function register(): void
105105
{
106106
$directory = app_path();
107-
$classes = ReflectionClassRecursiveIterator::getReflectionClassesFromDirectories([$directory], '(?!.*Test\.php$)');
107+
$classes = ReflectionClassRecursiveIterator::getReflectionClassesFromDirectories([$directory], '(?!.*(?:Test|\.blade)\.php$)');
108108

109109
foreach ($classes as $className => $refl) {
110110
foreach ($refl->getAttributes() as $attribute) {

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Laravel\Eloquent\Metadata;
1515

1616
use Illuminate\Database\Eloquent\Model;
17+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1718
use Illuminate\Database\Eloquent\Relations\Relation;
1819
use Illuminate\Support\Str;
1920
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
@@ -77,7 +78,10 @@ public function getAttributes(Model $model): array
7778
$indexes = $schema->getIndexes($table);
7879
$relations = $this->getRelations($model);
7980

80-
$foreignKeys = array_flip(array_filter(array_column($relations, 'foreign_key')));
81+
// Only exclude BelongsTo foreign keys — those are local columns on this model's table.
82+
// HasMany/HasOne foreign keys reference the related table and should not be excluded.
83+
$belongsToRelations = array_filter($relations, static fn ($r) => is_a($r['type'], BelongsTo::class, true));
84+
$foreignKeys = array_flip(array_filter(array_column($belongsToRelations, 'foreign_key')));
8185
$attributes = [];
8286

8387
foreach ($columns as $column) {

src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Orchestra\Testbench\Concerns\WithWorkbench;
2121
use Orchestra\Testbench\TestCase;
2222
use Workbench\App\Models\Book;
23+
use Workbench\App\Models\Device;
2324

2425
/**
2526
* @author Tobias Oitzinger <tobiasoitzinger@gmail.com>
@@ -80,4 +81,22 @@ public function secret(): HasMany // @phpstan-ignore-line
8081
$metadata = new ModelMetadata();
8182
$this->assertCount(1, $metadata->getRelations($model));
8283
}
84+
85+
/**
86+
* When a model has a custom primary key (e.g. device_id) and a HasMany
87+
* relation whose foreign key on the related table has the same name,
88+
* the primary key must not be excluded from getAttributes().
89+
*
90+
* Only BelongsTo foreign keys are local columns that should be excluded.
91+
* HasMany/HasOne foreign keys reference the related model's table.
92+
*/
93+
public function testCustomPrimaryKeyNotExcludedByHasManyForeignKey(): void
94+
{
95+
$model = new Device();
96+
$metadata = new ModelMetadata();
97+
$attributes = $metadata->getAttributes($model);
98+
99+
$this->assertArrayHasKey('device_id', $attributes, 'Primary key "device_id" should not be excluded from attributes when it matches a HasMany foreign key name.');
100+
$this->assertTrue($attributes['device_id']['primary'], 'The device_id attribute should be marked as primary key.');
101+
}
83102
}

0 commit comments

Comments
 (0)