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/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index e40ff6a..6cd4d7e 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\JsonApi\JsonSchema; -use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; use ApiPlatform\JsonSchema\ResourceMetadataTrait; use ApiPlatform\JsonSchema\Schema; @@ -106,7 +105,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ], ]; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); diff --git a/Serializer/CollectionNormalizer.php b/Serializer/CollectionNormalizer.php index 8fe2be7..3d55a0b 100644 --- a/Serializer/CollectionNormalizer.php +++ b/Serializer/CollectionNormalizer.php @@ -13,11 +13,10 @@ namespace ApiPlatform\JsonApi\Serializer; -use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; +use ApiPlatform\JsonApi\Util\IriHelper; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\AbstractCollectionNormalizer; -use ApiPlatform\Util\IriHelper; use Symfony\Component\Serializer\Exception\UnexpectedValueException; /** @@ -31,7 +30,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer { public const FORMAT = 'jsonapi'; - public function __construct(ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory) { parent::__construct($resourceClassResolver, $pageParameterName, $resourceMetadataFactory); } diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 5414d4d..6dc1e1e 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -13,12 +13,9 @@ namespace ApiPlatform\JsonApi\Serializer; -use ApiPlatform\Api\Entrypoint; -use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface as LegacyUrlGeneratorInterface; -use ApiPlatform\Documentation\Entrypoint as DocumentationEntrypoint; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Documentation\Entrypoint; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -37,7 +34,7 @@ final class EntrypointNormalizer implements NormalizerInterface, CacheableSuppor { public const FORMAT = 'jsonapi'; - public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly IriConverterInterface|LegacyIriConverterInterface $iriConverter, private readonly UrlGeneratorInterface|LegacyUrlGeneratorInterface $urlGenerator) + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly IriConverterInterface $iriConverter, private readonly UrlGeneratorInterface $urlGenerator) { } @@ -75,12 +72,12 @@ public function normalize(mixed $object, ?string $format = null, array $context */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return self::FORMAT === $format && ($data instanceof Entrypoint || $data instanceof DocumentationEntrypoint); + return self::FORMAT === $format && $data instanceof Entrypoint; } public function getSupportedTypes($format): array { - return self::FORMAT === $format ? [Entrypoint::class => true, DocumentationEntrypoint::class => true] : []; + return self::FORMAT === $format ? [Entrypoint::class => true] : []; } public function hasCacheableSupportsMethod(): bool diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 19764af..6249a07 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -13,7 +13,6 @@ namespace ApiPlatform\JsonApi\Serializer; -use ApiPlatform\Problem\Serializer\ErrorNormalizerTrait; use ApiPlatform\Serializer\CacheableSupportsMethodInterface; use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface as LegacyConstraintViolationListAwareExceptionInterface; use ApiPlatform\Validator\Exception\ConstraintViolationListAwareExceptionInterface; diff --git a/Serializer/ErrorNormalizerTrait.php b/Serializer/ErrorNormalizerTrait.php new file mode 100644 index 0000000..ffb08c4 --- /dev/null +++ b/Serializer/ErrorNormalizerTrait.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\JsonApi\Serializer; + +use ApiPlatform\Metadata\Exception\ErrorCodeSerializableInterface; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpFoundation\Response; + +/** + * @internal + */ +trait ErrorNormalizerTrait +{ + private function getErrorMessage($object, array $context, bool $debug = false): string + { + $message = $object->getMessage(); + + if ($debug) { + return $message; + } + + if ($object instanceof FlattenException) { + $statusCode = $context['statusCode'] ?? $object->getStatusCode(); + if ($statusCode >= 500 && $statusCode < 600) { + $message = Response::$statusTexts[$statusCode] ?? Response::$statusTexts[Response::HTTP_INTERNAL_SERVER_ERROR]; + } + } + + return $message; + } + + private function getErrorCode(object $object): ?string + { + if ($object instanceof FlattenException) { + $exceptionClass = $object->getClass(); + } else { + $exceptionClass = $object::class; + } + + if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { + return $exceptionClass::getErrorCode(); + } + + return null; + } +} diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 84c15dd..6b5fba8 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -13,14 +13,13 @@ namespace ApiPlatform\JsonApi\Serializer; -use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; -use ApiPlatform\Exception\ItemNotFoundException; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -28,7 +27,6 @@ use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; -use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; @@ -56,7 +54,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } diff --git a/Serializer/ObjectNormalizer.php b/Serializer/ObjectNormalizer.php index 2dc6a4c..f6b1583 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -13,8 +13,6 @@ namespace ApiPlatform\JsonApi\Serializer; -use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -34,7 +32,7 @@ final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMe public const FORMAT = 'jsonapi'; - public function __construct(private readonly NormalizerInterface $decorated, private readonly IriConverterInterface|LegacyIriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory) + public function __construct(private readonly NormalizerInterface $decorated, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory) { } 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/ErrorCodeSerializable.php b/Tests/Fixtures/ErrorCodeSerializable.php new file mode 100644 index 0000000..eff8414 --- /dev/null +++ b/Tests/Fixtures/ErrorCodeSerializable.php @@ -0,0 +1,27 @@ + + * + * 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\Exception\ErrorCodeSerializableInterface; + +class ErrorCodeSerializable extends \Exception implements ErrorCodeSerializableInterface +{ + /** + * {@inheritdoc} + */ + public static function getErrorCode(): string + { + return '1234'; + } +} 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..5c9b6f6 --- /dev/null +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,189 @@ + + * + * 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\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, + ); + + // $halSchemaFactory = new HalSchemaFactory($baseSchemaFactory); + // $hydraSchemaFactory = new HydraSchemaFactory($halSchemaFactory); + + $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(); + + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + // @noRector + $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()); + // @noRector + $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()); + // @noRector + $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..6d117e3 --- /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\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\Metadata\ResourceClassResolverInterface; +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..6da0e19 --- /dev/null +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -0,0 +1,102 @@ + + * + * 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\Serializer\Serializer; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * @author Baptiste Meyer + */ +class ConstraintViolationNormalizerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @group legacy + */ + 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)); + + if (!method_exists(Serializer::class, 'getSupportedTypes')) { + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + } + } + + 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..8b0b820 --- /dev/null +++ b/Tests/Serializer/EntrypointNormalizerTest.php @@ -0,0 +1,105 @@ + + * + * 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; +use Symfony\Component\Serializer\Serializer; + +/** + * @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)); + + if (!method_exists(Serializer::class, 'getSupportedTypes')) { + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + } + } + + 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/ErrorNormalizerTest.php b/Tests/Serializer/ErrorNormalizerTest.php new file mode 100644 index 0000000..7b9ee30 --- /dev/null +++ b/Tests/Serializer/ErrorNormalizerTest.php @@ -0,0 +1,132 @@ + + * + * 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\ErrorNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\ErrorCodeSerializable; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Serializer; + +/** + * @author Baptiste Meyer + */ +class ErrorNormalizerTest extends TestCase +{ + /** + * @group legacy + */ + public function testSupportsNormalization(): void + { + $normalizer = new ErrorNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new \Exception(), ErrorNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization(new \Exception(), 'xml')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); + + $this->assertTrue($normalizer->supportsNormalization(new FlattenException(), ErrorNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization(new FlattenException(), 'xml')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); + $this->assertEmpty($normalizer->getSupportedTypes('json')); + $this->assertSame([ + \Exception::class => true, + FlattenException::class => true, + ], $normalizer->getSupportedTypes($normalizer::FORMAT)); + + if (!method_exists(Serializer::class, 'getSupportedTypes')) { + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + } + } + + /** + * @dataProvider errorProvider + * + * @group legacy + * + * @param int $status http status code of the Exception + * @param string $originalMessage original message of the Exception + * @param bool $debug simulates kernel debug variable + */ + public function testNormalize(int $status, string $originalMessage, bool $debug): void + { + $normalizer = new ErrorNormalizer($debug); + $exception = FlattenException::create(new \Exception($originalMessage), $status); + + $expected = [ + 'title' => 'An error occurred', + 'description' => ($debug || $status < 500) ? $originalMessage : Response::$statusTexts[$status], + ]; + + if ($debug) { + $expected['trace'] = $exception->getTrace(); + } + + $this->assertSame($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + + /** + * @group legacy + */ + public function testNormalizeAnExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = new ErrorCodeSerializable($originalMessage); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertSame($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + + /** + * @group legacy + */ + public function testNormalizeAFlattenExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = FlattenException::create(new ErrorCodeSerializable($originalMessage), $status); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertSame($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + + public static function errorProvider(): array + { + return [ + [Response::HTTP_INTERNAL_SERVER_ERROR, 'Sensitive SQL error displayed', false], + [Response::HTTP_GATEWAY_TIMEOUT, 'Sensitive server error displayed', false], + [Response::HTTP_BAD_REQUEST, 'Bad Request Message', false], + [Response::HTTP_INTERNAL_SERVER_ERROR, 'Sensitive SQL error displayed', true], + [Response::HTTP_GATEWAY_TIMEOUT, 'Sensitive server error displayed', true], + [Response::HTTP_BAD_REQUEST, 'Bad Request Message', true], + ]; + } +} 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..18bf241 --- /dev/null +++ b/Tests/Serializer/ReservedAttributeNameConverterTest.php @@ -0,0 +1,64 @@ + + * + * 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'], + ]; + } + + /** + * @dataProvider propertiesProvider + */ + public function testNormalize(string $propertyName, string $expectedPropertyName): void + { + $this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->normalize($propertyName)); + } + + /** + * @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..9139f80 --- /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\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 = $this->createMock(ParameterBag::class); // @phpstan-ignore-line + $request->query->method('all')->willReturn(['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/Tests/Util/IriHelperTest.php b/Tests/Util/IriHelperTest.php new file mode 100644 index 0000000..3996176 --- /dev/null +++ b/Tests/Util/IriHelperTest.php @@ -0,0 +1,83 @@ + + * + * 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\Util; + +use ApiPlatform\JsonApi\Util\IriHelper; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; + +/** + * @author Kévin Dunglas + */ +class IriHelperTest extends TestCase +{ + use ExpectDeprecationTrait; + + public function testHelpers(): void + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertSame($parsed, IriHelper::parseIri('/hello.json?foo=bar&page=2&bar=3', 'page')); + $this->assertSame('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2.)); + } + + public function testHelpersWithNetworkPath(): void + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + 'scheme' => 'http', + 'user' => 'foo', + 'pass' => 'bar', + 'host' => 'localhost', + 'port' => 8080, + 'fragment' => 'foo', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertSame('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + unset($parsed['parts']['scheme']); + + $this->assertSame('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + $parsed['parts']['port'] = 443; + + $this->assertSame('//foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + } + + public function testParseIriWithInvalidUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The request URI "http:///" is malformed.'); + + IriHelper::parseIri('http:///', 'page'); + } +} diff --git a/Util/IriHelper.php b/Util/IriHelper.php new file mode 100644 index 0000000..11f0444 --- /dev/null +++ b/Util/IriHelper.php @@ -0,0 +1,108 @@ + + * + * 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\Util; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\Util\RequestParser; + +/** + * Parses and creates IRIs. + * + * @author Kévin Dunglas + * + * @internal + */ +final class IriHelper +{ + private function __construct() + { + } + + /** + * Parses and standardizes the request IRI. + * + * @throws InvalidArgumentException + */ + public static function parseIri(string $iri, string $pageParameterName): array + { + $parts = parse_url($iri); + if (false === $parts) { + throw new InvalidArgumentException(sprintf('The request URI "%s" is malformed.', $iri)); + } + + $parameters = []; + if (isset($parts['query'])) { + $parameters = RequestParser::parseRequestParams($parts['query']); + + // Remove existing page parameter + unset($parameters[$pageParameterName]); + } + + return ['parts' => $parts, 'parameters' => $parameters]; + } + + /** + * Gets a collection IRI for the given parameters. + */ + public static function createIri(array $parts, array $parameters, ?string $pageParameterName = null, ?float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string + { + if (null !== $page && null !== $pageParameterName) { + $parameters[$pageParameterName] = $page; + } + + $query = http_build_query($parameters, '', '&', \PHP_QUERY_RFC3986); + $parts['query'] = preg_replace('/%5B\d+%5D/', '%5B%5D', $query); + + $url = ''; + if ((UrlGeneratorInterface::ABS_URL === $urlGenerationStrategy || UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy) && isset($parts['host'])) { + if (isset($parts['scheme'])) { + $scheme = $parts['scheme']; + } elseif (isset($parts['port']) && 443 === $parts['port']) { + $scheme = 'https'; + } else { + $scheme = 'http'; + } + $url .= UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy ? '//' : "$scheme://"; + + if (isset($parts['user'])) { + $url .= $parts['user']; + + if (isset($parts['pass'])) { + $url .= ':'.$parts['pass']; + } + + $url .= '@'; + } + + $url .= $parts['host']; + + if (isset($parts['port'])) { + $url .= ':'.$parts['port']; + } + } + + $url .= $parts['path']; + + if ('' !== $parts['query']) { + $url .= '?'.$parts['query']; + } + + if (isset($parts['fragment'])) { + $url .= '#'.$parts['fragment']; + } + + return $url; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6b5420b --- /dev/null +++ b/composer.json @@ -0,0 +1,73 @@ +{ + "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": "*@dev || ^3.1", + "api-platform/json-schema": "*@dev || ^3.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/serializer": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "symfony/error-handler": "^7.1", + "symfony/http-foundation": "^7.1" + }, + "require-dev": { + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^10" + }, + "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": "3.3.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 + + +