Skip to content

Commit 9c46162

Browse files
authored
feat(laravel): provide a trait in addition to the annotation (#6543)
* feat(laravel): provide a trait in addition to the annotation * fix * fix * add support for ApiProperty and refactoring * fix Eloquent property collection * fix PHPStan
1 parent 70fdb5a commit 9c46162

13 files changed

+521
-256
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface;
4646
use ApiPlatform\Laravel\Eloquent\Filter\SearchFilter;
4747
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyMetadataFactory;
48-
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyNameCollectionFactory;
4948
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
5049
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
5150
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Resource\EloquentResourceCollectionMetadataFactory;
@@ -62,6 +61,9 @@
6261
use ApiPlatform\Laravel\Eloquent\State\PersistProcessor;
6362
use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor;
6463
use ApiPlatform\Laravel\Exception\ErrorHandler;
64+
use ApiPlatform\Laravel\Metadata\ConcernsPropertyNameCollectionMetadataFactory;
65+
use ApiPlatform\Laravel\Metadata\ConcernsResourceMetadataCollectionFactory;
66+
use ApiPlatform\Laravel\Metadata\ConcernsResourceNameCollectionFactory;
6567
use ApiPlatform\Laravel\Routing\IriConverter;
6668
use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter;
6769
use ApiPlatform\Laravel\Routing\SkolemIriConverter;
@@ -70,6 +72,7 @@
7072
use ApiPlatform\Laravel\State\SwaggerUiProcessor;
7173
use ApiPlatform\Laravel\State\ValidateProvider;
7274
use ApiPlatform\Metadata\Exception\NotExposedHttpException;
75+
use ApiPlatform\Metadata\Factory\Property\ClassLevelAttributePropertyNameCollectionFactory;
7376
use ApiPlatform\Metadata\FilterInterface;
7477
use ApiPlatform\Metadata\IdentifiersExtractor;
7578
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
@@ -205,7 +208,7 @@ public function register(): void
205208
$refl = new \ReflectionClass(Error::class);
206209
$paths[] = \dirname($refl->getFileName());
207210

208-
return new AttributesResourceNameCollectionFactory($paths);
211+
return new ConcernsResourceNameCollectionFactory($paths, new AttributesResourceNameCollectionFactory($paths));
209212
});
210213

211214
$this->app->bind(ResourceClassResolverInterface::class, ResourceClassResolver::class);
@@ -238,11 +241,13 @@ public function register(): void
238241
});
239242

240243
$this->app->singleton(PropertyNameCollectionFactoryInterface::class, function (Application $app) {
241-
return new EloquentAttributePropertyNameCollectionFactory(
242-
new EloquentPropertyNameCollectionMetadataFactory(
243-
$app->make(ModelMetadata::class),
244-
new PropertyInfoPropertyNameCollectionFactory($app->make(PropertyInfoExtractorInterface::class)),
245-
$app->make(ResourceClassResolverInterface::class)
244+
return new ClassLevelAttributePropertyNameCollectionFactory(
245+
new ConcernsPropertyNameCollectionMetadataFactory(
246+
new EloquentPropertyNameCollectionMetadataFactory(
247+
$app->make(ModelMetadata::class),
248+
new PropertyInfoPropertyNameCollectionFactory($app->make(PropertyInfoExtractorInterface::class)),
249+
$app->make(ResourceClassResolverInterface::class)
250+
)
246251
)
247252
);
248253
});
@@ -273,13 +278,20 @@ public function register(): void
273278
$app->make(PathSegmentNameGeneratorInterface::class),
274279
new NotExposedOperationResourceMetadataCollectionFactory(
275280
$app->make(LinkFactoryInterface::class),
276-
new AttributesResourceMetadataCollectionFactory(
277-
null,
281+
new ConcernsResourceMetadataCollectionFactory(
282+
new AttributesResourceMetadataCollectionFactory(
283+
null,
284+
$app->make(LoggerInterface::class),
285+
[
286+
'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/',
287+
],
288+
false,
289+
),
278290
$app->make(LoggerInterface::class),
279291
[
280292
'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/',
281293
],
282-
false
294+
false,
283295
)
284296
)
285297
)

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

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515

1616
use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory;
1717
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\Exception\PropertyNotFoundException;
1819
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
1920
use Illuminate\Database\Eloquent\Model;
2021

22+
/**
23+
* Handles Eloquent methods for relations.
24+
*/
2125
final class EloquentAttributePropertyMetadataFactory implements PropertyMetadataFactoryInterface
2226
{
2327
public function __construct(
@@ -28,45 +32,41 @@ public function __construct(
2832
public function create(string $resourceClass, string $property, array $options = []): ApiProperty
2933
{
3034
if (!class_exists($resourceClass)) {
31-
return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty();
35+
return $this->decorated?->create($resourceClass, $property, $options) ??
36+
$this->throwNotFound($resourceClass, $property);
3237
}
3338

3439
$refl = new \ReflectionClass($resourceClass);
3540
$model = $refl->newInstanceWithoutConstructor();
3641

3742
$propertyMetadata = $this->decorated?->create($resourceClass, $property, $options);
3843
if (!$model instanceof Model) {
39-
return $propertyMetadata ?? new ApiProperty();
44+
return $propertyMetadata ?? $this->throwNotFound($resourceClass, $property);
4045
}
4146

42-
try {
43-
$method = $refl->getMethod($property);
44-
45-
if ($attributes = $method->getAttributes(ApiProperty::class)) {
46-
return $this->createMetadata($attributes[0]->newInstance(), $propertyMetadata);
47-
}
48-
} catch (\ReflectionException) {
49-
}
50-
51-
$attributes = $refl->getAttributes(ApiProperty::class);
52-
foreach ($attributes as $attribute) {
53-
$instance = $attribute->newInstance();
54-
if ($instance->getProperty() === $property) {
55-
return $this->createMetadata($instance, $propertyMetadata);
56-
}
47+
if ($refl->hasMethod($property) && $attributes = $refl->getMethod($property)->getAttributes(ApiProperty::class)) {
48+
return $this->createMetadata($attributes[0]->newInstance(), $propertyMetadata);
5749
}
5850

5951
return $propertyMetadata;
6052
}
6153

54+
/**
55+
* @throws PropertyNotFoundException
56+
*/
57+
private function throwNotFound(string $resourceClass, string $property): never
58+
{
59+
throw new PropertyNotFoundException(\sprintf('Property "%s" of class "%s" not found.', $property, $resourceClass));
60+
}
61+
6262
private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMetadata = null): ApiProperty
6363
{
6464
if (null === $propertyMetadata) {
6565
return $this->handleUserDefinedSchema($attribute);
6666
}
6767

6868
foreach (get_class_methods(ApiProperty::class) as $method) {
69-
if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches) && null !== $val = $attribute->{$method}()) {
69+
if (preg_match('/^(?:get|is)(.*)/', $method, $matches) && null !== $val = $attribute->{$method}()) {
7070
$propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val);
7171
}
7272
}

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

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ final class EloquentPropertyNameCollectionMetadataFactory implements PropertyNam
2323
{
2424
public function __construct(
2525
private readonly ModelMetadata $modelMetadata,
26-
private readonly PropertyNameCollectionFactoryInterface $decorated,
26+
private readonly ?PropertyNameCollectionFactoryInterface $decorated,
2727
private readonly ResourceClassResolverInterface $resourceClassResolver,
2828
) {
2929
}
@@ -35,39 +35,41 @@ public function __construct(
3535
*/
3636
public function create(string $resourceClass, array $options = []): PropertyNameCollection
3737
{
38-
if (!class_exists($resourceClass)) {
39-
return $this->decorated->create($resourceClass, $options);
38+
if (!class_exists($resourceClass) || !is_a($resourceClass, Model::class, true)) {
39+
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
4040
}
4141

42+
$refl = new \ReflectionClass($resourceClass);
4243
try {
43-
$refl = new \ReflectionClass($resourceClass);
4444
$model = $refl->newInstanceWithoutConstructor();
4545
} catch (\ReflectionException) {
46-
return $this->decorated->create($resourceClass, $options);
47-
}
48-
49-
if (!$model instanceof Model) {
50-
return $this->decorated->create($resourceClass, $options);
46+
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
5147
}
5248

49+
/**
50+
* @var array<string, true> $properties
51+
*/
5352
$properties = [];
53+
5454
// When it's an Eloquent model we read attributes from database (@see ShowModelCommand)
5555
foreach ($this->modelMetadata->getAttributes($model) as $property) {
5656
if (!$property['primary'] && $property['hidden']) {
5757
continue;
5858
}
5959

60-
$properties[] = $property['name'];
60+
$properties[$property['name']] = true;
6161
}
6262

6363
foreach ($this->modelMetadata->getRelations($model) as $relation) {
6464
if (!$this->resourceClassResolver->isResourceClass($relation['related'])) {
6565
continue;
6666
}
6767

68-
$properties[] = $relation['name'];
68+
$properties[$relation['name']] = true;
6969
}
7070

71-
return new PropertyNameCollection($properties);
71+
return new PropertyNameCollection(
72+
array_keys($properties) // @phpstan-ignore-line
73+
);
7274
}
7375
}

src/Laravel/IsApiResource.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
/**
19+
* @author Kévin Dunglas <[email protected]>
20+
*/
21+
trait IsApiResource
22+
{
23+
public static function apiResource(): ApiResource
24+
{
25+
return new ApiResource();
26+
}
27+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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\Metadata;
15+
16+
use ApiPlatform\Laravel\IsApiResource;
17+
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Property\PropertyNameCollection;
20+
21+
/**
22+
* Handles property defined with the {@see IsApiResource} concern.
23+
*
24+
* @author Kévin Dunglas <[email protected]>
25+
*/
26+
final class ConcernsPropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface
27+
{
28+
public function __construct(
29+
private readonly ?PropertyNameCollectionFactoryInterface $decorated = null,
30+
) {
31+
}
32+
33+
/**
34+
* {@inheritdoc}
35+
*
36+
* @param class-string $resourceClass
37+
*/
38+
public function create(string $resourceClass, array $options = []): PropertyNameCollection
39+
{
40+
$propertyNameCollection = $this->decorated?->create($resourceClass, $options);
41+
if (!method_exists($resourceClass, 'apiResource')) {
42+
return $propertyNameCollection ?? new PropertyNameCollection();
43+
}
44+
45+
$refl = new \ReflectionClass($resourceClass);
46+
$method = $refl->getMethod('apiResource');
47+
if (!$method->isPublic() || !$method->isStatic()) {
48+
return $propertyNameCollection ?? new PropertyNameCollection();
49+
}
50+
51+
$metadataCollection = $method->invoke(null);
52+
if (!\is_array($metadataCollection)) {
53+
$metadataCollection = [$metadataCollection];
54+
}
55+
56+
$properties = $propertyNameCollection ? array_flip(iterator_to_array($propertyNameCollection)) : [];
57+
58+
foreach ($metadataCollection as $apiProperty) {
59+
if (!$apiProperty instanceof ApiProperty) {
60+
continue;
61+
}
62+
63+
if (null !== $propertyName = $apiProperty->getProperty()) {
64+
$properties[$propertyName] = true;
65+
}
66+
}
67+
68+
return new PropertyNameCollection(array_keys($properties));
69+
}
70+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\Metadata;
15+
16+
use ApiPlatform\Laravel\IsApiResource;
17+
use ApiPlatform\Metadata\Resource\Factory\MetadataCollectionFactoryTrait;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
20+
21+
/**
22+
* Creates a resource metadata from {@see IsApiResource} concerns.
23+
*
24+
* @author Kévin Dunglas <[email protected]>
25+
*/
26+
final class ConcernsResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
27+
{
28+
use MetadataCollectionFactoryTrait;
29+
30+
/**
31+
* {@inheritdoc}
32+
*/
33+
public function create(string $resourceClass): ResourceMetadataCollection
34+
{
35+
$resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection(
36+
$resourceClass
37+
);
38+
39+
if (!method_exists($resourceClass, 'apiResource')) {
40+
return $resourceMetadataCollection;
41+
}
42+
43+
$metadataCollection = $resourceClass::apiResource();
44+
if (!\is_array($metadataCollection)) {
45+
$metadataCollection = [$metadataCollection];
46+
}
47+
48+
foreach ($this->buildResourceOperations($metadataCollection, $resourceClass) as $resource) {
49+
$resourceMetadataCollection[] = $resource;
50+
}
51+
52+
return $resourceMetadataCollection;
53+
}
54+
}

0 commit comments

Comments
 (0)