Skip to content

Commit

Permalink
Merge branch '4.0' into fix/hydra-context
Browse files Browse the repository at this point in the history
  • Loading branch information
valentin-dassonville authored Oct 11, 2024
2 parents 6bd6b56 + 4bbfff3 commit a816103
Show file tree
Hide file tree
Showing 30 changed files with 572 additions and 38 deletions.
3 changes: 0 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,6 @@ Notes:
* [afe7d47d7](https://github.com/api-platform/core/commit/afe7d47d7b7ba6c8591bfb60137a65d1fa1fe38f) fix(metadata): passing class as parameter in XML ApiResource's definition (#6659)
* [b93ee467c](https://github.com/api-platform/core/commit/b93ee467c69253e0cfe60e75b48a5c7aa683474a) fix(metadata): overwriting XML ApiResource definition by YAML ApiResource definition (#6660)

> [!WARNING]
> Hydra prefix on errors is breaking, read `title` not `hydra:title`. The `hydra_prefix` flag doesn't apply to errors as it provided redundant information (both `hydra:title` and `title` were available)
## v3.4.1

### Bug fixes
Expand Down
4 changes: 4 additions & 0 deletions features/hydra/error.feature
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ Feature: Error handling
And the header "Link" should contain '<http://www.w3.org/ns/hydra/error>; rel="http://www.w3.org/ns/json-ld#error"'
And the JSON node "type" should exist
And the JSON node "title" should be equal to "An error occurred"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "detail" should exist
And the JSON node "description" should exist
And the JSON node "hydra:description" should exist
And the JSON node "trace" should exist
And the JSON node "status" should exist
And the JSON node "@context" should exist
Expand Down Expand Up @@ -48,6 +50,8 @@ Feature: Error handling
],
"detail": "name: This value should not be blank.",
"title": "An error occurred",
"hydra:title": "An error occurred",
"hydra:description": "name: This value should not be blank.",
"description": "name: This value should not be blank.",
"type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3"
}
Expand Down
1 change: 0 additions & 1 deletion src/JsonLd/ContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
namespace ApiPlatform\JsonLd;

use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait;
use ApiPlatform\Metadata\Error;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IriConverterInterface;
Expand Down
57 changes: 57 additions & 0 deletions src/JsonLd/Serializer/ErrorNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\JsonLd\Serializer;

use ApiPlatform\State\ApiResource\Error;
use ApiPlatform\Validator\Exception\ValidationException;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class ErrorNormalizer implements NormalizerInterface
{
use HydraPrefixTrait;

public function __construct(private readonly NormalizerInterface $inner, private readonly array $defaultContext = [])
{
}

public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$normalized = $this->inner->normalize($object, $format, $context);
$hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext);
if (!$hydraPrefix) {
return $normalized;
}

if (isset($normalized['description'])) {
$normalized['hydra:description'] = $normalized['description'];
}

if (isset($normalized['title'])) {
$normalized['hydra:title'] = $normalized['title'];
}

return $normalized;
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $this->inner->supportsNormalization($data, $format, $context)
&& (is_a($data, Error::class) || is_a($data, ValidationException::class));
}

public function getSupportedTypes(?string $format): array
{
return $this->inner->getSupportedTypes($format);
}
}
15 changes: 12 additions & 3 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
use ApiPlatform\Serializer\ItemNormalizer;
use ApiPlatform\Serializer\JsonEncoder;
use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory;
use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader;
use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider;
use ApiPlatform\Serializer\SerializerContextBuilder;
use ApiPlatform\State\CallableProcessor;
Expand Down Expand Up @@ -206,6 +207,7 @@
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\Mapping\Loader\LoaderChain;
use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
Expand Down Expand Up @@ -244,8 +246,15 @@ public function register(): void

$this->app->bind(LoaderInterface::class, AttributeLoader::class);
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
$this->app->singleton(ClassMetadataFactory::class, function () {
return new ClassMetadataFactory(new AttributeLoader());
$this->app->singleton(ClassMetadataFactory::class, function (Application $app) {
return new ClassMetadataFactory(
new LoaderChain([
new PropertyMetadataLoader(
$app->make(PropertyNameCollectionFactoryInterface::class),
),
new AttributeLoader(),
])
);
});

$this->app->singleton(SerializerClassMetadataFactory::class, function (Application $app) {
Expand Down Expand Up @@ -799,7 +808,7 @@ public function register(): void
$app->make(SchemaFactoryInterface::class),
null,
$config->get('api-platform.formats'),
null, // ?Options $openApiOptions = null,
$app->make(Options::class),
$app->make(PaginationOptions::class), // ?PaginationOptions $paginationOptions = null,
// ?RouterInterface $router = null
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public function create(string $resourceClass, string $property, array $options =
// see https://laravel.com/docs/11.x/eloquent-mutators#attribute-casting
$builtinType = $p['cast'] ?? $p['type'];
$type = match ($builtinType) {
'integer' => new Type(Type::BUILTIN_TYPE_INT, $p['nullable']),
'double', 'real' => new Type(Type::BUILTIN_TYPE_FLOAT, $p['nullable']),
'datetime', 'date', 'timestamp' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTime::class),
'immutable_datetime', 'immutable_date' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTimeImmutable::class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ public function create(string $resourceClass, array $options = []): PropertyName
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
}

$refl = new \ReflectionClass($resourceClass);
try {
$refl = new \ReflectionClass($resourceClass);
if ($refl->isAbstract()) {
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
}

$model = $refl->newInstanceWithoutConstructor();
} catch (\ReflectionException) {
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
Expand Down
12 changes: 12 additions & 0 deletions src/Laravel/Tests/JsonApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Workbench\App\Models\Book;
use Workbench\Database\Factories\AuthorFactory;
use Workbench\Database\Factories\BookFactory;
use Workbench\Database\Factories\WithAccessorFactory;

class JsonApiTest extends TestCase
{
Expand Down Expand Up @@ -197,4 +198,15 @@ public function testDeleteBook(): void
$response->assertStatus(204);
$this->assertNull(Book::find($book->id));
}

public function testRelationWithGroups(): void
{
WithAccessorFactory::new()->create();
$response = $this->get('/api/with_accessors/1', ['accept' => 'application/vnd.api+json']);
$content = $response->json();
$this->assertArrayHasKey('data', $content);
$this->assertArrayHasKey('relationships', $content['data']);
$this->assertArrayHasKey('relation', $content['data']['relationships']);
$this->assertArrayHasKey('data', $content['data']['relationships']['relation']);
}
}
10 changes: 10 additions & 0 deletions src/Laravel/Tests/JsonLdTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Workbench\Database\Factories\CommentFactory;
use Workbench\Database\Factories\PostFactory;
use Workbench\Database\Factories\SluggableFactory;
use Workbench\Database\Factories\WithAccessorFactory;

class JsonLdTest extends TestCase
{
Expand Down Expand Up @@ -327,4 +328,13 @@ public function testError(): void
$content = $response->json();
$this->assertArrayHasKey('trace', $content);
}

public function testRelationWithGroups(): void
{
WithAccessorFactory::new()->create();
$response = $this->get('/api/with_accessors/1', ['accept' => 'application/ld+json']);
$content = $response->json();
$this->assertArrayHasKey('relation', $content);
$this->assertArrayHasKey('name', $content['relation']);
}
}
12 changes: 11 additions & 1 deletion src/Laravel/workbench/app/Models/WithAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,28 @@

namespace Workbench\App\Models;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource]
#[ApiResource(normalizationContext: ['groups' => ['read']])]
class WithAccessor extends Model
{
use HasFactory;

protected $hidden = ['created_at', 'updated_at', 'id'];

#[ApiProperty(serialize: [new Groups(['read'])])]
public function relation(): BelongsTo
{
return $this->belongsTo(WithAccessorRelation::class);
}

#[ApiProperty(serialize: [new Groups(['read'])])]
protected function name(): Attribute
{
return Attribute::make(
Expand Down
26 changes: 26 additions & 0 deletions src/Laravel/workbench/app/Models/WithAccessorRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Workbench\App\Models;

use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Serializer\Attribute\Groups;

#[Groups(['read'])]
#[ApiResource(operations: [])]
class WithAccessorRelation extends Model
{
use HasFactory;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function definition(): array
{
return [
'name' => strtolower(fake()->name()),
'relation_id' => WithAccessorRelationFactory::new(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Workbench\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Workbench\App\Models\WithAccessorRelation;

/**
* @template TModel of \Workbench\App\Models\WithAccessorRelation
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
*/
class WithAccessorRelationFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<TModel>
*/
protected $model = WithAccessorRelation::class;

/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => strtolower(fake()->name()),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@
*/
public function up(): void
{
Schema::create('with_accessor_relations', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->timestamps();
});

Schema::create('with_accessors', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->integer('relation_id')->unsigned();
$table->foreign('relation_id')->references('id')->on('with_accessor_relations');
$table->timestamps();
});
}
Expand All @@ -34,5 +42,6 @@ public function up(): void
public function down(): void
{
Schema::dropIfExists('with_accessors');
Schema::dropIfExists('with_accessors_relation');
}
};
Loading

0 comments on commit a816103

Please sign in to comment.