Skip to content

Commit 1b677e9

Browse files
committed
fix(laravel): read property type before serialization
fixes #7316
1 parent d1abfc0 commit 1b677e9

File tree

8 files changed

+243
-20
lines changed

8 files changed

+243
-20
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
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;
8990
use ApiPlatform\Laravel\Eloquent\Serializer\SerializerContextBuilder as EloquentSerializerContextBuilder;
9091
use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController;
9192
use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController;
@@ -195,15 +196,16 @@ public function register(): void
195196
{
196197
$this->mergeConfigFrom(__DIR__.'/config/api-platform.php', 'api-platform');
197198

198-
$this->app->singleton(PropertyInfoExtractorInterface::class, function () {
199+
$this->app->singleton(PropertyInfoExtractorInterface::class, function (Application $app) {
199200
$phpstanExtractor = class_exists(PhpDocParser::class) ? new PhpStanExtractor() : null;
200201
$reflectionExtractor = new ReflectionExtractor();
202+
$eloquentExtractor = new EloquentExtractor($app->make(ModelMetadata::class));
201203

202204
return new PropertyInfoExtractor(
203205
[$reflectionExtractor],
204206
$phpstanExtractor ? [$phpstanExtractor, $reflectionExtractor] : [$reflectionExtractor],
205207
[],
206-
[$reflectionExtractor],
208+
[$eloquentExtractor],
207209
[$reflectionExtractor]
208210
);
209211
});
@@ -262,10 +264,10 @@ public function register(): void
262264
return new CachePropertyMetadataFactory(
263265
new SchemaPropertyMetadataFactory(
264266
$app->make(ResourceClassResolverInterface::class),
265-
new PropertyInfoPropertyMetadataFactory(
266-
$app->make(PropertyInfoExtractorInterface::class),
267-
new SerializerPropertyMetadataFactory(
268-
$app->make(SerializerClassMetadataFactory::class),
267+
new SerializerPropertyMetadataFactory(
268+
$app->make(SerializerClassMetadataFactory::class),
269+
new PropertyInfoPropertyMetadataFactory(
270+
$app->make(PropertyInfoExtractorInterface::class),
269271
new AttributePropertyMetadataFactory(
270272
new EloquentAttributePropertyMetadataFactory(
271273
new EloquentPropertyMetadataFactory(

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

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ public function create(string $resourceClass, string $property, array $options =
6363
$propertyMetadata = new ApiProperty();
6464
}
6565

66-
if ($model->getKeyName() === $property) {
67-
$propertyMetadata = $propertyMetadata->withIdentifier(true)->withWritable($propertyMetadata->isWritable() ?? false);
68-
}
66+
// if ($model->getKeyName() === $property) {
67+
// $propertyMetadata = $propertyMetadata->withIdentifier(true)
68+
// ->withWritable($propertyMetadata->isWritable() ?? false);
69+
// }
6970

7071
foreach ($this->modelMetadata->getAttributes($model) as $p) {
7172
if ($p['name'] !== $property) {
@@ -89,17 +90,6 @@ public function create(string $resourceClass, string $property, array $options =
8990
$propertyMetadata = $propertyMetadata
9091
->withBuiltinTypes([$type]);
9192

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-
10393
return $propertyMetadata;
10494
}
10595

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace ApiPlatform\Laravel\Eloquent\PropertyInfo;
4+
5+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
8+
9+
class EloquentExtractor implements PropertyAccessExtractorInterface
10+
{
11+
public function __construct(private readonly ModelMetadata $modelMetadata)
12+
{
13+
}
14+
15+
public function isReadable(string $class, string $property, array $context = []): ?bool
16+
{
17+
if (!is_a($class, Model::class, true)) {
18+
return null;
19+
}
20+
21+
try {
22+
$refl = new \ReflectionClass($class);
23+
$model = $refl->newInstanceWithoutConstructor();
24+
} catch (\ReflectionException) {
25+
return null;
26+
}
27+
28+
foreach ($this->modelMetadata->getAttributes($model) as $p) {
29+
if ($p['name'] !== $property) {
30+
continue;
31+
}
32+
33+
if (($visible = $model->getVisible()) && \in_array($property, $visible, true)) {
34+
return true;
35+
}
36+
37+
if (($hidden = $model->getHidden()) && \in_array($property, $hidden, true)) {
38+
return false;
39+
}
40+
41+
return true;
42+
}
43+
44+
return null;
45+
}
46+
47+
public function isWritable(string $class, string $property, array $context = []): ?bool
48+
{
49+
if (!is_a($class, Model::class, true)) {
50+
return null;
51+
}
52+
53+
try {
54+
$refl = new \ReflectionClass($class);
55+
$model = $refl->newInstanceWithoutConstructor();
56+
} catch (\ReflectionException) {
57+
return null;
58+
}
59+
60+
foreach ($this->modelMetadata->getAttributes($model) as $p) {
61+
if ($p['name'] !== $property) {
62+
continue;
63+
}
64+
65+
if (($fillable = $model->getFillable())) {
66+
return \in_array($property, $fillable);
67+
}
68+
69+
return true;
70+
}
71+
72+
return null;
73+
}
74+
}

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
}
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+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\Order;
18+
19+
class OrderFactory extends Factory
20+
{
21+
protected $model = Order::class;
22+
23+
public function definition()
24+
{
25+
return [
26+
'number' => $this->faker->randomNumber(),
27+
];
28+
}
29+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
use Illuminate\Database\Migrations\Migration;
15+
use Illuminate\Database\Schema\Blueprint;
16+
use Illuminate\Support\Facades\Schema;
17+
18+
return new class extends Migration {
19+
/**
20+
* Run the migrations.
21+
*/
22+
public function up(): void
23+
{
24+
Schema::create('orders', function (Blueprint $table): void {
25+
$table->id();
26+
$table->integer('number');
27+
$table->timestamps();
28+
});
29+
}
30+
31+
/**
32+
* Reverse the migrations.
33+
*/
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('orders');
37+
}
38+
};

0 commit comments

Comments
 (0)