diff --git a/jsonapi.services.yml b/jsonapi.services.yml index 1a15be7..3327db1 100644 --- a/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -88,7 +88,7 @@ services: - { name: encoder, priority: 21, format: 'api_json' } jsonapi.resource_type.repository: class: Drupal\jsonapi\ResourceType\ResourceTypeRepository - arguments: ['@entity_type.manager', '@entity_type.bundle.info'] + arguments: ['@entity_type.manager', '@entity_type.bundle.info', '@entity_field.manager'] jsonapi.route_enhancer: class: Drupal\jsonapi\Routing\RouteEnhancer tags: diff --git a/src/ResourceType/ResourceType.php b/src/ResourceType/ResourceType.php index 1ef2133..aaff176 100644 --- a/src/ResourceType/ResourceType.php +++ b/src/ResourceType/ResourceType.php @@ -158,8 +158,56 @@ class ResourceType { $this->entityTypeId = $entity_type_id; $this->bundle = $bundle; $this->deserializationTargetClass = $deserialization_target_class; - $this->typeName = sprintf('%s--%s', $this->entityTypeId, $this->bundle); } + /** + * Sets the relatable resource types. + * + * @param array $relatable_resource_types + * The resource types with which this resource type may have a relationship. + * The array should be a multi-dimensional array keyed by public field name + * whose values are an array of resource types. There may be duplicate + * across resource types across fields, but not within a field. + */ + public function setRelatableResourceTypes(array $relatable_resource_types) { + $this->relatableResourceTypes = $relatable_resource_types; + } + + /** + * Get all resource types with which this type may have a relationship. + * + * @return array + * The relatable resource types, keyed by relationship field names. + * + * @see self::setRelatableResourceTypes() + */ + public function getRelatableResourceTypes() { + if (!isset($this->relatableResourceTypes)) { + throw new \LogicException("setRelatableResourceTypes() must be called before getting relatable resource types."); + } + return $this->relatableResourceTypes; + } + + /** + * Get all resource types with which the given field may have a relationship. + * + * Gets all the JSON API resource types that may be related by the given, + * public field name. + * + * @param string $field_name + * The public field name. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType[] + * The relatable JSON API resource types. + * + * @see self::getRelatableResourceTypes() + */ + public function getRelatableResourceTypesByField($field_name) { + $relatable_resource_types = $this->getRelatableResourceTypes(); + return isset($relatable_resource_types[$field_name]) ? + $relatable_resource_types[$field_name] : + []; + } + } diff --git a/src/ResourceType/ResourceTypeRepository.php b/src/ResourceType/ResourceTypeRepository.php index eb8a763..601a7b2 100644 --- a/src/ResourceType/ResourceTypeRepository.php +++ b/src/ResourceType/ResourceTypeRepository.php @@ -2,8 +2,12 @@ namespace Drupal\jsonapi\ResourceType; +use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\TypedData\DataReferenceTargetDefinition; use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException; /** @@ -36,7 +40,14 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface { * * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface */ - protected $bundleManager; + protected $entityTypeBundleInfo; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; /** * All JSON API resource types. @@ -50,12 +61,15 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface { * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. - * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_manager - * The bundle manager. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_bundle_info + * The entity type bundle info service. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_bundle_info, EntityFieldManagerInterface $entity_field_manager) { $this->entityTypeManager = $entity_type_manager; - $this->bundleManager = $bundle_manager; + $this->entityTypeBundleInfo = $entity_bundle_info; + $this->entityFieldManager = $entity_field_manager; } /** @@ -71,7 +85,11 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface { $bundle, $this->entityTypeManager->getDefinition($entity_type_id)->getClass() ); - }, array_keys($this->bundleManager->getBundleInfo($entity_type_id)))); + }, array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id)))); + } + foreach ($this->all as $resource_type) { + $relatable_resource_types = $this->calculateRelatableResourceTypes($resource_type); + $resource_type->setRelatableResourceTypes($relatable_resource_types); } } return $this->all; @@ -104,4 +122,91 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface { return NULL; } + /** + * Calculates relatable JSON API resource types for a given resource type. + * + * This method has no affect after being called once. + * + * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type + * The resource type repository. + * + * @return array + * The relatable JSON API resource types, keyed by field name. + */ + public function calculateRelatableResourceTypes(ResourceType $resource_type) { + // For now, only fieldable entity types may contain relationships. + $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId()); + if ($entity_type->entityClassImplements(FieldableEntityInterface::class)) { + $field_definitions = $this->entityFieldManager->getFieldDefinitions( + $resource_type->getEntityTypeId(), + $resource_type->getBundle() + ); + + return array_map(function ($field_definition) { + return $this->getRelatableResourceTypesFromFieldDefinition($field_definition); + }, array_filter($field_definitions, function ($field_definition) { + return $this->isReferenceFieldDefinition($field_definition); + })); + } + return []; + } + + /** + * Get relatable resource types from a field definition. + * + * @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition from which to calculate relatable JSON API resource + * types. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType[] + * The JSON API resource types with which the given field may have a + * relationship. + */ + protected function getRelatableResourceTypesFromFieldDefinition(FieldDefinitionInterface $field_definition) { + $item_definition = $field_definition->getItemDefinition(); + + $entity_type_id = $item_definition->getSetting('target_type'); + $handler_settings = $item_definition->getSetting('handler_settings'); + + $has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']); + $target_bundles = $has_target_bundles ? + $handler_settings['target_bundles'] + : $this->getAllBundlesForEntityType($entity_type_id); + + return array_map(function ($target_bundle) use ($entity_type_id) { + return $this->get($entity_type_id, $target_bundle); + }, $target_bundles); + } + + /** + * Determines if a given field definition is a reference field. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition to inspect. + * + * @return bool + * TRUE if the field definition is found to be a reference field. FALSE + * otherwise. + */ + protected function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) { + /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */ + $item_definition = $field_definition->getItemDefinition(); + $main_property = $item_definition->getMainPropertyName(); + $property_definition = $item_definition->getPropertyDefinition($main_property); + return $property_definition instanceof DataReferenceTargetDefinition; + } + + /** + * Gets all bundle IDs for a given entity type. + * + * @param string $entity_type_id + * The entity type for which to get bundles. + * + * @return string[] + * The bundle IDs. + */ + protected function getAllBundlesForEntityType($entity_type_id) { + return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id)); + } + } diff --git a/tests/src/Kernel/Context/FieldResolverTest.php b/tests/src/Kernel/Context/FieldResolverTest.php index 5d2926e..5da37fd 100644 --- a/tests/src/Kernel/Context/FieldResolverTest.php +++ b/tests/src/Kernel/Context/FieldResolverTest.php @@ -16,7 +16,13 @@ use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase; */ class FieldResolverTest extends JsonapiKernelTestBase { - public static $modules = ['entity_test', 'serialization', 'field', 'text']; + public static $modules = [ + 'entity_test', + 'serialization', + 'field', + 'text', + 'user', + ]; /** * The subject under test. diff --git a/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php b/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php new file mode 100644 index 0000000..d77034d --- /dev/null +++ b/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php @@ -0,0 +1,182 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + + // Add the additional table schemas. + $this->installSchema('system', ['sequences']); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + + NodeType::create([ + 'type' => 'foo', + ])->save(); + + NodeType::create([ + 'type' => 'bar', + ])->save(); + + $this->createEntityReferenceField( + 'node', + 'foo', + 'field_ref_bar', + 'Bar Reference', + 'node', + 'default', + ['target_bundles' => ['bar']] + ); + + $this->createEntityReferenceField( + 'node', + 'foo', + 'field_ref_foo', + 'Foo Reference', + 'node', + 'default', + // Important to test self-referencing resource types. + ['target_bundles' => ['foo']] + ); + + $this->createEntityReferenceField( + 'node', + 'foo', + 'field_ref_any', + 'Any Bundle Reference', + 'node', + 'default', + // This should result in a reference to any bundle. + ['target_bundles' => NULL] + ); + + $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository'); + } + + /** + * @covers ::getRelatableResourceTypes + * @dataProvider getRelatableResourceTypesProvider + */ + public function testGetRelatableResourceTypes($resource_type_name, $relatable_type_names) { + // We're only testing the fields that we set up. + $test_fields = [ + 'field_ref_foo', + 'field_ref_bar', + 'field_ref_any', + ]; + + $resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name); + + // This extracts just the relationship fields under test. + $subjects = array_intersect_key( + $resource_type->getRelatableResourceTypes(), + array_flip($test_fields) + ); + + // Map the related resource type to their type name so we can just compare + // the type names rather that the whole object. + foreach ($test_fields as $field_name) { + if (isset($subjects[$field_name])) { + $subjects[$field_name] = array_map(function ($resource_type) { + return $resource_type->getTypeName(); + }, $subjects[$field_name]); + } + } + + $this->assertArraySubset($relatable_type_names, $subjects); + } + + /** + * @covers ::getRelatableResourceTypes + * @dataProvider getRelatableResourceTypesProvider + */ + public function getRelatableResourceTypesProvider() { + return [ + [ + 'node--foo', + [ + 'field_ref_foo' => ['node--foo'], + 'field_ref_bar' => ['node--bar'], + 'field_ref_any' => ['node--foo', 'node--bar'], + ], + ], + ['node--bar', []], + ]; + } + + /** + * @covers ::getRelatableResourceTypesByField + * @dataProvider getRelatableResourceTypesByFieldProvider + */ + public function testGetRelatableResourceTypesByField($entity_type_id, $bundle, $field) { + $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle); + $relatable_types = $resource_type->getRelatableResourceTypes(); + $this->assertSame( + $relatable_types[$field], + $resource_type->getRelatableResourceTypesByField($field) + ); + } + + /** + * Provides cases to test getRelatableTypesByField. + */ + public function getRelatableResourceTypesByFieldProvider() { + return [ + ['node', 'foo', 'field_ref_foo'], + ['node', 'foo', 'field_ref_bar'], + ['node', 'foo', 'field_ref_any'], + ]; + } + +} diff --git a/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php index 3013221..4f49fcf 100644 --- a/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php +++ b/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -42,14 +42,9 @@ class JsonApiDocumentTopLevelNormalizerTest extends UnitTestCase { $resource_type_repository = $this->prophesize(ResourceTypeRepository::class); $field_resolver = $this->prophesize(FieldResolver::class); - $resource_type = $this->prophesize(ResourceType::class); - $resource_type - ->getEntityTypeId() - ->willReturn('node'); - $resource_type_repository ->getByTypeName(Argument::any()) - ->willReturn($resource_type->reveal()); + ->willReturn(new ResourceType('node', 'article', NULL)); $entity_storage = $this->prophesize(EntityStorageInterface::class); $self = $this;