Skip to content

Commit

Permalink
Fix ResourceClassResolver handling of inheritance
Browse files Browse the repository at this point in the history
  • Loading branch information
teohhanhui committed May 17, 2019
1 parent 1f84724 commit ebc7fd2
Show file tree
Hide file tree
Showing 36 changed files with 595 additions and 541 deletions.
1 change: 0 additions & 1 deletion features/main/operation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Feature: Operation support
I need to be able to add custom operations and remove built-in ones

@createSchema
@dropSchema
Scenario: Can not write readonly property
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/readable_only_properties" with body:
Expand Down
8 changes: 3 additions & 5 deletions features/main/relation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ Feature: Relations support
Given there are people having pets
When I add "Content-Type" header equal to "application/ld+json"
And I send a "GET" request to "/people"
And the response status code should be 200
Then the response status code should be 200
And the response should be in JSON
And the JSON should be equal to:
"""
Expand Down Expand Up @@ -621,8 +621,6 @@ Feature: Relations support
}
"""


@dropSchema
Scenario: Passing an invalid IRI to a relation
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/relation_embedders" with body:
Expand All @@ -634,7 +632,7 @@ Feature: Relations support
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; charset=utf-8"
And the JSON node "hydra:description" should contain "Invalid value provided (invalid IRI?)."
And the JSON node "hydra:description" should contain 'Invalid IRI "certainly not an iri and not a plain identifier".'

Scenario: Passing an invalid type to a relation
When I add "Content-Type" header equal to "application/ld+json"
Expand All @@ -647,4 +645,4 @@ Feature: Relations support
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; charset=utf-8"
And the JSON node "hydra:description" should contain "Invalid value provided (invalid IRI?)."
And the JSON node "hydra:description" should contain 'Invalid IRI "8".'
48 changes: 24 additions & 24 deletions features/serializer/vo_relations.feature
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,28 @@ Feature: Value object as ApiResource
Then the response status code should be 201
And the JSON should be equal to:
"""
{
"@context": "/contexts/VoDummyCar",
"@id": "/vo_dummy_cars/1",
"@type": "VoDummyCar",
"mileage": 1500,
"bodyType": "suv",
"inspections": [],
"make": "CustomCar",
"insuranceCompany": {
"@id": "/vo_dummy_insurance_companies/1",
"@type": "VoDummyInsuranceCompany",
"name": "Safe Drive Company"
},
"drivers": [
{
"@id": "/vo_dummy_drivers/1",
"@type": "VoDummyDriver",
"firstName": "John",
"lastName": "Doe"
}
]
}
{
"@context": "/contexts/VoDummyCar",
"@id": "/vo_dummy_cars/1",
"@type": "VoDummyCar",
"mileage": 1500,
"bodyType": "suv",
"inspections": [],
"make": "CustomCar",
"insuranceCompany": {
"@id": "/vo_dummy_insurance_companies/1",
"@type": "VoDummyInsuranceCompany",
"name": "Safe Drive Company"
},
"drivers": [
{
"@id": "/vo_dummy_drivers/1",
"@type": "VoDummyDriver",
"firstName": "John",
"lastName": "Doe"
}
]
}
"""
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

Expand All @@ -60,6 +60,7 @@ Feature: Value object as ApiResource
"car": "/vo_dummy_cars/1"
}
"""
Then print last JSON response
Then the response status code should be 201
And the JSON should be valid according to this schema:
"""
Expand Down Expand Up @@ -98,8 +99,7 @@ Feature: Value object as ApiResource
"@type": "VoDummyInspection",
"accepted": true,
"car": "/vo_dummy_cars/1",
"performed": "2018-08-24T00:00:00+00:00",
"id": 1
"performed": "2018-08-24T00:00:00+00:00"
}
"""

Expand Down
3 changes: 2 additions & 1 deletion src/Api/IdentifiersExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public function getIdentifiersFromResourceClass(string $resourceClass): array
public function getIdentifiersFromItem($item): array
{
$identifiers = [];
$resourceClass = $this->getObjectClass($item);
$resourceClass = null !== $this->resourceClassResolver ? $this->resourceClassResolver->getResourceClass($item) : $this->getObjectClass($item);

foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
$identifier = $propertyMetadata->isIdentifier();
Expand Down
46 changes: 28 additions & 18 deletions src/Api/ResourceClassResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,43 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName
*/
public function getResourceClass($value, string $resourceClass = null, bool $strict = false): string
{
$type = \is_object($value) && !$value instanceof \Traversable ? $this->getObjectClass($value) : $resourceClass;
$resourceClass = $resourceClass ?? $type;
if ($strict && null === $resourceClass) {
throw new InvalidArgumentException('Strict checking is only possible when resource class is specified.');
}

$actualClass = \is_object($value) && !$value instanceof \Traversable ? $this->getObjectClass($value) : null;

if (null === $actualClass && null === $resourceClass) {
throw new InvalidArgumentException('Resource type could not be determined. Resource class must be specified.');
}

if (null === $resourceClass) {
throw new InvalidArgumentException(sprintf('No resource class found.'));
if (null !== $resourceClass && !$this->isResourceClass($resourceClass)) {
throw new InvalidArgumentException(sprintf('Specified class "%s" is not a resource class.', $resourceClass));
}

if (
null === $type
|| ((!$strict || $resourceClass === $type) && $isResourceClass = $this->isResourceClass($type))
) {
if (null === $actualClass) {
return $resourceClass;
}

// The Resource is an interface
if ($value instanceof $resourceClass && $type !== $resourceClass && interface_exists($resourceClass)) {
throw new InvalidArgumentException(sprintf('The given object\'s resource is the interface "%s", finding a class is not possible.', $resourceClass));
if ($strict && !($typesMatch = is_a($actualClass, $resourceClass, true))) {
throw new InvalidArgumentException(sprintf('Object of type "%s" does not match "%s" resource class.', $actualClass, $resourceClass));
}

$mostSpecificResourceClass = null;

foreach ($this->resourceNameCollectionFactory->create() as $resourceClassName) {
if (is_a($actualClass, $resourceClassName, true)) {
if (null === $mostSpecificResourceClass || is_subclass_of($resourceClassName, $mostSpecificResourceClass, true)) {
$mostSpecificResourceClass = $resourceClassName;
}
}
}

if (
($isResourceClass ?? $this->isResourceClass($type))
|| (is_subclass_of($type, $resourceClass) && $this->isResourceClass($resourceClass))
) {
return $type;
if (null === $mostSpecificResourceClass) {
throw new InvalidArgumentException(sprintf('No resource class found for object of type "%s".', $actualClass));
}

throw new InvalidArgumentException(sprintf('No resource class found for object of type "%s".', $type));
return $mostSpecificResourceClass;
}

/**
Expand All @@ -79,7 +89,7 @@ public function isResourceClass(string $type): bool
}

foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
if ($type === $resourceClass) {
if (is_a($type, $resourceClass, true)) {
return $this->localIsResourceClassCache[$type] = true;
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator
}

/**
* {@inheritdoc}
*
* The context may contain serialization groups which helps defining joined entities that are readable.
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
Expand Down Expand Up @@ -111,6 +113,10 @@ private function apply(bool $collection, QueryBuilder $queryBuilder, QueryNameGe
return;
}

if (!empty($context[AbstractNormalizer::GROUPS])) {
$options['serializer_groups'] = $context[AbstractNormalizer::GROUPS];
}

$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context);
}

Expand All @@ -134,10 +140,6 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt
$classMetadata = $entityManager->getClassMetadata($resourceClass);
$attributesMetadata = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($resourceClass)->getAttributesMetadata() : null;

if (!empty($normalizationContext[AbstractNormalizer::GROUPS])) {
$options['serializer_groups'] = $normalizationContext[AbstractNormalizer::GROUPS];
}

foreach ($classMetadata->associationMappings as $association => $mapping) {
//Don't join if max depth is enabled and the current depth limit is reached
if (0 === $currentDepth && ($normalizationContext[AbstractObjectNormalizer::ENABLE_MAX_DEPTH] ?? false)) {
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<argument type="service" id="api_platform.identifiers_extractor.cached" />
<argument type="service" id="api_platform.subresource_data_provider" on-invalid="ignore" />
<argument type="service" id="api_platform.identifier.converter" on-invalid="ignore" />
<argument type="service" id="api_platform.resource_class_resolver" />
</service>
<service id="ApiPlatform\Core\Api\IriConverterInterface" alias="api_platform.iri_converter" />

Expand Down
7 changes: 5 additions & 2 deletions src/Bridge/Symfony/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\Api\OperationType;
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
use ApiPlatform\Core\Api\UrlGeneratorInterface;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\OperationDataProviderTrait;
Expand Down Expand Up @@ -48,15 +49,17 @@ final class IriConverter implements IriConverterInterface
private $routeNameResolver;
private $router;
private $identifiersExtractor;
private $resourceClassResolver;

public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null)
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
{
$this->itemDataProvider = $itemDataProvider;
$this->routeNameResolver = $routeNameResolver;
$this->router = $router;
$this->identifiersExtractor = $identifiersExtractor;
$this->subresourceDataProvider = $subresourceDataProvider;
$this->identifierConverter = $identifierConverter;
$this->resourceClassResolver = $resourceClassResolver;

if (null === $identifiersExtractor) {
@trigger_error(sprintf('Not injecting "%s" is deprecated since API Platform 2.1 and will not be possible anymore in API Platform 3', IdentifiersExtractorInterface::class), E_USER_DEPRECATED);
Expand Down Expand Up @@ -115,7 +118,7 @@ public function getItemFromIri(string $iri, array $context = [])
*/
public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
$resourceClass = $this->getObjectClass($item);
$resourceClass = null !== $this->resourceClassResolver ? $this->resourceClassResolver->getResourceClass($item) : $this->getObjectClass($item);
$routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM);

try {
Expand Down
10 changes: 6 additions & 4 deletions src/GraphQl/Resolver/Factory/CollectionResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
}

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$dataProviderContext = $resourceMetadata->getGraphqlAttribute($operationName ?? 'query', 'normalization_context', [], true);
$dataProviderContext['attributes'] = $this->fieldsToAttributes($info);
$normalizationContext = $resourceMetadata->getGraphqlAttribute($operationName ?? 'query', 'normalization_context', [], true);
$normalizationContext['attributes'] = $this->fieldsToAttributes($info);
$dataProviderContext = $normalizationContext;
$dataProviderContext['filters'] = $this->getNormalizedFilters($args);
$dataProviderContext['graphql'] = true;
$normalizationContext['resource_class'] = $resourceClass;

if (isset($rootClass, $source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_KEY])) {
$rootResolvedFields = $this->identifiersExtractor->getIdentifiersFromItem(unserialize($source[ItemNormalizer::ITEM_KEY]));
Expand All @@ -95,7 +97,7 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
if (!$this->paginationEnabled) {
$data = [];
foreach ($collection as $index => $object) {
$data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $dataProviderContext);
$data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext);
}

return $data;
Expand All @@ -120,7 +122,7 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,

foreach ($collection as $index => $object) {
$data['edges'][$index] = [
'node' => $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $dataProviderContext),
'node' => $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext),
'cursor' => base64_encode((string) ($index + $offset)),
];
}
Expand Down
8 changes: 5 additions & 3 deletions src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$wrapFieldName = lcfirst($resourceMetadata->getShortName());
$normalizationContext = $resourceMetadata->getGraphqlAttribute($operationName ?? '', 'normalization_context', [], true);
$normalizationContext['attributes'] = $this->fieldsToAttributes($info)[$wrapFieldName] ?? [];
$baseNormalizationContext = $resourceMetadata->getGraphqlAttribute($operationName ?? '', 'normalization_context', [], true);
$baseNormalizationContext['attributes'] = $this->fieldsToAttributes($info)[$wrapFieldName] ?? [];
$normalizationContext = $baseNormalizationContext;
$normalizationContext['resource_class'] = $resourceClass;

if (isset($args['input']['id'])) {
try {
$item = $this->iriConverter->getItemFromIri($args['input']['id'], $normalizationContext);
$item = $this->iriConverter->getItemFromIri($args['input']['id'], $baseNormalizationContext);
} catch (ItemNotFoundException $e) {
throw Error::createLocatedError(sprintf('Item "%s" not found.', $args['input']['id']), $info->fieldNodes, $info->path);
}
Expand Down
1 change: 1 addition & 0 deletions src/GraphQl/Resolver/ItemResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public function __invoke($source, $args, $context, ResolveInfo $info)
$this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, 'query');

$normalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true);
$normalizationContext['resource_class'] = $resourceClass;

return $this->normalizer->normalize($item, ItemNormalizer::FORMAT, $normalizationContext + $baseNormalizationContext);
}
Expand Down
3 changes: 1 addition & 2 deletions src/Hal/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ public function normalize($object, $format = null, array $context = [])
$context['cache_key'] = $this->getHalCacheKey($format, $context);
}

// Use resolved resource class instead of given resource class to support multiple inheritance child types
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true);
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, isset($context['resource_class']));
$context = $this->initContext($resourceClass, $context);
$iri = $this->iriConverter->getIriFromItem($object);
$context['iri'] = $iri;
Expand Down
14 changes: 3 additions & 11 deletions src/Hydra/Serializer/CollectionFiltersNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use ApiPlatform\Core\Api\FilterInterface;
use ApiPlatform\Core\Api\FilterLocatorTrait;
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
Expand Down Expand Up @@ -71,19 +70,12 @@ public function hasCacheableSupportsMethod(): bool
public function normalize($object, $format = null, array $context = [])
{
$data = $this->collectionNormalizer->normalize($object, $format, $context);
if (isset($context['api_sub_level'])) {

if (!isset($context['resource_class']) || isset($context['api_sub_level'])) {
return $data;
}

try {
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true);
} catch (InvalidArgumentException $e) {
if (!isset($context['resource_class'])) {
return $data;
}

throw $e;
}
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

$operationName = $context['collection_operation_name'] ?? null;
Expand Down
Loading

0 comments on commit ebc7fd2

Please sign in to comment.