From e370d20e35c99fd72028fafa769308d7598854a1 Mon Sep 17 00:00:00 2001 From: valentindrdt Date: Thu, 19 Sep 2024 07:48:28 -0700 Subject: [PATCH] feat: api-platform/json-hal component (#6621) * feat: add hal support for laravel * feat: quick review * fix: typo & cs-fixer * fix: typo in composer.json * fix: cs-fixer & phpstan * fix: forgot about hal item normalizer, therefore there's no more createbook nor updatebook test as Hal is a readonly format --- src/Hal/Serializer/ObjectNormalizer.php | 1 - src/Hal/composer.json | 62 +++++++++++++ src/Laravel/ApiPlatformProvider.php | 50 ++++++++++- src/Laravel/Tests/HalTest.php | 112 ++++++++++++++++++++++++ src/Laravel/composer.json | 1 + 5 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/Hal/composer.json create mode 100644 src/Laravel/Tests/HalTest.php diff --git a/src/Hal/Serializer/ObjectNormalizer.php b/src/Hal/Serializer/ObjectNormalizer.php index ea3d7a895b..f52903b877 100644 --- a/src/Hal/Serializer/ObjectNormalizer.php +++ b/src/Hal/Serializer/ObjectNormalizer.php @@ -17,7 +17,6 @@ use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; /** * Decorates the output with JSON HAL metadata when appropriate, but otherwise diff --git a/src/Hal/composer.json b/src/Hal/composer.json new file mode 100644 index 0000000000..df27693cd2 --- /dev/null +++ b/src/Hal/composer.json @@ -0,0 +1,62 @@ +{ + "name": "api-platform/json-hal", + "description": "API Hal support", + "type": "library", + "keywords": [ + "REST", + "API", + "HAL" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/state": "^3.4 || ^4.0", + "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/serializer": "^3.4 || ^4.0" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Hal\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" + }, + "symfony": { + "require": "^6.4 || ^7.1" + } + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, + "require-dev": { + "phpunit/phpunit": "^11.2" + } +} diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9a53504e32..d11885ab54 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -43,6 +43,10 @@ use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\GraphQl\Type\TypesFactory; use ApiPlatform\GraphQl\Type\TypesFactoryInterface; +use ApiPlatform\Hal\Serializer\CollectionNormalizer as HalCollectionNormalizer; +use ApiPlatform\Hal\Serializer\EntrypointNormalizer as HalEntrypointNormalizer; +use ApiPlatform\Hal\Serializer\ItemNormalizer as HalItemNormalizer; +use ApiPlatform\Hal\Serializer\ObjectNormalizer as HalObjectNormalizer; use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer as HydraCollectionFiltersNormalizer; use ApiPlatform\Hydra\Serializer\CollectionNormalizer as HydraCollectionNormalizer; @@ -660,6 +664,43 @@ public function register(): void ); }); + $this->app->singleton(HalCollectionNormalizer::class, function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new HalCollectionNormalizer( + $app->make(ResourceClassResolverInterface::class), + $config->get('api-platform.pagination.page_parameter_name'), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + ); + }); + + $this->app->singleton(HalObjectNormalizer::class, function (Application $app) { + return new HalObjectNormalizer( + $app->make(ObjectNormalizer::class), + $app->make(IriConverterInterface::class) + ); + }); + + $this->app->singleton(HalItemNormalizer::class, function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new HalItemNormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + ); + }); + $this->app->singleton(Options::class, function (Application $app) { /** @var ConfigRepository */ $config = $app['config']; @@ -922,6 +963,10 @@ public function register(): void $list = new \SplPriorityQueue(); $list->insert($app->make(HydraEntrypointNormalizer::class), -800); $list->insert($app->make(HydraPartialCollectionViewNormalizer::class), -800); + $list->insert($app->make(HalCollectionNormalizer::class), -800); + $list->insert($app->make(HalEntrypointNormalizer::class), -985); + $list->insert($app->make(HalObjectNormalizer::class), -995); + $list->insert($app->make(HalItemNormalizer::class), -890); $list->insert($app->make(JsonLdItemNormalizer::class), -890); $list->insert($app->make(JsonLdObjectNormalizer::class), -995); $list->insert($app->make(ArrayDenormalizer::class), -990); @@ -950,10 +995,6 @@ public function register(): void // TODO: unused + implement hal/jsonapi ? // $list->insert($dataUriNormalizer, -920); // $list->insert($unwrappingDenormalizer, 1000); - // $list->insert($halItemNormalizer, -890); - // $list->insert($halEntrypointNormalizer, -800); - // $list->insert($halCollectionNormalizer, -985); - // $list->insert($halObjectNormalizer, -995); // $list->insert($jsonserializableNormalizer, -900); // $list->insert($uuidDenormalizer, -895); //Todo ramsey uuid support ? @@ -964,6 +1005,7 @@ public function register(): void $app->make(JsonEncoder::class), new JsonEncoder('jsonopenapi'), new JsonEncoder('jsonapi'), + new JsonEncoder('jsonhal'), new CsvEncoder(), ]); }); diff --git a/src/Laravel/Tests/HalTest.php b/src/Laravel/Tests/HalTest.php new file mode 100644 index 0000000000..4fc11f4046 --- /dev/null +++ b/src/Laravel/Tests/HalTest.php @@ -0,0 +1,112 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Book; + +class HalTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], function (Repository $config): void { + $config->set('api-platform.formats', ['jsonhal' => ['application/hal+json']]); + $config->set('api-platform.docs_formats', ['jsonhal' => ['application/hal+json']]); + }); + } + + public function testGetEntrypoint(): void + { + $response = $this->get('/api/', ['accept' => ['application/hal+json']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/hal+json; charset=utf-8'); + + $this->assertJsonContains( + [ + '_links' => [ + 'self' => ['href' => '/api'], + 'book' => ['href' => '/api/books'], + 'post' => ['href' => '/api/posts'], + 'sluggable' => ['href' => '/api/sluggables'], + 'vault' => ['href' => '/api/vaults'], + 'author' => ['href' => '/api/authors'], + ], + ], + $response->json() + ); + } + + public function testGetCollection(): void + { + $response = $this->get('/api/books', ['accept' => 'application/hal+json']); + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/hal+json; charset=utf-8'); + $this->assertJsonContains( + [ + '_links' => [ + 'first' => ['href' => '/api/books?page=1'], + 'self' => ['href' => '/api/books?page=1'], + 'last' => ['href' => '/api/books?page=2'], + ], + 'totalItems' => 10, + ], + $response->json() + ); + } + + public function testGetBook(): void + { + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->get($iri, ['accept' => ['application/hal+json']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/hal+json; charset=utf-8'); + $this->assertJsonContains( + [ + 'name' => $book->name, // @phpstan-ignore-line + 'isbn' => $book->isbn, // @phpstan-ignore-line + '_links' => [ + 'self' => [ + 'href' => $iri, + ], + 'author' => [ + 'href' => '/api/authors/1', + ], + ], + ], + $response->json() + ); + } + + public function testDeleteBook(): void + { + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->delete($iri, headers: ['accept' => 'application/hal+json']); + $response->assertStatus(204); + $this->assertNull(Book::find($book->id)); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 99f9cd6581..ce2378f9c0 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -30,6 +30,7 @@ "php": ">=8.1", "api-platform/documentation": "^4.0", "api-platform/hydra": "^4.0", + "api-platform/json-hal": "^4.0", "api-platform/json-schema": "^4.0", "api-platform/jsonld": "^4.0", "api-platform/json-api": "^4.0",