Skip to content

Commit 05a5d4d

Browse files
authored
feat(laravel): object mapper (#7704)
1 parent ae6a75d commit 05a5d4d

8 files changed

Lines changed: 258 additions & 5 deletions

File tree

src/Laravel/ApiPlatformProvider.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
use ApiPlatform\Metadata\Resource\Factory\ConcernsResourceNameCollectionFactory;
127127
use ApiPlatform\Metadata\Resource\Factory\LinkFactory;
128128
use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface;
129+
use ApiPlatform\Metadata\Resource\Factory\ObjectMapperMetadataCollectionFactory;
129130
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
130131
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
131132
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
@@ -146,15 +147,18 @@
146147
use ApiPlatform\State\CallableProcessor;
147148
use ApiPlatform\State\CallableProvider;
148149
use ApiPlatform\State\ErrorProvider;
150+
use ApiPlatform\State\ObjectMapper\ObjectMapper;
149151
use ApiPlatform\State\Pagination\Pagination;
150152
use ApiPlatform\State\Pagination\PaginationOptions;
151153
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
154+
use ApiPlatform\State\Processor\ObjectMapperProcessor;
152155
use ApiPlatform\State\Processor\RespondProcessor;
153156
use ApiPlatform\State\Processor\SerializeProcessor;
154157
use ApiPlatform\State\Processor\WriteProcessor;
155158
use ApiPlatform\State\ProcessorInterface;
156159
use ApiPlatform\State\Provider\ContentNegotiationProvider;
157160
use ApiPlatform\State\Provider\DeserializeProvider;
161+
use ApiPlatform\State\Provider\ObjectMapperProvider;
158162
use ApiPlatform\State\Provider\ParameterProvider;
159163
use ApiPlatform\State\Provider\ReadProvider;
160164
use ApiPlatform\State\ProviderInterface;
@@ -166,6 +170,9 @@
166170
use Negotiation\Negotiator;
167171
use PHPStan\PhpDocParser\Parser\PhpDocParser;
168172
use Psr\Log\LoggerInterface;
173+
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
174+
use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory;
175+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
169176
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
170177
use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor;
171178
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
@@ -347,6 +354,16 @@ public function register(): void
347354
return new HydraPrefixNameConverter(new MetadataAwareNameConverter($app->make(ClassMetadataFactoryInterface::class), $nameConverter), $defaultContext);
348355
});
349356

357+
// ObjectMapper metadata factory support
358+
if (interface_exists(ObjectMapperInterface::class)) {
359+
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
360+
return new ObjectMapperMetadataCollectionFactory(
361+
$inner,
362+
$app->make(ObjectMapperMetadataFactoryInterface::class)
363+
);
364+
});
365+
}
366+
350367
$this->app->singleton(OperationMetadataFactory::class, static function (Application $app) {
351368
return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
352369
});
@@ -404,6 +421,23 @@ public function register(): void
404421

405422
$this->app->bind(ProviderInterface::class, ContentNegotiationProvider::class);
406423

424+
// ObjectMapper support
425+
if (interface_exists(ObjectMapperInterface::class)) {
426+
$this->app->singleton(ObjectMapperMetadataFactoryInterface::class, ReflectionObjectMapperMetadataFactory::class);
427+
428+
$this->app->singleton(ObjectMapper::class, static function (Application $app) {
429+
if (!$app->bound('api_platform.object_mapper')) {
430+
return null;
431+
}
432+
433+
return new ObjectMapper($app->make('api_platform.object_mapper'));
434+
});
435+
436+
$this->app->extend(ProviderInterface::class, static function (ProviderInterface $inner, Application $app) {
437+
return new ObjectMapperProvider($app->make(ObjectMapper::class), $inner);
438+
});
439+
}
440+
407441
$this->app->singleton(RespondProcessor::class, static function (Application $app) {
408442
$decorated = new RespondProcessor(
409443
$app->make(IriConverterInterface::class),
@@ -470,6 +504,13 @@ public function register(): void
470504
return $app->make(WriteProcessor::class);
471505
});
472506

507+
// ObjectMapperProcessor wraps the base processor if available
508+
if (interface_exists(ObjectMapperInterface::class)) {
509+
$this->app->extend(ProcessorInterface::class, static function (ProcessorInterface $inner, Application $app) {
510+
return new ObjectMapperProcessor($app->make(ObjectMapper::class), $inner);
511+
});
512+
}
513+
473514
$this->app->singleton(ObjectNormalizer::class, static function (Application $app) {
474515
$config = $app['config'];
475516
$defaultContext = $config->get('api-platform.serializer', []);

src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Laravel\Eloquent\State\CollectionProvider;
1717
use ApiPlatform\Laravel\Eloquent\State\ItemProvider;
18+
use ApiPlatform\Laravel\Eloquent\State\Options;
1819
use ApiPlatform\Laravel\Eloquent\State\PersistProcessor;
1920
use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor;
2021
use ApiPlatform\Metadata\CollectionOperationInterface;
@@ -32,11 +33,14 @@
3233
use ApiPlatform\Metadata\Put;
3334
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
3435
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
36+
use ApiPlatform\State\Util\StateOptionsTrait;
3537
use Illuminate\Database\Eloquent\Model;
3638
use Illuminate\Support\Facades\Gate;
3739

3840
final class EloquentResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
3941
{
42+
use StateOptionsTrait;
43+
4044
private const POLICY_METHODS = [
4145
Put::class => 'update',
4246
Post::class => 'create',
@@ -71,18 +75,24 @@ public function create(string $resourceClass): ResourceMetadataCollection
7175
return $this->decorated->create($resourceClass);
7276
}
7377

74-
if (!$model instanceof Model) {
75-
return $resourceMetadataCollection;
76-
}
78+
$isModel = $model instanceof Model;
7779

7880
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
7981
$operations = $resourceMetadata->getOperations();
8082
foreach ($operations ?? [] as $operationName => $operation) {
83+
// Check if this operation uses Eloquent via stateOptions
84+
$modelClass = $this->getStateOptionsClass($operation, $resourceClass, Options::class);
85+
$usesEloquent = $isModel || ($modelClass !== $resourceClass);
86+
87+
if (!$usesEloquent) {
88+
continue;
89+
}
90+
8191
if (!$operation->getProvider()) {
8292
$operation = $operation->withProvider($operation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class);
8393
}
8494

85-
if (!$operation->getPolicy() && ($policy = Gate::getPolicyFor($model))) {
95+
if ($isModel && !$operation->getPolicy() && ($policy = Gate::getPolicyFor($model))) {
8696
$policyMethod = self::POLICY_METHODS[$operation::class] ?? null;
8797
if ($operation instanceof Put && $operation->getAllowCreate()) {
8898
$policyMethod = self::POLICY_METHODS[Post::class];
@@ -104,7 +114,15 @@ public function create(string $resourceClass): ResourceMetadataCollection
104114

105115
$graphQlOperations = $resourceMetadata->getGraphQlOperations();
106116
foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) {
107-
if (!$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) {
117+
// Check if this operation uses Eloquent via stateOptions
118+
$modelClass = $this->getStateOptionsClass($graphQlOperation, $resourceClass, Options::class);
119+
$usesEloquent = $isModel || ($modelClass !== $resourceClass);
120+
121+
if (!$usesEloquent) {
122+
continue;
123+
}
124+
125+
if ($isModel && !$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) {
108126
if (($policyMethod = self::POLICY_METHODS[$graphQlOperation::class] ?? null) && method_exists($policy, $policyMethod)) {
109127
$graphQlOperation = $graphQlOperation->withPolicy($policyMethod);
110128
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Illuminate\Foundation\Testing\RefreshDatabase;
18+
use Orchestra\Testbench\Concerns\WithWorkbench;
19+
use Orchestra\Testbench\TestCase;
20+
use Workbench\Database\Factories\ProductFactory;
21+
22+
class ObjectMapperTest extends TestCase
23+
{
24+
use ApiTestAssertionsTrait;
25+
use RefreshDatabase;
26+
use WithWorkbench;
27+
28+
public function testObjectMapperMapsModelToApiResource(): void
29+
{
30+
/** @var \Workbench\App\Models\Product $product */
31+
$product = ProductFactory::new(['name' => 'Test Product', 'price' => 19.99])->create();
32+
33+
$response = $this->get('/api/products/'.$product->id, ['Accept' => ['application/ld+json']]);
34+
$response->assertStatus(200);
35+
36+
$data = $response->json();
37+
$this->assertArrayHasKey('name', $data);
38+
$this->assertArrayHasKey('price', $data);
39+
$this->assertEquals('Test Product', $data['name']);
40+
$this->assertEquals(19.99, $data['price']);
41+
}
42+
43+
public function testObjectMapperMapsCollectionOfModels(): void
44+
{
45+
ProductFactory::new(['name' => 'Product 1', 'price' => 10.00])->create();
46+
ProductFactory::new(['name' => 'Product 2', 'price' => 20.00])->create();
47+
48+
$response = $this->get('/api/products', ['Accept' => ['application/ld+json']]);
49+
$response->assertStatus(200);
50+
51+
$data = $response->json();
52+
$this->assertArrayHasKey('member', $data);
53+
$this->assertCount(2, $data['member']);
54+
$this->assertEquals('Product 1', $data['member'][0]['name']);
55+
$this->assertEquals('Product 2', $data['member'][1]['name']);
56+
}
57+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 Workbench\App\ApiResource;
15+
16+
use ApiPlatform\Laravel\Eloquent\State\Options;
17+
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use Symfony\Component\ObjectMapper\Attribute\Map;
20+
use Workbench\App\Models\Product as ProductModel;
21+
22+
#[ApiResource(
23+
shortName: 'Product',
24+
stateOptions: new Options(modelClass: ProductModel::class),
25+
)]
26+
#[Map(source: ProductModel::class)]
27+
class Product
28+
{
29+
#[ApiProperty(identifier: true)]
30+
public ?int $id = null;
31+
32+
public ?string $name = null;
33+
34+
public ?float $price = null;
35+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 Workbench\App\Models;
15+
16+
use Illuminate\Database\Eloquent\Factories\HasFactory;
17+
use Illuminate\Database\Eloquent\Model;
18+
19+
class Product extends Model
20+
{
21+
use HasFactory;
22+
23+
protected $fillable = ['name', 'price'];
24+
25+
protected $casts = [
26+
'price' => 'float',
27+
];
28+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 Workbench\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\Product;
18+
19+
/**
20+
* @extends Factory<Product>
21+
*/
22+
class ProductFactory extends Factory
23+
{
24+
protected $model = Product::class;
25+
26+
/**
27+
* @return array<string, mixed>
28+
*/
29+
public function definition(): array
30+
{
31+
return [
32+
'name' => fake()->word(),
33+
'price' => fake()->randomFloat(2, 1, 100),
34+
];
35+
}
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
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+
public function up(): void
20+
{
21+
Schema::create('products', static function (Blueprint $table): void {
22+
$table->id();
23+
$table->string('name');
24+
$table->decimal('price', 10, 2);
25+
$table->timestamps();
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('products');
32+
}
33+
};

src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ public function create(string $resourceClass): ResourceMetadataCollection
5252
$entityClass = $options->getDocumentClass();
5353
}
5454

55+
// Laravel Eloquent State Options
56+
if (($options = $operation->getStateOptions()) && method_exists($options, 'getModelClass') && $options->getModelClass()) {
57+
$entityClass = $options->getModelClass();
58+
}
59+
5560
$class = $operation->getInput()['class'] ?? $operation->getClass();
5661
$outputClass = $operation->getOutput()['class'] ?? null;
5762
$entityMap = null;

0 commit comments

Comments
 (0)