Skip to content

Commit

Permalink
Enforce API strong typing
Browse files Browse the repository at this point in the history
  • Loading branch information
dunglas committed Aug 18, 2016
1 parent ce84fd7 commit e58bd73
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 95 deletions.
4 changes: 2 additions & 2 deletions features/hydra/error.feature
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Feature: Error handling
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'Nested objects for attribute "relatedDummy" of "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" are not enabled. Use serialization groups to change that behavior.'
And the JSON node "hydra:description" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.'
And the JSON node "trace" should exist

Scenario: Get an error during deserialization of collection
Expand All @@ -62,7 +62,7 @@ Feature: Error handling
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'Nested objects for attribute "relatedDummies" of "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" are not enabled. Use serialization groups to change that behavior.'
And the JSON node "hydra:description" should be equal to 'Nested documents for attribute "relatedDummies" are not allowed. Use IRIs instead.'
And the JSON node "trace" should exist

Scenario: Get an error because of an invalid JSON
Expand Down
54 changes: 36 additions & 18 deletions features/invalid_data.feature
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Feature: Handle properly invalid data submitted to the API
In order to have robust API
As a client software developer
I can send unsupported attributes that will be ignored
The API must enforce strong typing

@createSchema
Scenario: Create a resource
Expand Down Expand Up @@ -49,7 +49,7 @@ Feature: Handle properly invalid data submitted to the API
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'Expected IRI or nested object for attribute "relatedDummy" of "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy", "string" given.'
And the JSON node "hydra:description" should be equal to 'Expected IRI or nested document for attribute "relatedDummy", "string" given.'
And the JSON node "trace" should exist

Scenario: Ignore invalid dates
Expand All @@ -64,33 +64,51 @@ Feature: Handle properly invalid data submitted to the API
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json"

@dropSchema
Scenario: Send non-array data when an array is expected
When I send a "POST" request to "/dummies" with body:
"""
"""
{
"name": "Invalid",
"relatedDummies": "hello"
}
"""
Then the response status code should be 201
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json"
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'The type of the "relatedDummies" attribute must be "array", "string" given.'
And the JSON node "trace" should exist

Scenario: Send an object where an array is expected
When I send a "POST" request to "/dummies" with body:
"""
And the JSON should be equal to:
{
"@context": "/contexts/Dummy",
"@id": "/dummies/2",
"@type": "Dummy",
"name": "Invalid",
"alias": null,
"description": null,
"dummyDate": null,
"dummyPrice": null,
"jsonData": [],
"relatedDummy": null,
"dummy": null,
"relatedDummies": [],
"name_converted": null
"relatedDummies": {"a": {}, "b": {}}
}
"""
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json"
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'The type of the key "a" must be "int", "string" given.'

@dropSchema
Scenario: Send a scalar having the bad type
When I send a "POST" request to "/dummies" with body:
"""
{
"name": 42
}
"""
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json"
And the JSON node "@context" should be equal to "/contexts/Error"
And the JSON node "@type" should be equal to "Error"
And the JSON node "hydra:title" should be equal to "An error occurred"
And the JSON node "hydra:description" should be equal to 'The type of the "name" attribute must be "string", "integer" given.'
2 changes: 1 addition & 1 deletion features/problem.feature
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json)
And the header "Content-Type" should be equal to "application/problem+json"
And the JSON node "type" should be equal to "https://tools.ietf.org/html/rfc2616#section-10"
And the JSON node "title" should be equal to "An error occurred"
And the JSON node "detail" should be equal to 'Nested objects for attribute "relatedDummy" of "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" are not enabled. Use serialization groups to change that behavior.'
And the JSON node "detail" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.'
And the JSON node "trace" should exist
131 changes: 77 additions & 54 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;

Expand Down Expand Up @@ -142,60 +143,89 @@ protected function setAttributeValue($object, $attribute, $value, $format = null
$propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
$type = $propertyMetadata->getType();

if ($type && $value) {
if (
$type->isCollection() &&
($collectionType = $type->getCollectionValueType()) &&
($className = $collectionType->getClassName())
) {
if (!is_array($value)) {
return;
}

$values = [];
foreach ($value as $index => $obj) {
$values[$index] = $this->denormalizeRelation(
$context['resource_class'],
$attribute,
$propertyMetadata,
$className,
$obj,
$format,
$context
);
}

$this->setValue($object, $attribute, $values);

return;
}
if (null === $type) {
// No type provided, blindly set the value
$this->setValue($object, $attribute, $value);

if ($className = $type->getClassName()) {
$this->setValue(
$object,
$attribute,
$this->denormalizeRelation(
$context['resource_class'],
$attribute,
$propertyMetadata,
$className,
$value,
$format,
$context
)
);
return;
}

return;
}
if (
$type->isCollection() &&
null !== ($collectionValueType = $type->getCollectionValueType()) &&
null !== $className = $collectionValueType->getClassName()
) {
$this->setValue(
$object,
$attribute,
$this->denormalizeCollection($attribute, $propertyMetadata, $type, $className, $value, $format, $context)
);

return;
}

if (null !== $className = $type->getClassName()) {
$this->setValue(
$object,
$attribute,
$this->denormalizeRelation($attribute, $propertyMetadata, $className, $value, $format, $context)
);

return;
}

$builtinType = $type->getBuiltinType();
if (!call_user_func('is_'.$builtinType, $value)) {
throw new InvalidArgumentException(sprintf(
'The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, gettype($value)
));
}

$this->setValue($object, $attribute, $value);
}

/**
* Denormalizes a collection of objects.
*
* @param string $attribute
* @param PropertyMetadata $propertyMetadata
* @param Type $type
* @param string $className
* @param mixed $value
* @param string|null $format
* @param array $context
*
* @return array
*/
private function denormalizeCollection(string $attribute, PropertyMetadata $propertyMetadata, Type $type, string $className, $value, string $format = null, array $context) : array
{
if (!is_array($value)) {
throw new InvalidArgumentException(sprintf(
'The type of the "%s" attribute must be "array", "%s" given.', $attribute, gettype($value)
));
}

$collectionKeyType = $type->getCollectionKeyType();
$collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType();

$values = [];
foreach ($value as $index => $obj) {
if (null !== $collectionKeyBuiltinType && !call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
throw new InvalidArgumentException(sprintf(
'The type of the key "%s" must be "%s", "%s" given.',
$index, $collectionKeyBuiltinType, gettype($index))
);
}

$values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $context);
}

return $values;
}

/**
* Denormalizes a relation.
*
* @param string $resourceClass
* @param string $attributeName
* @param PropertyMetadata $propertyMetadata
* @param string $className
Expand All @@ -207,7 +237,7 @@ protected function setAttributeValue($object, $attribute, $value, $format = null
*
* @return object|null
*/
private function denormalizeRelation(string $resourceClass, string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, string $format = null, array $context)
private function denormalizeRelation(string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, string $format = null, array $context)
{
if (is_string($value)) {
try {
Expand All @@ -223,18 +253,11 @@ private function denormalizeRelation(string $resourceClass, string $attributeNam

if (!is_array($value)) {
throw new InvalidArgumentException(sprintf(
'Expected IRI or nested object for attribute "%s" of "%s", "%s" given.',
$attributeName,
$resourceClass,
is_object($value) ? get_class($value) : gettype($value)
'Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, gettype($value)
));
}

throw new InvalidArgumentException(sprintf(
'Nested objects for attribute "%s" of "%s" are not enabled. Use serialization groups to change that behavior.',
$attributeName,
$resourceClass
));
throw new InvalidArgumentException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
}

/**
Expand Down
34 changes: 14 additions & 20 deletions tests/Serializer/AbstractItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ public function testSupportNormalization()
{
$std = new \stdClass();
$dummy = new Dummy();
$dummy->setDescription('hello');

$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
Expand All @@ -43,9 +42,7 @@ public function testSupportNormalization()

$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass($dummy)->willReturn(Dummy::class)->shouldBeCalled();
$resourceClassResolverProphecy->getResourceClass($std)->willThrow(
new InvalidArgumentException()
)->shouldBeCalled();
$resourceClassResolverProphecy->getResourceClass($std)->willThrow(new InvalidArgumentException())->shouldBeCalled();
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true)->shouldBeCalled();
$resourceClassResolverProphecy->isResourceClass(\stdClass::class)->willReturn(false)->shouldBeCalled();

Expand All @@ -54,41 +51,38 @@ public function testSupportNormalization()
$this->assertTrue($normalizer->supportsNormalization($dummy));
$this->assertFalse($normalizer->supportsNormalization($std));
$this->assertTrue($normalizer->supportsDenormalization($dummy, Dummy::class));

$this->assertFalse($normalizer->supportsDenormalization($std, \stdClass::class));
}

public function testNormalize()
{
$std = new \stdClass();
$dummy = new Dummy();
$dummy->setName('hello');

$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['name']));

$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'name', true, true, true, true, false, false, null, []));

$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
$propertyAccesorProphecy = $this->prophesize(PropertyAccessorInterface::class);
$propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['name']));
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'name', true, true, true, true, false, false, null, []));

$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass($dummy, null, true)->willReturn(Dummy::class)->shouldBeCalled();

$normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class,
[
$propertyNameCollectionFactoryProphecy->reveal(),
$propertyMetadataFactoryProphecy->reveal(),
$iriConverterProphecy->reveal(),
$resourceClassResolverProphecy->reveal(),
$propertyAccesorProphecy->reveal(),
]
);
$normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [
$propertyNameCollectionFactoryProphecy->reveal(),
$propertyMetadataFactoryProphecy->reveal(),
$iriConverterProphecy->reveal(),
$resourceClassResolverProphecy->reveal(),
$propertyAccesorProphecy->reveal(),
]);

$serializerProphecy = $this->prophesize(SerializerInterface::class);
$serializerProphecy->willImplement(NormalizerInterface::class);
$serializerProphecy->normalize($this->any()); ///null, 'jsonld', ['api_sub_level' => true, 'resource_class' => Dummy::class, 'api_normalize' => true, 'cache_key' => '000000007f023b9200000000744fe5cf', 'circular_reference_limit' => ['000000002ef5eccf0000000016956ea1' => 1]])->willReturn('hello')->shouldBeCalled();
$serializerProphecy->normalize($this->any());

$normalizer->setSerializer($serializerProphecy->reveal());
$this->assertEquals($normalizer->normalize($dummy, 'jsonld', []), ['name' => null]);
$this->assertEquals($normalizer->normalize($dummy), ['name' => null]);
}
}

0 comments on commit e58bd73

Please sign in to comment.