Skip to content

Commit d1073bc

Browse files
authored
fix(laravel): read property type before serialization (#7332)
fixes #7316
1 parent 385953a commit d1073bc

File tree

12 files changed

+335
-22
lines changed

12 files changed

+335
-22
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
8787
use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver;
8888
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
89+
use ApiPlatform\Laravel\Eloquent\PropertyInfo\EloquentExtractor;
90+
use ApiPlatform\Laravel\Eloquent\Serializer\EloquentNameConverter;
8991
use ApiPlatform\Laravel\Eloquent\Serializer\SerializerContextBuilder as EloquentSerializerContextBuilder;
9092
use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController;
9193
use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController;
@@ -195,15 +197,16 @@ public function register(): void
195197
{
196198
$this->mergeConfigFrom(__DIR__.'/config/api-platform.php', 'api-platform');
197199

198-
$this->app->singleton(PropertyInfoExtractorInterface::class, function () {
200+
$this->app->singleton(PropertyInfoExtractorInterface::class, function (Application $app) {
199201
$phpstanExtractor = class_exists(PhpDocParser::class) ? new PhpStanExtractor() : null;
200202
$reflectionExtractor = new ReflectionExtractor();
203+
$eloquentExtractor = new EloquentExtractor($app->make(ModelMetadata::class));
201204

202205
return new PropertyInfoExtractor(
203206
[$reflectionExtractor],
204207
$phpstanExtractor ? [$phpstanExtractor, $reflectionExtractor] : [$reflectionExtractor],
205208
[],
206-
[$reflectionExtractor],
209+
[$eloquentExtractor],
207210
[$reflectionExtractor]
208211
);
209212
});
@@ -262,10 +265,10 @@ public function register(): void
262265
return new CachePropertyMetadataFactory(
263266
new SchemaPropertyMetadataFactory(
264267
$app->make(ResourceClassResolverInterface::class),
265-
new PropertyInfoPropertyMetadataFactory(
266-
$app->make(PropertyInfoExtractorInterface::class),
267-
new SerializerPropertyMetadataFactory(
268-
$app->make(SerializerClassMetadataFactory::class),
268+
new SerializerPropertyMetadataFactory(
269+
$app->make(SerializerClassMetadataFactory::class),
270+
new PropertyInfoPropertyMetadataFactory(
271+
$app->make(PropertyInfoExtractorInterface::class),
269272
new AttributePropertyMetadataFactory(
270273
new EloquentAttributePropertyMetadataFactory(
271274
new EloquentPropertyMetadataFactory(
@@ -315,7 +318,7 @@ public function register(): void
315318
$config = $app['config'];
316319
$nameConverter = $config->get('api-platform.name_converter', SnakeCaseToCamelCaseNameConverter::class);
317320
if ($nameConverter && class_exists($nameConverter)) {
318-
$nameConverter = $app->make($nameConverter);
321+
$nameConverter = new EloquentNameConverter($app->make($nameConverter));
319322
}
320323

321324
$defaultContext = $config->get('api-platform.serializer', []);
@@ -400,9 +403,13 @@ public function register(): void
400403
});
401404
$this->app->bind(SerializerContextBuilderInterface::class, EloquentSerializerContextBuilder::class);
402405
$this->app->singleton(EloquentSerializerContextBuilder::class, function (Application $app) {
406+
/** @var ConfigRepository */
407+
$config = $app['config'];
408+
403409
return new EloquentSerializerContextBuilder(
404410
$app->make(SerializerContextBuilder::class),
405-
$app->make(PropertyNameCollectionFactoryInterface::class)
411+
$app->make(PropertyNameCollectionFactoryInterface::class),
412+
$config->get('api-platform.name_converter', SnakeCaseToCamelCaseNameConverter::class)
406413
);
407414
});
408415

src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public function create(string $resourceClass, string $property, array $options =
6464
}
6565

6666
if ($model->getKeyName() === $property) {
67-
$propertyMetadata = $propertyMetadata->withIdentifier(true)->withWritable($propertyMetadata->isWritable() ?? false);
67+
$propertyMetadata = $propertyMetadata->withIdentifier(true);
6868
}
6969

7070
foreach ($this->modelMetadata->getAttributes($model) as $p) {
@@ -89,17 +89,6 @@ public function create(string $resourceClass, string $property, array $options =
8989
$propertyMetadata = $propertyMetadata
9090
->withBuiltinTypes([$type]);
9191

92-
// If these are set let the SerializerPropertyMetadataFactory do the work
93-
if (!isset($options['denormalization_groups'])) {
94-
$propertyMetadata = $propertyMetadata
95-
->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable']);
96-
}
97-
98-
if (!isset($options['normalization_groups'])) {
99-
$propertyMetadata = $propertyMetadata
100-
->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']);
101-
}
102-
10392
return $propertyMetadata;
10493
}
10594

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\Laravel\Eloquent\PropertyInfo;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use Illuminate\Database\Eloquent\Model;
18+
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
19+
20+
class EloquentExtractor implements PropertyAccessExtractorInterface
21+
{
22+
public function __construct(private readonly ModelMetadata $modelMetadata)
23+
{
24+
}
25+
26+
/**
27+
* @param array<string, mixed> $context
28+
*/
29+
public function isReadable(string $class, string $property, array $context = []): ?bool
30+
{
31+
if (!is_a($class, Model::class, true)) {
32+
return null;
33+
}
34+
35+
try {
36+
$refl = new \ReflectionClass($class);
37+
$model = $refl->newInstanceWithoutConstructor();
38+
} catch (\ReflectionException) {
39+
return null;
40+
}
41+
42+
foreach ($this->modelMetadata->getAttributes($model) as $p) {
43+
if ($p['name'] !== $property) {
44+
continue;
45+
}
46+
47+
if (($visible = $model->getVisible()) && \in_array($property, $visible, true)) {
48+
return true;
49+
}
50+
51+
if (($hidden = $model->getHidden()) && \in_array($property, $hidden, true)) {
52+
return false;
53+
}
54+
55+
return true;
56+
}
57+
58+
return null;
59+
}
60+
61+
/**
62+
* @param array<string, mixed> $context
63+
*/
64+
public function isWritable(string $class, string $property, array $context = []): ?bool
65+
{
66+
if (!is_a($class, Model::class, true)) {
67+
return null;
68+
}
69+
70+
try {
71+
$refl = new \ReflectionClass($class);
72+
$model = $refl->newInstanceWithoutConstructor();
73+
} catch (\ReflectionException) {
74+
return null;
75+
}
76+
77+
foreach ($this->modelMetadata->getAttributes($model) as $p) {
78+
if ($p['name'] !== $property) {
79+
continue;
80+
}
81+
82+
if ($fillable = $model->getFillable()) {
83+
return \in_array($property, $fillable, true);
84+
}
85+
86+
return true;
87+
}
88+
89+
return null;
90+
}
91+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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\Laravel\Eloquent\Serializer;
15+
16+
use Symfony\Component\Serializer\Exception\UnexpectedPropertyException;
17+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
18+
19+
final class EloquentNameConverter implements NameConverterInterface
20+
{
21+
public function __construct(private readonly NameConverterInterface $nameConverter)
22+
{
23+
}
24+
25+
/**
26+
* @param array<string, mixed> $context
27+
*/
28+
public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string
29+
{
30+
try {
31+
return $this->nameConverter->normalize($propertyName, $class, $format, $context); // @phpstan-ignore-line
32+
} catch (UnexpectedPropertyException $e) {
33+
return $this->nameConverter->denormalize($propertyName, $class, $format, $context); // @phpstan-ignore-line
34+
}
35+
}
36+
37+
/**
38+
* @param array<string, mixed> $context
39+
*/
40+
public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string
41+
{
42+
try {
43+
return $this->nameConverter->denormalize($propertyName, $class, $format, $context); // @phpstan-ignore-line
44+
} catch (UnexpectedPropertyException $e) {
45+
return $this->nameConverter->normalize($propertyName, $class, $format, $context); // @phpstan-ignore-line
46+
}
47+
}
48+
}

src/Laravel/Eloquent/Serializer/SerializerContextBuilder.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@
1717
use ApiPlatform\State\SerializerContextBuilderInterface;
1818
use Illuminate\Database\Eloquent\Model;
1919
use Symfony\Component\HttpFoundation\Request;
20+
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
21+
use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
2022
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2123

2224
final class SerializerContextBuilder implements SerializerContextBuilderInterface
2325
{
26+
/**
27+
* @param class-string $nameConverterClass
28+
*/
2429
public function __construct(
2530
private readonly SerializerContextBuilderInterface $decorated,
2631
private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
32+
private readonly ?string $nameConverterClass = null,
2733
) {
2834
}
2935

@@ -37,6 +43,12 @@ public function createFromRequest(Request $request, bool $normalization, ?array
3743
return $context;
3844
}
3945

46+
if (SnakeCaseToCamelCaseNameConverter::class === $this->nameConverterClass) {
47+
$context[SnakeCaseToCamelCaseNameConverter::REQUIRE_CAMEL_CASE_PROPERTIES] = true;
48+
} elseif (CamelCaseToSnakeCaseNameConverter::class === $this->nameConverterClass) {
49+
$context[CamelCaseToSnakeCaseNameConverter::REQUIRE_SNAKE_CASE_PROPERTIES] = true;
50+
}
51+
4052
if (!isset($context[AbstractNormalizer::ATTRIBUTES])) {
4153
// isWritable/isReadable is checked later on
4254
$context[AbstractNormalizer::ATTRIBUTES] = iterator_to_array($this->propertyNameCollectionFactory->create($context['resource_class'], ['serializer_groups' => $context['groups'] ?? null]));

src/Laravel/Tests/JsonLdTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,4 +369,13 @@ public function testResourceWithOptionModel(): void
369369
'@type' => 'Collection',
370370
]);
371371
}
372+
373+
public function testCustomRelation(): void
374+
{
375+
$response = $this->get('/api/home', headers: ['accept' => ['application/ld+json']]);
376+
$home = $response->json();
377+
$this->assertArrayHasKey('order', $home);
378+
$this->assertArrayHasKey('id', $home['order']);
379+
$this->assertArrayHasKey('number', $home['order']);
380+
}
372381
}

src/Laravel/Tests/ValidationTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ public function testValidationCamelCase(): void
4848
$response->assertStatus(422);
4949
}
5050

51+
public function testCamelCaseValid(): void
52+
{
53+
$data = [
54+
'surName' => 'ok',
55+
];
56+
57+
$response = $this->postJson('/api/issue_6932', $data, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
58+
$response->assertStatus(201);
59+
}
60+
5161
public function testValidationSnakeCase(): void
5262
{
5363
$data = [
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 Workbench\App\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\Operation;
20+
use Symfony\Component\Serializer\Annotation\Groups;
21+
use Workbench\App\Models\Order;
22+
use Workbench\Database\Factories\OrderFactory;
23+
24+
#[ApiResource(
25+
operations: [
26+
new Get(
27+
uriTemplate: '/home',
28+
normalizationContext: ['groups' => ['home:read']],
29+
provider: [self::class, 'provide'],
30+
),
31+
],
32+
)]
33+
class Home
34+
{
35+
#[ApiProperty(identifier: true)]
36+
public int $id = 1;
37+
38+
#[Groups(['home:read'])]
39+
public ?Order $order = null;
40+
41+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
42+
{
43+
$order = OrderFactory::new()->create();
44+
$home = new self();
45+
$home->order = $order;
46+
47+
return $home;
48+
}
49+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use Illuminate\Database\Eloquent\Factories\HasFactory;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Symfony\Component\Serializer\Attribute\Groups;
21+
22+
#[ApiResource()]
23+
#[ApiProperty(property: 'id', serialize: [new Groups(['home:read'])])]
24+
#[ApiProperty(property: 'number', serialize: [new Groups(['home:read'])])]
25+
class Order extends Model
26+
{
27+
use HasFactory;
28+
29+
protected $fillable = [
30+
'number',
31+
];
32+
}

0 commit comments

Comments
 (0)