diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae3c2e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitignore export-ignore +/Tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb0a8e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/Tests/Fixtures/CircularReference.php b/Tests/Fixtures/CircularReference.php new file mode 100644 index 0000000..d9808dd --- /dev/null +++ b/Tests/Fixtures/CircularReference.php @@ -0,0 +1,20 @@ + + * + * 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\JsonApi\Tests\Fixtures; + +class CircularReference +{ + public $id; + public CircularReference $parent; +} diff --git a/Tests/Fixtures/CustomConverter.php b/Tests/Fixtures/CustomConverter.php new file mode 100644 index 0000000..2dd8b97 --- /dev/null +++ b/Tests/Fixtures/CustomConverter.php @@ -0,0 +1,42 @@ + + * + * 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\JsonApi\Tests\Fixtures; + +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Custom converter that will only convert a property named "nameConverted" + * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. + */ +class CustomConverter implements AdvancedNameConverterInterface +{ + private NameConverterInterface $nameConverter; + + public function __construct() + { + $this->nameConverter = new CamelCaseToSnakeCaseNameConverter(); + } + + public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string + { + return 'nameConverted' === $propertyName ? $this->nameConverter->normalize($propertyName) : $propertyName; + } + + public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string + { + return 'name_converted' === $propertyName ? $this->nameConverter->denormalize($propertyName) : $propertyName; + } +} diff --git a/Tests/Fixtures/Dummy.php b/Tests/Fixtures/Dummy.php new file mode 100644 index 0000000..3f3b9e1 --- /dev/null +++ b/Tests/Fixtures/Dummy.php @@ -0,0 +1,206 @@ + + * + * 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\JsonApi\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] +class Dummy +{ + /** + * @var int|null The id + */ + private $id; + + /** + * @var string The dummy name + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + #[Assert\NotBlank] + private string $name; + + /** + * @var string|null The dummy name alias + */ + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + private $alias; + + /** + * @var array foo + */ + private ?array $foo = null; + + /** + * @var string|null A short description of the item + */ + #[ApiProperty(iris: ['https://schema.org/description'])] + public $description; + + /** + * @var string|null A dummy + */ + public $dummy; + + /** + * @var bool|null A dummy boolean + */ + public ?bool $dummyBoolean = null; + /** + * @var \DateTime|null A dummy date + */ + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + public $dummyDate; + + /** + * @var float|null A dummy float + */ + public $dummyFloat; + + /** + * @var string|null A dummy price + */ + public $dummyPrice; + + /** + * @var array|null serialize data + */ + public $jsonData = []; + + /** + * @var array|null + */ + public $arrayData = []; + + /** + * @var string|null + */ + public $nameConverted; + + public static function staticMethod(): void + { + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setAlias($alias): void + { + $this->alias = $alias; + } + + public function getAlias() + { + return $this->alias; + } + + public function setDescription($description): void + { + $this->description = $description; + } + + public function getDescription() + { + return $this->description; + } + + public function fooBar($baz): void + { + } + + public function getFoo(): ?array + { + return $this->foo; + } + + public function setFoo(?array $foo = null): void + { + $this->foo = $foo; + } + + public function setDummyDate(?\DateTime $dummyDate = null): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function setDummyPrice($dummyPrice) + { + $this->dummyPrice = $dummyPrice; + + return $this; + } + + public function getDummyPrice() + { + return $this->dummyPrice; + } + + public function setJsonData($jsonData): void + { + $this->jsonData = $jsonData; + } + + public function getJsonData() + { + return $this->jsonData; + } + + public function setArrayData($arrayData): void + { + $this->arrayData = $arrayData; + } + + public function getArrayData() + { + return $this->arrayData; + } + + public function setDummy($dummy = null): void + { + $this->dummy = $dummy; + } + + public function getDummy() + { + return $this->dummy; + } +} diff --git a/Tests/Fixtures/RelatedDummy.php b/Tests/Fixtures/RelatedDummy.php new file mode 100644 index 0000000..befb25d --- /dev/null +++ b/Tests/Fixtures/RelatedDummy.php @@ -0,0 +1,116 @@ + + * + * 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\JsonApi\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Related Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource] +class RelatedDummy implements \Stringable +{ + #[ApiProperty(writable: false)] + #[Groups(['chicago', 'friends'])] + private $id; + + /** + * @var string|null A name + */ + #[ApiProperty(iris: ['RelatedDummy.name'])] + #[Groups(['friends'])] + public $name; + + #[ApiProperty(deprecationReason: 'This property is deprecated for upgrade test')] + #[Groups(['barcelona', 'chicago', 'friends'])] + protected $symfony = 'symfony'; + + /** + * @var \DateTime|null A dummy date + */ + #[Groups(['friends'])] + public $dummyDate; + + /** + * @var bool|null A dummy bool + */ + #[Groups(['friends'])] + public ?bool $dummyBoolean = null; + + public function __construct() + { + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getSymfony() + { + return $this->symfony; + } + + public function setSymfony($symfony): void + { + $this->symfony = $symfony; + } + + public function setDummyDate(\DateTime $dummyDate): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php new file mode 100644 index 0000000..0c2548a --- /dev/null +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,182 @@ + + * + * 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\JsonApi\JsonSchema; + +use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonSchema\DefinitionNameFactory; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +class SchemaFactoryTest extends TestCase +{ + use ProphecyTrait; + + private SchemaFactory $schemaFactory; + + protected function setUp(): void + { + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withName('get'), + ])), + ])); + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true]); + + $baseSchemaFactory = new BaseSchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + + $this->schemaFactory = new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + + public function testBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertSame('Dummy.jsonapi', $resultSchema->getRootDefinitionKey()); + } + + public function testCustomFormatBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertSame('Dummy.jsonapi', $resultSchema->getRootDefinitionKey()); + } + + public function testHasRootDefinitionKeyBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties'])); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + $this->assertArrayHasKey('data', $properties); + $this->assertEquals( + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + 'type' => 'object', + 'properties' => [ + ], + ], + ], + 'required' => [ + 'type', + 'id', + ], + ], + $properties['data'] + ); + } + + public function testSchemaTypeBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new GetCollection()); + $definitionName = 'Dummy.jsonapi'; + + $this->assertNull($resultSchema->getRootDefinitionKey()); + $this->assertTrue(isset($resultSchema['properties'])); + $this->assertArrayHasKey('links', $resultSchema['properties']); + $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); + + $this->assertArrayHasKey('data', $resultSchema['properties']); + $this->assertArrayHasKey('items', $resultSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); + + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('data', $properties); + $this->assertArrayHasKey('properties', $properties['data']); + $this->assertArrayHasKey('id', $properties['data']['properties']); + $this->assertArrayHasKey('type', $properties['data']['properties']); + $this->assertArrayHasKey('attributes', $properties['data']['properties']); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + + $this->assertNull($resultSchema->getRootDefinitionKey()); + $this->assertTrue(isset($resultSchema['properties'])); + $this->assertArrayHasKey('links', $resultSchema['properties']); + $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); + + $this->assertArrayHasKey('data', $resultSchema['properties']); + $this->assertArrayHasKey('items', $resultSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); + + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('data', $properties); + $this->assertArrayHasKey('properties', $properties['data']); + $this->assertArrayHasKey('id', $properties['data']['properties']); + $this->assertArrayHasKey('type', $properties['data']['properties']); + $this->assertArrayHasKey('attributes', $properties['data']['properties']); + } +} diff --git a/Tests/Serializer/CollectionNormalizerTest.php b/Tests/Serializer/CollectionNormalizerTest.php new file mode 100644 index 0000000..f165af8 --- /dev/null +++ b/Tests/Serializer/CollectionNormalizerTest.php @@ -0,0 +1,387 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\JsonApi\Serializer\CollectionNormalizer; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; + +/** + * @author Amrouche Hamza + */ +class CollectionNormalizerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @group legacy + */ + public function testSupportsNormalize(): void + { + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo', 'api_sub_level' => true])); + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, [])); + $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml', ['resource_class' => 'Foo'])); + $this->assertEmpty($normalizer->getSupportedTypes('json')); + $this->assertSame([ + 'native-array' => true, + '\Traversable' => true, + ], $normalizer->getSupportedTypes($normalizer::FORMAT)); + + if (!method_exists(Serializer::class, 'getSupportedTypes')) { + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + } + } + + public function testNormalizePaginator(): void + { + $paginatorProphecy = $this->prophesize(PaginatorInterface::class); + $paginatorProphecy->getCurrentPage()->willReturn(3.); + $paginatorProphecy->getLastPage()->willReturn(7.); + $paginatorProphecy->getItemsPerPage()->willReturn(12.); + $paginatorProphecy->getTotalItems()->willReturn(1312.); + $paginatorProphecy->rewind()->will(function (): void {}); + $paginatorProphecy->next()->will(function (): void {}); + $paginatorProphecy->current()->willReturn('foo'); + $paginatorProphecy->valid()->willReturn(true, false); + + $paginator = $paginatorProphecy->reveal(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())])), + ])); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', + 'api_sub_level' => true, + 'resource_class' => 'Foo', + 'root_operation_name' => 'get', + ])->willReturn([ + 'data' => [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ]); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + 'links' => [ + 'self' => '/foos?page=3', + 'first' => '/foos?page=1', + 'last' => '/foos?page=7', + 'prev' => '/foos?page=2', + 'next' => '/foos?page=4', + ], + 'data' => [ + [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ], + 'meta' => [ + 'totalItems' => 1312, + 'itemsPerPage' => 12, + 'currentPage' => 3, + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos?page=3', + 'operation_name' => 'get', + 'uri' => 'http://example.com/foos?page=3', + 'resource_class' => 'Foo', + ])); + } + + public function testNormalizePartialPaginator(): void + { + $paginatorProphecy = $this->prophesize(PartialPaginatorInterface::class); + $paginatorProphecy->getCurrentPage()->willReturn(3.); + $paginatorProphecy->getItemsPerPage()->willReturn(12.); + $paginatorProphecy->rewind()->will(function (): void {}); + $paginatorProphecy->next()->will(function (): void {}); + $paginatorProphecy->current()->willReturn('foo'); + $paginatorProphecy->valid()->willReturn(true, false); + $paginatorProphecy->count()->willReturn(1312); + + $paginator = $paginatorProphecy->reveal(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())])), + ])); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', + 'api_sub_level' => true, + 'resource_class' => 'Foo', + 'root_operation_name' => 'get', + ])->willReturn([ + 'data' => [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ]); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + 'links' => [ + 'self' => '/foos?page=3', + 'prev' => '/foos?page=2', + 'next' => '/foos?page=4', + ], + 'data' => [ + [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ], + 'meta' => [ + 'itemsPerPage' => 12, + 'currentPage' => 3, + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos?page=3', + 'operation_name' => 'get', + 'uri' => 'http://example.com/foos?page=3', + 'resource_class' => 'Foo', + ])); + } + + public function testNormalizeArray(): void + { + $data = ['foo']; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())])), + ])); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', + 'api_sub_level' => true, + 'resource_class' => 'Foo', + 'root_operation_name' => 'get', + ])->willReturn([ + 'data' => [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Baptiste', + ], + ], + ]); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + 'links' => ['self' => '/foos'], + 'data' => [ + [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Baptiste', + ], + ], + ], + 'meta' => ['totalItems' => 1], + ]; + + $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'operation_name' => 'get', + 'uri' => 'http://example.com/foos', + 'resource_class' => 'Foo', + ])); + } + + public function testNormalizeIncludedData(): void + { + $data = ['foo']; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())])), + ])); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', + 'api_sub_level' => true, + 'resource_class' => 'Foo', + 'root_operation_name' => 'get', + ])->willReturn([ + 'data' => [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Baptiste', + ], + ], + 'included' => [ + [ + 'type' => 'Bar', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Anto', + ], + ], + ], + ]); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + 'links' => ['self' => '/foos'], + 'data' => [ + [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Baptiste', + ], + ], + ], + 'meta' => ['totalItems' => 1], + 'included' => [ + [ + 'type' => 'Bar', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Anto', + ], + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'operation_name' => 'get', + 'uri' => 'http://example.com/foos', + 'resource_class' => 'Foo', + ])); + } + + public function testNormalizeWithoutDataKey(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('The JSON API document must contain a "data" key.'); + + $data = ['foo']; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())])), + ])); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', + 'api_sub_level' => true, + 'resource_class' => 'Foo', + 'root_operation_name' => 'get', + ])->willReturn([]); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'request_uri' => '/foos', + 'operation_name' => 'get', + 'uri' => 'http://example.com/foos', + 'resource_class' => 'Foo', + ]); + } +} diff --git a/Tests/Serializer/ConstraintViolationNormalizerTest.php b/Tests/Serializer/ConstraintViolationNormalizerTest.php new file mode 100644 index 0000000..f587321 --- /dev/null +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -0,0 +1,94 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ConstraintViolationListNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * @author Baptiste Meyer + */ +class ConstraintViolationNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportNormalization(): void + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $nameConverterInterface = $this->prophesize(NameConverterInterface::class); + + $normalizer = new ConstraintViolationListNormalizer($propertyMetadataFactoryProphecy->reveal(), $nameConverterInterface->reveal()); + + $this->assertTrue($normalizer->supportsNormalization(new ConstraintViolationList(), ConstraintViolationListNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization(new ConstraintViolationList(), 'xml')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ConstraintViolationListNormalizer::FORMAT)); + $this->assertEmpty($normalizer->getSupportedTypes('json')); + $this->assertSame([ConstraintViolationListInterface::class => true], $normalizer->getSupportedTypes($normalizer::FORMAT)); + } + + public function testNormalize(): void + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)]))->shouldBeCalledTimes(1); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]))->shouldBeCalledTimes(1); + + $nameConverterProphecy = $this->prophesize(NameConverterInterface::class); + $nameConverterProphecy->normalize('relatedDummy', Dummy::class, 'jsonapi')->willReturn('relatedDummy')->shouldBeCalledTimes(1); + $nameConverterProphecy->normalize('name', Dummy::class, 'jsonapi')->willReturn('name')->shouldBeCalledTimes(1); + + $dummy = new Dummy(); + + $constraintViolationList = new ConstraintViolationList([ + new ConstraintViolation('This value should not be null.', 'This value should not be null.', [], $dummy, 'relatedDummy', null), + new ConstraintViolation('This value should not be null.', 'This value should not be null.', [], $dummy, 'name', null), + new ConstraintViolation('Unknown violation.', 'Unknown violation.', [], $dummy, '', ''), + ]); + + $this->assertEquals( + [ + 'errors' => [ + [ + 'detail' => 'This value should not be null.', + 'source' => [ + 'pointer' => 'data/relationships/relatedDummy', + ], + ], + [ + 'detail' => 'This value should not be null.', + 'source' => [ + 'pointer' => 'data/attributes/name', + ], + ], + [ + 'detail' => 'Unknown violation.', + 'source' => [ + 'pointer' => 'data', + ], + ], + ], + ], + (new ConstraintViolationListNormalizer($propertyMetadataFactoryProphecy->reveal(), $nameConverterProphecy->reveal()))->normalize($constraintViolationList) + ); + } +} diff --git a/Tests/Serializer/EntrypointNormalizerTest.php b/Tests/Serializer/EntrypointNormalizerTest.php new file mode 100644 index 0000000..2343253 --- /dev/null +++ b/Tests/Serializer/EntrypointNormalizerTest.php @@ -0,0 +1,100 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\Documentation\Entrypoint; +use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @author Amrouche Hamza + */ +class EntrypointNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportNormalization(): void + { + $collection = new ResourceNameCollection(); + $entrypoint = new Entrypoint($collection); + + $factoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + + $normalizer = new EntrypointNormalizer($factoryProphecy->reveal(), $iriConverterProphecy->reveal(), $urlGeneratorProphecy->reveal()); + + $this->assertTrue($normalizer->supportsNormalization($entrypoint, EntrypointNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization($entrypoint, 'json')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), EntrypointNormalizer::FORMAT)); + $this->assertEmpty($normalizer->getSupportedTypes('json')); + $this->assertSame([Entrypoint::class => true], $normalizer->getSupportedTypes($normalizer::FORMAT)); + } + + public function testNormalize(): void + { + $collection = new ResourceNameCollection([Dummy::class, RelatedDummy::class, DummyCar::class]); + $entrypoint = new Entrypoint($collection); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource())->withShortName('Dummy')->withOperations(new Operations([ + 'get' => (new GetCollection())->withShortName('Dummy'), + ])), + ]))->shouldBeCalled(); + $resourceMetadataCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection('RelatedDummy', [ + (new ApiResource())->withShortName('RelatedDummy')->withOperations(new Operations([ + 'get' => (new Get())->withShortName('RelatedDummy'), + ])), + ]))->shouldBeCalled(); + $resourceMetadataCollectionFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadataCollection('DummyCar', [ + (new ApiResource())->withShortName('DummyCar')->withOperations(new Operations([ + 'post' => (new Post())->withShortName('DummyCar'), + ])), + ]))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_URL, Argument::type(Operation::class))->willReturn('http://localhost/api/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(RelatedDummy::class, UrlGeneratorInterface::ABS_URL, Argument::type(Operation::class))->shouldNotBeCalled(); + $iriConverterProphecy->getIriFromResource(DummyCar::class, UrlGeneratorInterface::ABS_URL, Argument::type(Operation::class))->shouldNotBeCalled(); + + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + $urlGeneratorProphecy->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL)->willReturn('http://localhost/api')->shouldBeCalled(); + + $this->assertEquals( + [ + 'links' => [ + 'self' => 'http://localhost/api', + 'dummy' => 'http://localhost/api/dummies', + ], + ], + (new EntrypointNormalizer($resourceMetadataCollectionFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $urlGeneratorProphecy->reveal()))->normalize($entrypoint, EntrypointNormalizer::FORMAT) + ); + } +} diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php new file mode 100644 index 0000000..9065dfd --- /dev/null +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -0,0 +1,498 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Amrouche Hamza + */ +class ItemNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testNormalize(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name', '\bad_property'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/10'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withDescription('A dummy') + ->withUriTemplate('/dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'type' => 'Dummy', + 'id' => '/dummies/10', + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); + } + + public function testNormalizeCircularReference(): void + { + $circularReferenceEntity = new CircularReference(); + $circularReferenceEntity->id = 1; + $circularReferenceEntity->parent = $circularReferenceEntity; + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($circularReferenceEntity, Argument::cetera())->willReturn('/circular_references/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(CircularReference::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, null)->willReturn(CircularReference::class); + $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, CircularReference::class)->willReturn(CircularReference::class); + $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); + + $normalizer = new ItemNormalizer( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $this->prophesize(PropertyAccessorInterface::class)->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + + $normalizer->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); + + $circularReferenceLimit = 2; + if (!interface_exists(AdvancedNameConverterInterface::class) && method_exists($normalizer, 'setCircularReferenceLimit')) { + $normalizer->setCircularReferenceLimit($circularReferenceLimit); + + $context = [ + 'circular_reference_limit' => [spl_object_hash($circularReferenceEntity) => 2], + 'cache_error' => function (): void {}, + ]; + } else { + $context = [ + 'circular_reference_limit' => $circularReferenceLimit, + 'circular_reference_limit_counters' => [spl_object_hash($circularReferenceEntity) => 2], + 'cache_error' => function (): void {}, + ]; + } + + $this->assertSame('/circular_references/1', $normalizer->normalize($circularReferenceEntity, ItemNormalizer::FORMAT, $context)); + } + + public function testNormalizeNonExistentProperty(): void + { + $this->expectException(NoSuchPropertyException::class); + + $dummy = new Dummy(); + $dummy->setId(1); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['bar'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'bar', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'bar')->willThrow(new NoSuchPropertyException()); + + $serializerProphecy = $this->prophesize(Serializer::class); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + } + + public function testDenormalize(): void + { + $data = [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'name' => 'foo', + 'ghost' => 'invisible', + ], + 'relationships' => [ + 'relatedDummy' => [ + 'data' => [ + 'type' => 'related-dummy', + 'id' => '/related_dummies/1', + ], + ], + 'relatedDummies' => [ + 'data' => [ + [ + 'type' => 'related-dummy', + 'id' => '/related_dummies/2', + ], + ], + ], + ], + ], + ]; + + $relatedDummy1 = new RelatedDummy(); + $relatedDummy1->setId(1); + $relatedDummy2 = new RelatedDummy(); + $relatedDummy2->setId(2); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name', 'ghost', 'relatedDummy', 'relatedDummies'])); + + $relatedDummyType = new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); + $relatedDummiesType = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_INT), $relatedDummyType); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'ghost', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + + $getItemFromIriSecondArgCallback = fn ($arg): bool => \is_array($arg) && isset($arg['fetch_data']) && true === $arg['fetch_data']; + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('/related_dummies/1', Argument::that($getItemFromIriSecondArgCallback))->willReturn($relatedDummy1); + $iriConverterProphecy->getResourceFromIri('/related_dummies/2', Argument::that($getItemFromIriSecondArgCallback))->willReturn($relatedDummy2); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'ghost', 'invisible')->willThrow(new NoSuchPropertyException()); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummy', $relatedDummy1)->will(function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummies', [$relatedDummy2])->will(function (): void {}); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ] + )); + $resourceMetadataCollectionFactory->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ] + )); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT)); + } + + public function testDenormalizeUpdateOperationNotAllowed(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Update is not allowed for this operation.'); + + $normalizer = new ItemNormalizer( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $this->prophesize(IriConverterInterface::class)->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + ); + + $normalizer->denormalize( + [ + 'data' => [ + 'id' => 1, + 'type' => 'dummy', + ], + ], + Dummy::class, + ItemNormalizer::FORMAT, + [ + 'api_allow_update' => false, + ] + ); + } + + public function testDenormalizeCollectionIsNotArray(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('The type of the "relatedDummies" attribute must be "array", "string" given.'); + + $data = [ + 'data' => [ + 'type' => 'dummy', + 'relationships' => [ + 'relatedDummies' => [ + 'data' => 'foo', + ], + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummies'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', [])->willReturn((new ApiProperty()) + ->withBuiltinTypes([$type]) + ->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [] + ); + + $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); + } + + public function testDenormalizeCollectionWithInvalidKey(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('The type of the key "0" must be "string", "integer" given.'); + + $data = [ + 'data' => [ + 'type' => 'dummy', + 'relationships' => [ + 'relatedDummies' => [ + 'data' => [ + [ + 'type' => 'related-dummy', + 'id' => '2', + ], + ], + ], + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummies'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([$type])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [] + ); + + $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); + } + + public function testDenormalizeRelationIsNotResourceLinkage(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); + + $data = [ + 'data' => [ + 'type' => 'dummy', + 'relationships' => [ + 'relatedDummy' => [ + 'data' => 'foo', + ], + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummy'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class), ])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [] + ); + + $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); + } +} diff --git a/Tests/Serializer/ReservedAttributeNameConverterTest.php b/Tests/Serializer/ReservedAttributeNameConverterTest.php new file mode 100644 index 0000000..7c261af --- /dev/null +++ b/Tests/Serializer/ReservedAttributeNameConverterTest.php @@ -0,0 +1,60 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Tests\Fixtures\CustomConverter; +use PHPUnit\Framework\TestCase; + +/** + * @author Baptiste Meyer + */ +class ReservedAttributeNameConverterTest extends TestCase +{ + private ReservedAttributeNameConverter $reservedAttributeNameConverter; + + protected function setUp(): void + { + $this->reservedAttributeNameConverter = new ReservedAttributeNameConverter(new CustomConverter()); + } + + public static function propertiesProvider(): array + { + return [ + ['id', '_id'], + ['type', '_type'], + ['links', '_links'], + ['relationships', '_relationships'], + ['included', '_included'], + ['foo', 'foo'], + ['bar', 'bar'], + ['baz', 'baz'], + ['qux', 'qux'], + ['nameConverted', 'name_converted'], + ['_tilleuls', '_tilleuls'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('propertiesProvider')] + public function testNormalize(string $propertyName, string $expectedPropertyName): void + { + $this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->normalize($propertyName)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('propertiesProvider')] + public function testDenormalize(string $expectedPropertyName, string $propertyName): void + { + $this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->denormalize($propertyName)); + } +} diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php new file mode 100644 index 0000000..180c5d5 --- /dev/null +++ b/Tests/State/JsonApiProviderTest.php @@ -0,0 +1,40 @@ + + * + * 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\JsonApi\Tests\State; + +use ApiPlatform\JsonApi\State\JsonApiProvider; +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; + +class JsonApiProviderTest extends TestCase +{ + public function testProvide(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->once())->method('getRequestFormat')->willReturn('jsonapi'); + $request->attributes = $this->createMock(ParameterBag::class); + $request->attributes->expects($this->once())->method('get')->with('_api_filters', [])->willReturn([]); + $request->attributes->method('set')->with($this->logicalOr('_api_filter_property', '_api_included', '_api_filters'), $this->logicalOr(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], ['relatedDummy'], [])); + $request->query = new InputBag(['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $operation = new Get(class: 'dummy', shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..62536cc --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "api-platform/json-api", + "description": "API JSON-API support", + "type": "library", + "keywords": [ + "REST", + "GraphQL", + "API", + "JSON-LD", + "Hydra", + "JSONAPI", + "OpenAPI", + "HAL", + "Swagger" + ], + "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/documentation": "^3.2 || ^4.0", + "api-platform/json-schema": "^3.2 || ^4.0", + "api-platform/metadata": "^3.2 || ^4.0", + "api-platform/serializer": "^3.2 || ^4.0", + "api-platform/state": "^3.2 || ^4.0", + "symfony/error-handler": "^6.4 || ^7.1", + "symfony/http-foundation": "^6.4 || ^7.1" + }, + "require-dev": { + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.2" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\JsonApi\\": "" + }, + "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.1" + } + }, + "scripts": { + "test": "./vendor/bin/phpunit" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..5c65d38 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + +