diff --git a/.commitlintrc b/.commitlintrc index 1755beee8b8..d8422c6672b 100644 --- a/.commitlintrc +++ b/.commitlintrc @@ -11,7 +11,6 @@ "graphql", "hal", "httpcache", - "httpcache", "hydra", "jsonapi", "jsonld", @@ -26,6 +25,7 @@ "state", "symfony", "test", + "toon", "validator", ] ], diff --git a/composer.json b/composer.json index b8bbc2c4c37..a2da7e370d8 100644 --- a/composer.json +++ b/composer.json @@ -104,6 +104,7 @@ "api-platform/serializer": "self.version", "api-platform/state": "self.version", "api-platform/symfony": "self.version", + "api-platform/toon": "self.version", "api-platform/validator": "self.version" }, "require": { @@ -137,6 +138,7 @@ "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", + "helgesverre/toon": "^3.1", "illuminate/config": "^11.0 || ^12.0", "illuminate/contracts": "^11.0 || ^12.0", "illuminate/database": "^11.0 || ^12.0", @@ -201,6 +203,7 @@ "suggest": { "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", + "helgesverre/toon": "To support Toon format serialization.", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8404741c22f..bf8833d098b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -163,6 +163,13 @@ use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; +use ApiPlatform\Toon\Serializer\ToonEncoder; +use ApiPlatform\Toon\Serializer\ToonHydraCollectionNormalizer; +use ApiPlatform\Toon\Serializer\ToonHydraEntrypointNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiCollectionNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiEntrypointNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiItemNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonLdItemNormalizer; use Illuminate\Config\Repository as ConfigRepository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; @@ -963,6 +970,16 @@ public function register(): void $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); + $list->insert(new ToonHydraCollectionNormalizer($app->make(HydraCollectionNormalizer::class)), -880); + $list->insert(new ToonHydraEntrypointNormalizer( + $app->make(HydraEntrypointNormalizer::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) + ), -880); + $list->insert(new ToonJsonApiCollectionNormalizer($app->make(JsonApiCollectionNormalizer::class)), -880); + $list->insert(new ToonJsonApiEntrypointNormalizer($app->make(JsonApiEntrypointNormalizer::class)), -880); + $list->insert(new ToonJsonApiItemNormalizer($app->make(JsonApiItemNormalizer::class)), -880); + $list->insert(new ToonJsonLdItemNormalizer($app->make(JsonLdItemNormalizer::class)), -880); + if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); @@ -987,6 +1004,8 @@ public function register(): void return new Serializer( iterator_to_array($app->make('api_platform_normalizer_list')), [ + // ToonEncoder must come first to handle Toon-encoded formats before JSON encoder + new ToonEncoder(), new JsonEncoder('json'), $app->make(JsonEncoder::class), new JsonEncoder('jsonopenapi'), diff --git a/src/Laravel/Tests/ToonTest.php b/src/Laravel/Tests/ToonTest.php new file mode 100644 index 00000000000..f275feee6f0 --- /dev/null +++ b/src/Laravel/Tests/ToonTest.php @@ -0,0 +1,202 @@ + + * + * 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 HelgeSverre\Toon\Toon; +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\Author; +use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; + +class ToonTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + // Add Toon format as separate format (uses JSON-LD normalizers with Toon encoder) + $formats = $config->get('api-platform.formats', []); + $formats['toon'] = ['text/ld+toon']; + $formats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $formats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $formats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.formats', $formats); + + $patchFormats = $config->get('api-platform.patch_formats', []); + $patchFormats['toon'] = ['text/ld+toon']; + $patchFormats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $patchFormats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $patchFormats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.patch_formats', $patchFormats); + + $docsFormats = $config->get('api-platform.docs_formats', []); + $docsFormats['toon'] = ['text/ld+toon']; + $docsFormats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $docsFormats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $docsFormats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.docs_formats', $docsFormats); + + $config->set('app.debug', true); + }); + } + + public function testGetEntrypoint(): void + { + $response = $this->get('/api/', ['accept' => ['text/ld+toon']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + // Decode the Toon content to check structure + $decoded = \HelgeSverre\Toon\Toon::decode($content); + + $this->assertIsArray($decoded); + // Laravel entrypoint might have different structure, just check it's an array with resources + $this->assertNotEmpty($decoded); + // The response should contain book-related information + $contentLower = strtolower($content); + $this->assertTrue( + str_contains($contentLower, 'book') || str_contains($contentLower, 'api'), + 'Entrypoint should contain resource information' + ); + } + + public function testGetCollection(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['accept' => 'text/ld+toon']); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + // Decode to verify structure + $decoded = \HelgeSverre\Toon\Toon::decode($content); + + // Check for collection structure (Laravel doesn't use hydra prefix) + $this->assertIsArray($decoded); + + // Check if it's a proper collection or a plain array + if (isset($decoded['@id'])) { + // Full collection structure + $this->assertArrayHasKey('totalItems', $decoded); + $this->assertEquals(10, $decoded['totalItems']); + $this->assertArrayHasKey('member', $decoded); + $this->assertCount(5, $decoded['member']); // Default page size + } else { + // Plain array of items + $this->assertIsArray($decoded); + $this->assertNotEmpty($decoded); + // Just verify we got items back + $this->assertGreaterThan(0, count($decoded)); + } + } + + public function testGetBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->get($iri, ['accept' => ['text/ld+toon']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('id:', $content); + $this->assertStringContainsString('name: '.$book->name, $content); // @phpstan-ignore-line + // ISBN may be quoted in output + $this->assertStringContainsString($book->isbn, $content); // @phpstan-ignore-line + } + + public function testCreateBook(): void + { + AuthorFactory::new()->count(10)->create(); + $author = Author::find(1); + + $isbn = fake()->isbn13(); + + $response = $this->postJson( + '/api/books', + [ + 'name' => 'The Pragmatic Programmer', + 'isbn' => $isbn, + 'publicationDate' => fake()->optional()->date(), + 'author' => $this->getIriFromResource($author), + ], + [ + 'accept' => 'text/ld+toon', + 'content-type' => 'application/ld+json', + ] + ); + + $response->assertStatus(201); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('name: The Pragmatic Programmer', $content); + $this->assertStringContainsString('id:', $content); + } + + public function testUpdateBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + + $response = $this->patchJson( + $iri, + [ + 'name' => 'Updated Title', + ], + [ + 'accept' => 'text/ld+toon', + 'content-type' => 'application/merge-patch+json', + ] + ); + + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('name: Updated Title', $content); + // ISBN may be quoted in output + $this->assertStringContainsString($book->isbn, $content); // @phpstan-ignore-line unchanged + } + + public function testDeleteBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->delete($iri, headers: ['accept' => 'text/ld+toon']); + $response->assertStatus(204); + $this->assertNull(Book::find($book->id)); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 92d1c1d880c..e82d5634268 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -38,6 +38,7 @@ "api-platform/openapi": "^4.2", "api-platform/serializer": "^4.2.4", "api-platform/state": "^4.2.4", + "api-platform/toon": "^4.2", "illuminate/config": "^11.0 || ^12.0", "illuminate/container": "^11.0 || ^12.0", "illuminate/contracts": "^11.0 || ^12.0", diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index e664c3beb3a..c946e046e5d 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -179,6 +179,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerJsonApiConfiguration($formats, $loader, $config); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config); $this->registerJsonHalConfiguration($formats, $loader); + $this->registerToonConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); $this->registerGraphQlConfiguration($container, $config, $loader); $this->registerCacheConfiguration($container); @@ -723,6 +724,19 @@ private function registerJsonHalConfiguration(array $formats, PhpFileLoader $loa $loader->load('hal.php'); } + private function registerToonConfiguration(array $formats, PhpFileLoader $loader): void + { + if (!isset($formats['toon'])) { + return; + } + + if (!InstalledVersions::isInstalled('api-platform/toon')) { + throw new \LogicException('Toon support cannot be enabled as the Toon component is not installed. Try running "composer require api-platform/toon".'); + } + + $loader->load('toon.php'); + } + private function registerJsonProblemConfiguration(array $errorFormats, PhpFileLoader $loader): void { if (!isset($errorFormats['jsonproblem'])) { diff --git a/src/Symfony/Bundle/Resources/config/toon.php b/src/Symfony/Bundle/Resources/config/toon.php new file mode 100644 index 00000000000..1f5218f84d6 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/toon.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + // Toon encoder - can be used with any representation format (JSON-LD, JSON:API, HAL, Hydra) + // Priority: 10 to ensure it's checked before JsonEncoder for jsonld format + $services->set('api_platform.toon.encoder', 'ApiPlatform\Toon\Serializer\ToonEncoder') + ->tag('serializer.encoder', ['priority' => 10]); + + // Toon Normalizers + + $services->set('api_platform.toon.normalizer.hydra.collection', 'ApiPlatform\Toon\Serializer\ToonHydraCollectionNormalizer') + ->decorate('api_platform.hydra.normalizer.collection', null, 0) + ->args([ + service('.inner'), + ]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.hydra.entrypoint', 'ApiPlatform\Toon\Serializer\ToonHydraEntrypointNormalizer') + ->decorate('api_platform.hydra.normalizer.entrypoint') + ->args([ + service('.inner'), + service('api_platform.metadata.resource.metadata_collection_factory') + ]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.jsonapi.collection', 'ApiPlatform\Toon\Serializer\ToonJsonApiCollectionNormalizer') + ->decorate('api_platform.jsonapi.normalizer.collection', null, 0) + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.jsonapi.entrypoint', 'ApiPlatform\Toon\Serializer\ToonJsonApiEntrypointNormalizer') + ->decorate('api_platform.jsonapi.normalizer.entrypoint') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + $services->set('api_platform.toon.normalizer.jsonapi.item', 'ApiPlatform\Toon\Serializer\ToonJsonApiItemNormalizer') + ->decorate('api_platform.jsonapi.normalizer.item') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + $services->set('api_platform.toon.normalizer.jsonld.item', 'ApiPlatform\Toon\Serializer\ToonJsonLdItemNormalizer') + ->decorate('api_platform.jsonld.normalizer.item') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); +}; diff --git a/src/Toon/README.md b/src/Toon/README.md new file mode 100644 index 00000000000..7b6ca9d20b0 --- /dev/null +++ b/src/Toon/README.md @@ -0,0 +1,56 @@ +# API Platform Toon Format Support + +This component provides [Toon format](https://github.com/toon-format/spec) support for API Platform. + +## Features + +- Toon format encoder/decoder using [helgesverre/toon](https://packagist.org/packages/helgesverre/toon) +- Item and collection normalization +- Entrypoint support +- Compatible with both Symfony and Laravel + +## Installation + +```bash +composer require api-platform/toon +``` + +## Configuration + +### Symfony + +Add the format to your API Platform configuration: + +```yaml +# config/packages/api_platform.yaml +api_platform: + formats: + toon: ['application/x-toon'] +``` + +### Laravel + +Add the format to your configuration: + +```php +// config/api-platform.php +return [ + 'formats' => [ + 'toon' => ['application/x-toon'], + ], +]; +``` + +## About Toon Format + +TOON (Token-Oriented Object Notation) is a line-oriented, indentation-based text format that encodes the JSON data model with minimal quoting. It's designed for compact representation of structured data, especially uniform object arrays. + +Example: + +```toon +user: Alice +score: 95 +tags[3]: api,platform,toon +``` + +See the [official specification](https://github.com/toon-format/spec/blob/main/SPEC.md) for more details. diff --git a/src/Toon/Serializer/ToonEncoder.php b/src/Toon/Serializer/ToonEncoder.php new file mode 100644 index 00000000000..3a4acc74aa4 --- /dev/null +++ b/src/Toon/Serializer/ToonEncoder.php @@ -0,0 +1,72 @@ + + * + * 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\Toon\Serializer; + +use HelgeSverre\Toon\Toon; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; + +/** + * Encodes and decodes data in Toon format. + * + * Toon is an encoding format (like JSON/XML) that can be used with any representation format + * (JSON-LD, JSON:API, HAL, Hydra). This encoder works with normalized data from those formats. + * + * @author API Platform Community + */ +final class ToonEncoder implements EncoderInterface, DecoderInterface +{ + public const FORMAT = 'toon'; + + // Supported format combinations: representation+encoding + private const SUPPORTED_FORMATS = [ + 'toon', // JSON-LD structure with Toon encoding (text/ld+toon) + 'jsonhal_toon', // HAL + Toon (text/hal+toon) + 'jsonapi_toon', // JSON:API + Toon (text/vnd.api+toon) + 'hydra_toon', // Hydra + Toon + 'jsonopenapi_toon', // OpenAPI + Toon (text/vnd.openapi+toon) + ]; + + /** + * {@inheritdoc} + */ + public function encode(mixed $data, string $format, array $context = []): string + { + return Toon::encode($data); + } + + /** + * {@inheritdoc} + */ + public function supportsEncoding(string $format, array $context = []): bool + { + return \in_array($format, self::SUPPORTED_FORMATS, true); + } + + /** + * {@inheritdoc} + */ + public function decode(string $data, string $format, array $context = []): mixed + { + return Toon::decode($data); + } + + /** + * {@inheritdoc} + */ + public function supportsDecoding(string $format, array $context = []): bool + { + return \in_array($format, self::SUPPORTED_FORMATS, true); + } +} diff --git a/src/Toon/Serializer/ToonHydraCollectionNormalizer.php b/src/Toon/Serializer/ToonHydraCollectionNormalizer.php new file mode 100644 index 00000000000..a686e93c5b8 --- /dev/null +++ b/src/Toon/Serializer/ToonHydraCollectionNormalizer.php @@ -0,0 +1,56 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\Hydra\Serializer\CollectionNormalizer as DecoratedCollectionNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes collections in the Toon Hydra format through composition. + */ +final class ToonHydraCollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'hydra'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} \ No newline at end of file diff --git a/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php b/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php new file mode 100644 index 00000000000..a9f4d63643b --- /dev/null +++ b/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php @@ -0,0 +1,58 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an Hydra Entrypoint in the Toon Hydra format through composition. + */ +final class ToonHydraEntrypointNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'hydra'; + + public function __construct( + private NormalizerInterface $decorated, + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory + ) { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php b/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php new file mode 100644 index 00000000000..d31a532e0f1 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php @@ -0,0 +1,57 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as DecoratedCollectionNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes collections in the Toon JSON:API format through composition. + */ +final class ToonJsonApiCollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php b/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php new file mode 100644 index 00000000000..10abf87a927 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php @@ -0,0 +1,55 @@ + + * + * 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\Toon\Serializer; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON:API Entrypoint in the Toon JSON:API format through composition. + */ +final class ToonJsonApiEntrypointNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiItemNormalizer.php b/src/Toon/Serializer/ToonJsonApiItemNormalizer.php new file mode 100644 index 00000000000..fefde4fe378 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiItemNormalizer.php @@ -0,0 +1,71 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemNormalizer as DecoratedItemNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON:API Item in the Toon JSON:API format through composition. + */ +final class ToonJsonApiItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface & DenormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsDenormalization($data, $type, self::FORMAT, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) { + $format = self::FORMAT; + } + + return $this->decorated->denormalize($data, $type, $format, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonLdItemNormalizer.php b/src/Toon/Serializer/ToonJsonLdItemNormalizer.php new file mode 100644 index 00000000000..8cd12019abb --- /dev/null +++ b/src/Toon/Serializer/ToonJsonLdItemNormalizer.php @@ -0,0 +1,71 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonLd\Serializer\ItemNormalizer as DecoratedItemNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON-LD Item in the Toon JSON-LD format through composition. + */ +final class ToonJsonLdItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonld'; + + public function __construct(private NormalizerInterface & DenormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsDenormalization($data, $type, self::FORMAT, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) { + $format = self::FORMAT; + } + + return $this->decorated->denormalize($data, $type, $format, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Tests/.gitkeep b/src/Toon/Tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Toon/composer.json b/src/Toon/composer.json new file mode 100644 index 00000000000..a127ca9e077 --- /dev/null +++ b/src/Toon/composer.json @@ -0,0 +1,72 @@ +{ + "name": "api-platform/toon", + "description": "API Toon format support", + "type": "library", + "keywords": [ + "REST", + "API", + "TOON" + ], + "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.2", + "api-platform/state": "@dev", + "api-platform/metadata": "@dev", + "api-platform/documentation": "@dev", + "api-platform/serializer": "@dev", + "helgesverre/toon": "^1.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Toon\\": "" + }, + "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.3.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" + } + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, + "require-dev": { + "phpunit/phpunit": "^12.2", + "api-platform/json-schema": "@dev" + } +} diff --git a/src/Toon/phpunit.xml.dist b/src/Toon/phpunit.xml.dist new file mode 100644 index 00000000000..bc5eb1dc7e5 --- /dev/null +++ b/src/Toon/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + ./Tests/ + + + + + + . + + + ./Tests + ./vendor + + + diff --git a/tests/Fixtures/TestBundle/Document/ToonBook.php b/tests/Fixtures/TestBundle/Document/ToonBook.php new file mode 100644 index 00000000000..38f7754db17 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ToonBook.php @@ -0,0 +1,56 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + new Post(), + new Patch(inputFormats: ['jsonld' => ['application/ld+json', 'application/merge-patch+json'], 'toon' => ['text/ld+toon']]), + new Delete(), + ], + formats: ['jsonld' => ['application/ld+json'], 'toon' => ['text/ld+toon']] +)] +#[ODM\Document] +class ToonBook +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $title; + + #[ODM\Field(type: 'string')] + public string $author; + + #[ODM\Field(type: 'int')] + public int $pages = 0; + + #[ODM\Field(type: 'bool')] + public bool $available = true; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ToonBook.php b/tests/Fixtures/TestBundle/Entity/ToonBook.php new file mode 100644 index 00000000000..6e8d5b68b14 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ToonBook.php @@ -0,0 +1,70 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + new Post(), + new Patch(inputFormats: [ + 'jsonld' => ['application/ld+json', 'application/merge-patch+json'], + 'toon' => ['text/ld+toon'], // for jsonld base + 'jsonld_toon' => ['text/ld+toon'], // explicit for jsonld base + 'jsonapi_toon' => ['text/vnd.api+toon'] + ]), + ], + formats: [ + 'jsonld' => ['application/ld+json'], + 'toon' => ['text/ld+toon'], // for jsonld base + 'jsonld_toon' => ['text/ld+toon'], // explicit for jsonld base + 'jsonapi' => ['application/vnd.api+json'], + 'jsonapi_toon' => ['text/vnd.api+toon'], + 'hydra' => ['application/ld+json'], + 'hydra_toon' => ['text/ld+toon'] + ] +)] +#[ORM\Entity] +class ToonBook +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $title; + + #[ORM\Column(type: 'string', length: 255)] + public string $author; + + #[ORM\Column(type: 'integer')] + public int $pages = 0; + + #[ORM\Column(type: 'boolean')] + public bool $available = true; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index e2935e6fc13..692dcab2d90 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -39,22 +39,40 @@ api_platform: enable_swagger: true enable_swagger_ui: true formats: - jsonld: ['application/ld+json'] - jsonhal: ['application/hal+json'] - jsonapi: ['application/vnd.api+json'] - xml: ['application/xml', 'text/xml'] - json: ['application/json'] - html: ['text/html'] graphql: ['application/graphql'] + html: ['text/html'] + hydra: ['application/ld+json'] # Explicitly adding hydra + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon + json: ['application/json'] + jsonapi: ['application/vnd.api+json'] + jsonapi_toon: ['text/vnd.api+toon'] + jsonhal: ['application/hal+json'] + jsonhal_toon: ['text/hal+toon'] + jsonld: ['application/ld+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon multipart: ['multipart/form-data'] + toon: ['text/ld+toon'] + xml: ['application/xml', 'text/xml'] + patch_formats: + jsonhal: ['application/hal+json'] + jsonhal_toon: ['text/hal+toon'] + jsonapi_toon: ['text/vnd.api+toon'] + jsonld: ['application/ld+json', 'application/merge-patch+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon + toon: ['text/ld+toon'] + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon docs_formats: html: ['text/html'] json: ['application/json'] + jsonapi: ['application/vnd.api+json'] + jsonhal: ['application/hal+json'] + jsonld: ['application/ld+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon jsonopenapi: ['application/vnd.openapi+json'] + jsonopenapi_toon: ['text/vnd.openapi+toon'] + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon + toon: ['text/ld+toon'] yamlopenapi: ['application/vnd.openapi+yaml'] - jsonld: ['application/ld+json'] - jsonhal: ['application/hal+json'] - jsonapi: ['application/vnd.api+json'] error_formats: jsonproblem: ['application/problem+json'] jsonld: ['application/ld+json'] diff --git a/tests/Functional/ToonTest.php b/tests/Functional/ToonTest.php new file mode 100644 index 00000000000..77ddc8c4ae9 --- /dev/null +++ b/tests/Functional/ToonTest.php @@ -0,0 +1,436 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ToonBook; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use HelgeSverre\Toon\Toon; + +class ToonTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ToonBook::class, + ]; + } + + public function testCreateResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Send JSON-LD, expect Toon response + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'The Pragmatic Programmer', + 'author' => 'Andy Hunt', + 'pages' => 352, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('The Pragmatic Programmer', $decodedData['title']); + $this->assertEquals('Andy Hunt', $decodedData['author']); + $this->assertEquals(352, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testGetResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // First, create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'pages' => 464, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Now retrieve it + $response = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertEquals('Clean Code', $decodedData['title']); + $this->assertEquals('Robert C. Martin', $decodedData['author']); + $this->assertEquals(464, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testGetCollectionWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create multiple resources - send JSON-LD, expect Toon response + $books = [ + ['title' => 'Design Patterns', 'author' => 'Gang of Four', 'pages' => 395, 'available' => true], + ['title' => 'Refactoring', 'author' => 'Martin Fowler', 'pages' => 448, 'available' => false], + ['title' => 'Domain-Driven Design', 'author' => 'Eric Evans', 'pages' => 560, 'available' => true], + ]; + + foreach ($books as $book) { + self::createClient()->request('POST', '/toon_books', [ + 'json' => $book, + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + } + + // Retrieve collection + $response = self::createClient()->request('GET', '/toon_books', [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + + // Collection structure varies (may have hydra:member or member, or be a plain array) + if (isset($decodedData['hydra:member'])) { + // Hydra format with prefix + $this->assertCount(3, $decodedData['hydra:member']); + $this->assertEquals('Design Patterns', $decodedData['hydra:member'][0]['title']); + $this->assertEquals('Gang of Four', $decodedData['hydra:member'][0]['author']); + $this->assertArrayHasKey('hydra:totalItems', $decodedData); + $this->assertEquals(3, $decodedData['hydra:totalItems']); + } elseif (isset($decodedData['member'])) { + // JSON-LD format without prefix + $this->assertCount(3, $decodedData['member']); + $this->assertEquals('Design Patterns', $decodedData['member'][0]['title']); + $this->assertEquals('Gang of Four', $decodedData['member'][0]['author']); + $this->assertArrayHasKey('totalItems', $decodedData); + $this->assertEquals(3, $decodedData['totalItems']); + } else { + // Plain array + $this->assertGreaterThanOrEqual(3, count($decodedData)); + } + } + + public function testUpdateResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'Original Title', + 'author' => 'Original Author', + 'pages' => 100, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Update the resource - send JSON-LD, expect Toon response + $response = self::createClient()->request('PATCH', $resourceIri, [ + 'json' => [ + 'title' => 'Updated Title', + 'pages' => 200, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertEquals('Updated Title', $decodedData['title']); + $this->assertEquals('Original Author', $decodedData['author']); // Unchanged + $this->assertEquals(200, $decodedData['pages']); + $this->assertTrue($decodedData['available']); // Unchanged + } + + public function testDeleteResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'To Be Deleted', + 'author' => 'Test Author', + 'pages' => 123, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Delete the resource + self::createClient()->request('DELETE', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(204); + + // Verify it's deleted + self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointWithToonFormat(): void + { + self::bootKernel(); + + $response = self::createClient()->request('GET', '/', [ + 'headers' => [ + 'Accept' => 'text/ld+toon', // This should be handled by hydra_toon + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + // Verify entrypoint contains resource information + $this->assertNotEmpty($decodedData); + // Check that ToonBook resource is listed in the entrypoint, and that hydra:member is present + $contentLower = strtolower($responseContent); + $this->assertTrue( + str_contains($contentLower, 'toonbook') || str_contains($contentLower, 'toon_book'), + 'Entrypoint should contain ToonBook resource' + ); + $this->assertStringContainsString('hydra:member', $contentLower); // Ensure Hydra specific key + } + + public function testToonFormatEncodesSimpleStructures(): void + { + // Test that the Toon encoder works correctly for simple data + $data = [ + 'name' => 'Alice', + 'score' => 95, + 'active' => true, + ]; + + $encoded = Toon::encode($data); + $this->assertStringContainsString('name: Alice', $encoded); + $this->assertStringContainsString('score: 95', $encoded); + $this->assertStringContainsString('active: true', $encoded); + + $decoded = Toon::decode($encoded); + $this->assertEquals($data, $decoded); + } + + public function testToonFormatEncodesArrays(): void + { + // Test that arrays are encoded properly in Toon format + $data = [ + 'tags' => ['api', 'platform', 'toon'], + 'count' => 3, + ]; + + $encoded = Toon::encode($data); + $decoded = Toon::decode($encoded); + + $this->assertEquals($data['tags'], $decoded['tags']); + $this->assertEquals($data['count'], $decoded['count']); + } + + public function testPostWithToonContentType(): void + { + $this->recreateSchema(self::getResources()); + + // Create Toon-encoded request body + $toonData = Toon::encode([ + 'title' => 'Posted via Toon', + 'author' => 'Toon Author', + 'pages' => 999, + 'available' => true, + ]); + + // POST with Content-Type: text/ld+toon + $response = self::createClient()->request('POST', '/toon_books', [ + 'body' => $toonData, + 'headers' => [ + 'Content-Type' => 'text/ld+toon', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('Posted via Toon', $decodedData['title']); + $this->assertEquals('Toon Author', $decodedData['author']); + $this->assertEquals(999, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testJsonApiWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a ToonBook to test JSON:API + Toon format + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'JSON:API Book', + 'author' => 'JSON:API Author', + 'pages' => 555, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $createdData = Toon::decode($response->getContent()); + $resourceIri = $createdData['@id']; + + // Now request the resource with JSON:API + Toon format (text/vnd.api+toon) + $jsonApiResponse = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/vnd.api+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/vnd.api+toon; charset=utf-8'); + + $responseContent = $jsonApiResponse->getContent(); + $decodedData = Toon::decode($responseContent); + + // Verify JSON:API structure + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('data', $decodedData); + $this->assertArrayHasKey('attributes', $decodedData['data']); + $this->assertEquals('JSON:API Book', $decodedData['data']['attributes']['title']); + $this->assertEquals('JSON:API Author', $decodedData['data']['attributes']['author']); + $this->assertEquals(555, $decodedData['data']['attributes']['pages']); + } + + public function testJsonLdToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a ToonBook to test JSON-LD + Toon format + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'JSON-LD Toon Book', + 'author' => 'JSON-LD Toon Author', + 'pages' => 666, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $createdData = Toon::decode($response->getContent()); + $resourceIri = $createdData['@id']; + + // Now request the resource with JSON-LD + Toon format (text/ld+toon, but explicitly using jsonld_toon in config) + $jsonLdToonResponse = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', // This will map to jsonld_toon via formats config + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $jsonLdToonResponse->getContent(); + $decodedData = Toon::decode($responseContent); + + // Verify JSON-LD structure + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('JSON-LD Toon Book', $decodedData['title']); + $this->assertEquals('JSON-LD Toon Author', $decodedData['author']); + $this->assertEquals(666, $decodedData['pages']); + } +}