diff --git a/jsonapi.services.yml b/jsonapi.services.yml index d98a1bd..287805d 100644 --- a/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -1,5 +1,26 @@ services: - serializer.normalizer.htt_exception.jsonapi: + serializer.normalizer.sort.jsonapi: + class: Drupal\jsonapi\Normalizer\SortNormalizer + tags: + - { name: normalizer } + serializer.normalizer.offset_page.jsonapi: + class: Drupal\jsonapi\Normalizer\OffsetPageNormalizer + tags: + - { name: normalizer } + serializer.normalizer.entity_condition.jsonapi: + class: Drupal\jsonapi\Normalizer\EntityConditionNormalizer + tags: + - { name: normalizer } + serializer.normalizer.entity_condition_group.jsonapi: + class: Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer + tags: + - { name: normalizer } + serializer.normalizer.filter.jsonapi: + class: Drupal\jsonapi\Normalizer\FilterNormalizer + arguments: ['@jsonapi.field_resolver', '@serializer.normalizer.entity_condition.jsonapi', '@serializer.normalizer.entity_condition_group.jsonapi'] + tags: + - { name: normalizer } + serializer.normalizer.http_exception.jsonapi: class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer arguments: ['@current_user'] tags: @@ -69,12 +90,9 @@ services: - { name: route_enhancer } jsonapi.params.enhancer: class: Drupal\jsonapi\Routing\JsonApiParamEnhancer - arguments: ['@entity_field.manager'] + arguments: ['@serializer.normalizer.filter.jsonapi', '@serializer.normalizer.sort.jsonapi', '@serializer.normalizer.offset_page.jsonapi'] tags: - { name: route_enhancer } - jsonapi.query_builder: - class: Drupal\jsonapi\Query\QueryBuilder - arguments: ['@entity_type.manager', '@jsonapi.current_context', '@jsonapi.field_resolver'] jsonapi.link_manager: class: Drupal\jsonapi\LinkManager\LinkManager arguments: ['@router.no_access_checks', '@url_generator'] diff --git a/src/Context/FieldResolver.php b/src/Context/FieldResolver.php index a7a708d..de2cb3b 100644 --- a/src/Context/FieldResolver.php +++ b/src/Context/FieldResolver.php @@ -56,7 +56,7 @@ class FieldResolver { * Example: * 'field_author.entity.field_first_name' -> 'author.firstName'. * - * @param string $field_name + * @param string $internal_field_name * The Drupal field name to map to a public field name. * * @return string @@ -73,14 +73,18 @@ class FieldResolver { * Example: * 'author.firstName' -> 'field_author.entity.field_first_name'. * - * @param string $field_name + * @param string $entity_type_id + * The type of the entity for which to resolve the field name. + * @param string $bundle + * The bundle of the entity for which to resolve the field name. + * @param string $external_field_name * The public field name to map to a Drupal field name. * * @return string * The mapped field name. */ - public function resolveInternal($external_field_name) { - $resource_type = $this->currentContext->getResourceType(); + public function resolveInternal($entity_type_id, $bundle, $external_field_name) { + $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle); if (empty($external_field_name)) { throw new BadRequestHttpException('No field name was provided for the filter.'); } @@ -93,7 +97,6 @@ class FieldResolver { // 'uid.entity.field_category.entity.name'. This may be too simple, but it // works for the time being. $parts = explode('.', $external_field_name); - $entity_type_id = $this->currentContext->getResourceType()->getEntityTypeId(); $reference_breadcrumbs = []; $resource_types = [$resource_type]; while ($field_name = array_shift($parts)) { diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php index 6fbb175..a3c3b43 100644 --- a/src/Controller/EntityResource.php +++ b/src/Controller/EntityResource.php @@ -18,14 +18,14 @@ use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; use Drupal\jsonapi\Context\CurrentContext; use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException; use Drupal\jsonapi\Exception\UnprocessableHttpEntityException; +use Drupal\jsonapi\Query\Filter; +use Drupal\jsonapi\Query\Sort; +use Drupal\jsonapi\Query\OffsetPage; use Drupal\jsonapi\LinkManager\LinkManager; -use Drupal\jsonapi\Query\QueryBuilder; use Drupal\jsonapi\Resource\EntityCollection; use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel; use Drupal\jsonapi\ResourceResponse; use Drupal\jsonapi\ResourceType\ResourceType; -use Drupal\jsonapi\Routing\Param\JsonApiParamBase; -use Drupal\jsonapi\Routing\Param\OffsetPage; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -60,13 +60,6 @@ class EntityResource { protected $fieldManager; /** - * The query builder service. - * - * @var \Drupal\jsonapi\Query\QueryBuilder - */ - protected $queryBuilder; - - /** * The current context service. * * @var \Drupal\jsonapi\Context\CurrentContext @@ -94,8 +87,6 @@ class EntityResource { * The JSON API resource type. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. - * @param \Drupal\jsonapi\Query\QueryBuilder $query_builder - * The query builder. * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager * The entity type field manager. * @param \Drupal\jsonapi\Context\CurrentContext $current_context @@ -105,10 +96,9 @@ class EntityResource { * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager * The link manager service. */ - public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, QueryBuilder $query_builder, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager, LinkManager $link_manager) { + public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager, LinkManager $link_manager) { $this->resourceType = $resource_type; $this->entityTypeManager = $entity_type_manager; - $this->queryBuilder = $query_builder; $this->fieldManager = $field_manager; $this->currentContext = $current_context; $this->pluginManager = $plugin_manager; @@ -587,7 +577,7 @@ class EntityResource { * * @param string $entity_type_id * The entity type for the entity query. - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * @param array $params * The parameters for the query. * * @return \Drupal\Core\Entity\Query\QueryInterface @@ -595,8 +585,38 @@ class EntityResource { */ protected function getCollectionQuery($entity_type_id, $params) { $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $entity_storage = $this->entityTypeManager->getStorage($entity_type_id); + + $query = $entity_storage->getQuery(); + + // Ensure that access checking is performed on the query. + $query->accessCheck(TRUE); + + // Compute and apply an entity query condition from the filter parameter. + if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) { + $query->condition($filter->queryCondition($query)); + } + + // Apply any sorts to the entity query. + if (isset($params[Sort::KEY_NAME]) && $sort = $params[Sort::KEY_NAME]) { + foreach ($sort->fields() as $field) { + $path = $field[Sort::PATH_KEY]; + $direction = isset($field[Sort::DIRECTION_KEY]) ? $field[Sort::DIRECTION_KEY] : 'ASC'; + $langcode = isset($field[Sort::LANGUAGE_KEY]) ? $field[Sort::LANGUAGE_KEY] : NULL; + $query->sort($path, $direction, $langcode); + } + } - $query = $this->queryBuilder->newQuery($entity_type, $params); + // Apply any pagination options to the query. + if (isset($params[OffsetPage::KEY_NAME])) { + $pagination = $params[OffsetPage::KEY_NAME]; + } + else { + $pagination = new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX); + } + // Add one extra element to the page to see if there are more pages needed. + $query->range($pagination->offset(), $pagination->size() + 1); + $query->addMetaData('pager_size', (int) $pagination->size()); // Limit this query to the bundle type for this resource. $bundle = $this->resourceType->getBundle(); @@ -614,7 +634,7 @@ class EntityResource { * * @param string $entity_type_id * The entity type for the entity query. - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * @param array $params * The parameters for the query. * * @return \Drupal\Core\Entity\Query\QueryInterface @@ -622,7 +642,7 @@ class EntityResource { */ protected function getCollectionCountQuery($entity_type_id, $params) { // Override the pagination parameter to get all the available results. - $params[OffsetPage::KEY_NAME] = new JsonApiParamBase([]); + unset($params[OffsetPage::KEY_NAME]); return $this->getCollectionQuery($entity_type_id, $params); } diff --git a/src/Controller/RequestHandler.php b/src/Controller/RequestHandler.php index d032b2c..73cd1e2 100644 --- a/src/Controller/RequestHandler.php +++ b/src/Controller/RequestHandler.php @@ -195,8 +195,6 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter $resource_type_repository = $this->container->get('jsonapi.resource_type.repository'); /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ $entity_type_manager = $this->container->get('entity_type.manager'); - /* @var \Drupal\jsonapi\Query\QueryBuilder $query_builder */ - $query_builder = $this->container->get('jsonapi.query_builder'); /* @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */ $field_manager = $this->container->get('entity_field.manager'); /* @var \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager */ @@ -206,7 +204,6 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter $resource = new EntityResource( $resource_type_repository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')), $entity_type_manager, - $query_builder, $field_manager, $current_context, $plugin_manager, diff --git a/src/LinkManager/LinkManager.php b/src/LinkManager/LinkManager.php index a6fd201..27c37b1 100644 --- a/src/LinkManager/LinkManager.php +++ b/src/LinkManager/LinkManager.php @@ -4,7 +4,7 @@ namespace Drupal\jsonapi\LinkManager; use Drupal\Core\Routing\UrlGeneratorInterface; use Drupal\jsonapi\ResourceType\ResourceType; -use Drupal\jsonapi\Routing\Param\OffsetPage; +use Drupal\jsonapi\Query\OffsetPage; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -116,14 +116,14 @@ class LinkManager { } $params = $request->get('_json_api_params'); if ($page_param = $params[OffsetPage::KEY_NAME]) { - /* @var \Drupal\jsonapi\Routing\Param\OffsetPage $page_param */ - $offset = $page_param->getOffset(); - $size = $page_param->getSize(); + /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */ + $offset = $page_param->offset(); + $size = $page_param->size(); } else { // Apply the defaults. - $offset = 0; - $size = OffsetPage::$maxSize; + $offset = OffsetPage::DEFAULT_OFFSET; + $size = OffsetPage::SIZE_MAX; } if ($size <= 0) { throw new BadRequestHttpException(sprintf('The page size needs to be a positive integer.')); diff --git a/src/Normalizer/EntityConditionGroupNormalizer.php b/src/Normalizer/EntityConditionGroupNormalizer.php new file mode 100644 index 0000000..e8e5d55 --- /dev/null +++ b/src/Normalizer/EntityConditionGroupNormalizer.php @@ -0,0 +1,40 @@ +supportedInterfaceOrClass; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + return new EntityConditionGroup($data['conjunction'], $data['members']); + } + +} diff --git a/src/Normalizer/EntityConditionNormalizer.php b/src/Normalizer/EntityConditionNormalizer.php new file mode 100644 index 0000000..fe1ea18 --- /dev/null +++ b/src/Normalizer/EntityConditionNormalizer.php @@ -0,0 +1,114 @@ +]. + * + * @var string + */ + const PATH_KEY = 'path'; + + /** + * The value key in the filter condition: filter[lorem][condition][]. + * + * @var string + */ + const VALUE_KEY = 'value'; + + /** + * The operator key in the condition: filter[lorem][condition][]. + * + * @var string + */ + const OPERATOR_KEY = 'operator'; + + /** + * {@inheritdoc} + */ + protected $supportedInterfaceOrClass = EntityCondition::class; + + /** + * {@inheritdoc} + */ + protected $formats = ['api_json']; + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) { + return $type === $this->supportedInterfaceOrClass; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + $this->validate($data); + $field = $data[static::PATH_KEY]; + $value = (isset($data[static::VALUE_KEY])) ? $data[static::VALUE_KEY] : NULL; + $operator = (isset($data[static::OPERATOR_KEY])) ? $data[static::OPERATOR_KEY] : NULL; + return new EntityCondition($field, $value, $operator); + } + + /** + * Validates the filter has the required fields. + */ + protected function validate($data) { + $valid_key_combinations = [ + [static::PATH_KEY, static::VALUE_KEY], + [static::PATH_KEY, static::OPERATOR_KEY], + [static::PATH_KEY, static::VALUE_KEY, static::OPERATOR_KEY], + ]; + + $given_keys = array_keys($data); + $valid_key_set = array_reduce($valid_key_combinations, function ($valid, $set) use ($given_keys) { + return ($valid) ? $valid : count(array_diff($set, $given_keys)) === 0; + }, FALSE); + + $has_operator_key = isset($data[static::OPERATOR_KEY]); + $has_path_key = isset($data[static::PATH_KEY]); + $has_value_key = isset($data[static::VALUE_KEY]); + + if (!$valid_key_set) { + // Try to provide a more specific exception is a key is missing. + if (!$has_operator_key) { + if (!$has_path_key) { + throw new BadRequestHttpException("Filter parameter is missing a '" . static::PATH_KEY . "' key."); + } + if (!$has_value_key) { + throw new BadRequestHttpException("Filter parameter is missing a '" . static::VALUE_KEY . "' key."); + } + } + + // Catchall exception. + $reason = "You must provide a valid filter condition. Check that you have set the required keys for your filter."; + throw new BadRequestHttpException($reason); + } + + if ($has_operator_key) { + $operator = $data[static::OPERATOR_KEY]; + if (!in_array($operator, EntityCondition::$allowedOperators)) { + $reason = "The '" . $operator . "' operator is not allowed in a filter parameter."; + throw new BadRequestHttpException($reason); + } + + if (in_array($operator, ['IS NULL', 'IS NOT NULL']) && $has_value_key) { + $reason = "Filters using the '" . $operator . "' operator should not provide a value."; + throw new BadRequestHttpException($reason); + } + } + } + +} diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php new file mode 100644 index 0000000..a9d49fc --- /dev/null +++ b/src/Normalizer/FilterNormalizer.php @@ -0,0 +1,247 @@ +] parameter for conditions. + * + * @var string + */ + const CONDITION_KEY = 'condition'; + + /** + * Key in the filter[] parameter for groups. + * + * @var string + */ + const GROUP_KEY = 'group'; + + /** + * Key in the filter[][] parameter for group membership. + * + * @var string + */ + const MEMBER_KEY = 'memberOf'; + + /** + * The interface or class that this Normalizer supports. + * + * @var string + */ + protected $supportedInterfaceOrClass = Filter::class; + + /** + * {@inheritdoc} + */ + protected $formats = ['api_json']; + + /** + * The entity condition denormalizer. + * + * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface + */ + protected $conditionDenormalizer; + + /** + * The entity condition group denormalizer. + * + * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface + */ + protected $groupDenormalizer; + + /** + * The field resolver service. + * + * @var \Drupal\jsonapi\Context\FieldResolver + */ + protected $fieldResolver; + + /** + * {@inheritdoc} + */ + public function __construct(FieldResolver $field_resolver, DenormalizerInterface $condition_denormalizer, DenormalizerInterface $group_denormalizer) { + $this->fieldResolver = $field_resolver; + $this->conditionDenormalizer = $condition_denormalizer; + $this->groupDenormalizer = $group_denormalizer; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) { + return $type === $this->supportedInterfaceOrClass; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + $expanded = $this->expand($data, $context); + $denormalized = $this->denormalizeItems($expanded); + return new Filter($denormalized); + } + + /** + * Expands any filter parameters using shorthand notation. + * + * @param array $original + * The unexpanded filter data. + * @param array $context + * The denormalization context. + * + * @return array + * The expanded filter data. + */ + protected function expand(array $original, array $context) { + $expanded = []; + foreach ($original as $key => $item) { + // Throw an exception if the query uses the reserved filter id for the + // root group. + if ($key == static::ROOT_ID) { + $msg = sprintf("'%s' is a reserved filter id.", static::ROOT_ID); + throw new \UnexpectedValueException($msg); + } + + // Add a memberOf key to all items. + if (isset($item[static::CONDITION_KEY][static::MEMBER_KEY])) { + $item[static::MEMBER_KEY] = $item[static::CONDITION_KEY][static::MEMBER_KEY]; + unset($item[static::CONDITION_KEY][static::MEMBER_KEY]); + } + else if (isset($item[static::GROUP_KEY][static::MEMBER_KEY])) { + $item[static::MEMBER_KEY] = $item[static::GROUP_KEY][static::MEMBER_KEY]; + unset($item[static::GROUP_KEY][static::MEMBER_KEY]); + } + else { + $item[static::MEMBER_KEY] = static::ROOT_ID; + } + + // Add the filter id to all items. + $item['id'] = $key; + + // Expands shorthand filters. + $expanded[$key] = $this->expandItem($key, $item, $context); + } + + return $expanded; + } + + /** + * Expands a filter item in case a shortcut was used. + * + * Possible cases for the conditions: + * 1. filter[uuid][value]=1234. + * 2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234. + * 3. filter[uuid][condition][value]=1234. + * 4. filter[uuid][value]=1234&filter[uuid][group]=my_group. + * + * @param string $filter_index + * The index. + * @param array $filter_item + * The raw filter item. + * @param array $context + * The denormalization context. + * + * @return array + * The expanded filter item. + */ + protected function expandItem($filter_index, array $filter_item, array $context) { + if (isset($filter_item[EntityConditionNormalizer::VALUE_KEY])) { + if (!isset($filter_item[EntityConditionNormalizer::PATH_KEY])) { + $filter_item[EntityConditionNormalizer::PATH_KEY] = $filter_index; + } + + $filter_item = [ + static::CONDITION_KEY => $filter_item, + static::MEMBER_KEY => $filter_item[static::MEMBER_KEY], + ]; + } + + if (!isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY])) { + $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY] = '='; + } + + if (isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY])) { + $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY] = $this->fieldResolver->resolveInternal( + $context['entity_type_id'], + $context['bundle'], + $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY] + ); + } + + return $filter_item; + } + + /** + * Denormalizes the given filter items into a single EntityConditionGroup. + * + * @param array $items + * The normalized entity conditions and groups. + * + * @return \Drupal\jsonapi\Query\EntityConditionGroup + * A root group containing all the denormalized conditions and groups. + */ + protected function denormalizeItems(array $items) { + $root = [ + 'id' => static::ROOT_ID, + static::GROUP_KEY => ['conjunction' => 'AND'], + ]; + return $this->buildTree($root, $items); + } + + /** + * Organizes the flat, normalized filter items into a tree structure. + * + * @param array $items + * The normalized entity conditions and groups. + * + * @return \Drupal\jsonapi\Query\EntityConditionGroup + * The entity condition group + */ + protected function buildTree(array $root, $items) { + $id = $root['id']; + + // Recursively build a tree of denormalized conditions and condition groups. + $members = []; + foreach ($items as $item) { + if ($item[static::MEMBER_KEY] == $id) { + if (isset($item[static::GROUP_KEY])) { + array_push($members, $this->buildTree($item, $items)); + } + else if (isset($item[static::CONDITION_KEY])) { + $condition = $this->conditionDenormalizer->denormalize( + $item[static::CONDITION_KEY], + EntityCondition::class + ); + array_push($members, $condition); + } + } + } + + $root[static::GROUP_KEY]['members'] = $members; + + // Denormalize the root into a condition group. + return $this->groupDenormalizer->denormalize( + $root[static::GROUP_KEY], + EntityConditionGroup::class + ); + } + +} diff --git a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php index 00fa121..221ad0c 100644 --- a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php +++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php @@ -245,7 +245,7 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm // Translate ALL the includes from the public field names to the internal. $includes = array_filter(explode(',', $request->query->get('include'))); $public_includes = array_map(function ($include_str) use ($resource_type) { - $resolved = $this->fieldResolver->resolveInternal($include_str); + $resolved = $this->fieldResolver->resolveInternal($resource_type->getEntityTypeId(), $resource_type->getBundle(), $include_str); // We don't need the entity information for the includes. Clean it. return preg_replace('/\.entity\./', '.', $resolved); }, $includes); diff --git a/src/Normalizer/OffsetPageNormalizer.php b/src/Normalizer/OffsetPageNormalizer.php new file mode 100644 index 0000000..373d439 --- /dev/null +++ b/src/Normalizer/OffsetPageNormalizer.php @@ -0,0 +1,58 @@ +supportedInterfaceOrClass; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + $expanded = $this->expand($data); + return new OffsetPage($expanded[OffsetPage::OFFSET_KEY], $expanded[OffsetPage::SIZE_KEY]); + } + + /** + * {@inheritdoc} + */ + protected function expand($data) { + if (!is_array($data)) { + throw new BadRequestHttpException('The page parameter needs to be an array.'); + } + + $expanded = $data + [ + OffsetPage::OFFSET_KEY => OffsetPage::DEFAULT_OFFSET, + OffsetPage::SIZE_KEY => OffsetPage::SIZE_MAX, + ]; + + if ($expanded[OffsetPage::SIZE_KEY] > OffsetPage::SIZE_MAX) { + $expanded[OffsetPage::SIZE_KEY] = OffsetPage::SIZE_MAX; + } + + return $expanded; + } + +} diff --git a/src/Routing/Param/Sort.php b/src/Normalizer/SortNormalizer.php similarity index 65% rename from src/Routing/Param/Sort.php rename to src/Normalizer/SortNormalizer.php index eee57d9..edc3743 100644 --- a/src/Routing/Param/Sort.php +++ b/src/Normalizer/SortNormalizer.php @@ -1,52 +1,44 @@ ]. - * - * @var string - */ - const FIELD_KEY = 'path'; - - /** - * The direction key in the sort parameter: sort[lorem][]. + * The interface or class that this Normalizer supports. * * @var string */ - const DIRECTION_KEY = 'direction'; + protected $supportedInterfaceOrClass = Sort::class; /** - * The langcode key in the sort parameter: sort[lorem][]. - * - * @var string + * {@inheritdoc} */ - const LANGUAGE_KEY = 'langcode'; + public function supportsDenormalization($data, $type, $format = null) { + return $type == $this->supportedInterfaceOrClass; + } /** - * The conjunction key in the condition: filter[lorem][group][]. - * - * @var string + * {@inheritdoc} */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + $expanded = $this->expand($data); + return new Sort($expanded); + } /** * {@inheritdoc} */ - protected function expand() { - $sort = $this->original; - + protected function expand($sort) { if (empty($sort)) { throw new BadRequestHttpException('You need to provide a value for the sort parameter.'); } @@ -79,12 +71,12 @@ class Sort extends JsonApiParamBase { $sort = []; if ($field[0] == '-') { - $sort[static::DIRECTION_KEY] = 'DESC'; - $sort[static::FIELD_KEY] = substr($field, 1); + $sort[Sort::DIRECTION_KEY] = 'DESC'; + $sort[Sort::PATH_KEY] = substr($field, 1); } else { - $sort[static::DIRECTION_KEY] = 'ASC'; - $sort[static::FIELD_KEY] = $field; + $sort[Sort::DIRECTION_KEY] = 'ASC'; + $sort[Sort::PATH_KEY] = $field; } return $sort; @@ -104,18 +96,18 @@ class Sort extends JsonApiParamBase { */ protected function expandItem($sort_index, array $sort_item) { $defaults = [ - static::DIRECTION_KEY => 'ASC', - static::LANGUAGE_KEY => NULL, + Sort::DIRECTION_KEY => 'ASC', + Sort::LANGUAGE_KEY => NULL, ]; - if (!isset($sort_item[static::FIELD_KEY])) { + if (!isset($sort_item[Sort::PATH_KEY])) { throw new BadRequestHttpException('You need to provide a field name for the sort parameter.'); } $expected_keys = [ - static::FIELD_KEY, - static::DIRECTION_KEY, - static::LANGUAGE_KEY, + Sort::PATH_KEY, + Sort::DIRECTION_KEY, + Sort::LANGUAGE_KEY, ]; $expanded = array_merge($defaults, $sort_item); diff --git a/src/Query/ConditionOption.php b/src/Query/ConditionOption.php deleted file mode 100644 index 05e2d32..0000000 --- a/src/Query/ConditionOption.php +++ /dev/null @@ -1,101 +0,0 @@ -id = $id; - $this->field = $field; - $this->value = $value; - $this->operator = $operator; - $this->langcode = $langcode; - $this->parentId = $parent_id; - } - - /** - * {@inheritdoc} - */ - public function id() { - return $this->id; - } - - /** - * {@inheritdoc} - */ - public function apply($query) { - return $query->condition($this->field, $this->value, $this->operator, $this->langcode); - } - - /** - * Returns the id of this option's parent. - * - * @return string|null - * Either the id of its parent or NULL. - */ - public function parentId() { - return $this->parentId; - } - -} diff --git a/src/Query/EntityCondition.php b/src/Query/EntityCondition.php new file mode 100644 index 0000000..88e59a4 --- /dev/null +++ b/src/Query/EntityCondition.php @@ -0,0 +1,87 @@ +', + '>', '>=', '<', '<=', + 'STARTS_WITH', 'CONTAINS', 'ENDS_WITH', + 'IN', 'NOT IN', + 'BETWEEN', 'NOT BETWEEN', + 'IS NULL', 'IS NOT NULL', + ]; + + /** + * The field to be evaluated. + * + * @var string + */ + protected $field; + + /** + * The condition operator. + * + * @var string + */ + protected $operator; + + /** + * The value against which the field should be evaluated. + * + * @var mixed + */ + protected $value; + + /** + * Constructs a new EntityCondition object. + */ + public function __construct($field, $value, $operator = NULL) { + $this->field = $field; + $this->value = $value; + $this->operator = ($operator) ? $operator : '='; + } + + /** + * The field to be evaluated. + * + * @return string + */ + public function field() { + return $this->field; + } + + /** + * The comparison operator to use for the evaluation. + * + * For a list of allowed operators: + * + * @see \Drupal\jsonapi\Query\EntityCondition::allowedOperators + * + * @return string + */ + public function operator() { + return $this->operator; + } + + /** + * The value against which the condition should be evaluated. + * + * @return mixed + */ + public function value() { + return $this->value; + } + +} diff --git a/src/Query/EntityConditionGroup.php b/src/Query/EntityConditionGroup.php new file mode 100644 index 0000000..e7a2db2 --- /dev/null +++ b/src/Query/EntityConditionGroup.php @@ -0,0 +1,65 @@ +conjunction = $conjunction; + $this->members = $members; + } + + /** + * The condition group conjunction. + * + * @return string + */ + public function conjunction() { + return $this->conjunction; + } + + /** + * The members which belong to the the condition group. + * + * @return \Drupal\jsonapi\Query\EntityCondition[] + */ + public function members() { + return $this->members; + } + +} diff --git a/src/Query/Filter.php b/src/Query/Filter.php new file mode 100644 index 0000000..925862e --- /dev/null +++ b/src/Query/Filter.php @@ -0,0 +1,109 @@ +root = $root; + } + + /** + * Gets the root condition group. + */ + public function root() { + return $this->root; + } + + /** + * Applies the root condition to the given query. + * + * @param \Drupal\Entity\Query\QueryInterface $query + * The query for which the condition should be constructed. + * + * @return \Drupal\Entity\Query\ConditionInterface + * The compiled entity query condition. + */ + public function queryCondition(QueryInterface $query) { + $condition = $this->buildGroup($query, $this->root()); + return $condition; + } + + /** + * Applies the root condition to the given query. + * + * @param \Drupal\Entity\Query\QueryInterface $query + * The query to which the filter should be applied. + * + * @return \Drupal\Entity\Query\QueryInterface + * The query with the filter applied. + */ + protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) { + // Create a condition group using the original query. + switch ($condition_group->conjunction()) { + case 'AND': + $group = $query->andConditionGroup(); + break; + case 'OR': + $group = $query->orConditionGroup(); + break; + } + + // Get all children of the group. + $members = $condition_group->members(); + + foreach ($members as $member) { + // If the child is simply a condition, add it to the new group. + if ($member instanceof EntityCondition) { + if ($member->operator() == 'IS NULL') { + $group->notExists($member->field()); + } + else if ($member->operator() == 'IS NOT NULL') { + $group->exists($member->field()); + } + else { + $group->condition($member->field(), $member->value(), $member->operator()); + } + } + // If the child is a group, then recursively construct a sub group. + else if ($member instanceof EntityConditionGroup) { + // Add the subgroup to this new group. + $subgroup = $this->buildGroup($query, $member); + $group->condition($subgroup); + } + } + + // Return the constructed group so that it can be added to the query. + return $group; + } + +} diff --git a/src/Query/GroupOption.php b/src/Query/GroupOption.php deleted file mode 100644 index d51a20c..0000000 --- a/src/Query/GroupOption.php +++ /dev/null @@ -1,153 +0,0 @@ -id = $id; - $this->conjunction = $conjunction; - $this->parentGroup = $parent_group; - } - - /** - * {@inheritdoc} - */ - public function id() { - return $this->id; - } - - /** - * {@inheritdoc} - */ - public function parentId() { - return $this->parentGroup; - } - - /** - * {@inheritdoc} - */ - public function insert($target_id, QueryOptionInterface $option) { - $find_proper_id = function ($child_id, $group_option) use ($target_id) { - if ($child_id) { - return $child_id; - }; - return $group_option->hasChild($target_id) ? - $group_option->id() : - NULL; - }; - - if ($this->id() == $target_id) { - $prop = $option instanceof GroupOption ? 'childGroups' : 'childOptions'; - $this->{$prop}[$option->id()] = $option; - return TRUE; - } - elseif ($proper_child = array_reduce($this->childGroups, $find_proper_id, NULL)) { - return $this->childGroups[$proper_child]->insert($target_id, $option); - } - - return FALSE; - } - - /** - * {@inheritdoc} - */ - public function apply($query) { - switch ($this->conjunction) { - case 'OR': - $group = $query->orConditionGroup(); - break; - - case 'AND': - default: - $group = $query->andConditionGroup(); - break; - } - - if (!empty($this->childOptions)) { - $group = array_reduce($this->childOptions, function ($group, $child) { - return $child->apply($group); - }, $group); - } - - if (!empty($this->childGroups)) { - $group = array_reduce($this->childGroups, function ($group, $child) { - return $child->apply($group); - }, $group); - } - - return $query->condition($group); - } - - /** - * {@inheritdoc} - */ - public function hasChild($id) { - // Return FALSE if this node has no child. - if (!isset($this->childOptions) || empty($this->childOptions)) { - return FALSE; - } - - // If any of the options have the specified id, return TRUE. - if (in_array($id, array_keys($this->childOptions))) { - return TRUE; - } - - // If any child GroupOptions or their children have the id return TRUE. - return array_reduce($this->groupOptions, function ($has_child, $group) use ($id) { - // If we already know that we have the child, skip evaluation and return. - return $has_child || $group->id() == $id || $group->hasChild($id); - }, FALSE); - } - -} diff --git a/src/Query/OffsetPage.php b/src/Query/OffsetPage.php new file mode 100644 index 0000000..97a2e2f --- /dev/null +++ b/src/Query/OffsetPage.php @@ -0,0 +1,92 @@ +offset = $offset; + $this->size = $size; + } + + /** + * Returns the current offset. + * + * @return int + */ + public function offset() { + return $this->offset; + } + + /** + * Returns the page size. + * + * @return int + */ + public function size() { + return $this->size; + } + +} diff --git a/src/Query/OffsetPagerOption.php b/src/Query/OffsetPagerOption.php deleted file mode 100644 index 9e08cf3..0000000 --- a/src/Query/OffsetPagerOption.php +++ /dev/null @@ -1,57 +0,0 @@ -size = $size; - $this->offset = $offset ?: 0; - } - - /** - * {@inheritdoc} - */ - public function id() { - return 'offset_pager'; - } - - /** - * {@inheritdoc} - */ - public function apply($query) { - if (isset($this->offset) && isset($this->size)) { - // Request one extra entity to know if there is a next page. - $query->range($this->offset, $this->size + 1); - $query->addMetaData('pager_size', (int) $this->size); - } - - return $query; - } - -} diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php deleted file mode 100644 index 6a01b36..0000000 --- a/src/Query/QueryBuilder.php +++ /dev/null @@ -1,362 +0,0 @@ -entityTypeManager = $entity_type_manager; - $this->currentContext = $current_context; - $this->fieldResolver = $field_resolver; - } - - /** - * Creates a new Entity Query. - * - * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type - * The entity type for which to create a query. - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params - * The JSON API parameters. - * - * @return \Drupal\Core\Entity\Query\QueryInterface - * The new query. - */ - public function newQuery(EntityTypeInterface $entity_type, array $params = []) { - $this->entityType = $entity_type; - $this->options = []; - $this->configureFromContext($params); - - $query = $this->entityTypeManager - ->getStorage($this->entityType->id()) - ->getQuery() - ->accessCheck(TRUE); - - // This applies each option from the option tree to the query before - // returning it. - $applied_query = array_reduce($this->options, function ($query, $option) { - /* @var \Drupal\jsonapi\Query\QueryOptionInterface $option */ - return $option->apply($query); - }, $query); - - return $applied_query ? $applied_query : $query; - } - - /** - * Configure the query from the current context and the provided parameters. - * - * To avoid using the global context so much use the passed in parameters - * over the ones in the current context. - * - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params - * The JSON API parameters. - */ - protected function configureFromContext(array $params = []) { - // TODO: Explore the possibility to turn JsonApiParam into a plugin type. - $param_keys = [Filter::KEY_NAME, Sort::KEY_NAME]; - foreach ($param_keys as $param_key) { - if (isset($params[$param_key])) { - $this->configureParam($param_key, $params[$param_key]); - } - elseif ($param = $this->currentContext->getJsonApiParameter($param_key)) { - $this->configureParam($param_key, $param); - } - } - // We always add a default pagination parameter. - $pager = isset($params[OffsetPage::KEY_NAME]) ? - $params[OffsetPage::KEY_NAME] : - new OffsetPage([]); - $this->configureParam(OffsetPage::KEY_NAME, $pager); - } - - /** - * Configure a parameter based on the type parameter type. - * - * @param string $type - * The parameter type. - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param - * The parameter to configure. - */ - protected function configureParam($type, JsonApiParamInterface $param) { - switch ($type) { - case Filter::KEY_NAME: - $this->configureFilter($param); - break; - - case Sort::KEY_NAME: - $this->configureSort($param); - break; - - case OffsetPage::KEY_NAME: - $this->configurePager($param); - break; - } - } - - /** - * Configures the query builder from a Filter parameter. - * - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param - * A Filter parameter from which to configure this query builder. - * - * @todo The nested closures passing parameters by reference may not be ideal. - */ - protected function configureFilter(JsonApiParamInterface $param) { - $extracted = []; - - foreach ($param->get() as $filter_index => $filter) { - foreach ($filter as $filter_type => $properties) { - switch ($filter_type) { - case Filter::CONDITION_KEY: - $extracted[] = $this->newCondtionOption($filter_index, $properties); - break; - - case Filter::GROUP_KEY: - $extracted[] = $this->newGroupOption($filter_index, $properties); - break; - - default: - throw new BadRequestHttpException( - sprintf('Invalid syntax in the filter parameter: %s.', $filter_index) - ); - }; - } - } - - $this->buildTree($extracted); - } - - /** - * Configures the query builder from a Sort parameter. - * - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param - * A Sort parameter from which to configure this query builder. - */ - protected function configureSort(JsonApiParamInterface $param) { - $extracted = []; - foreach ($param->get() as $sort_index => $sort) { - $extracted[] = $this->newSortOption(sprintf('sort_%s', $sort_index), $sort); - } - - $this->buildTree($extracted); - } - - /** - * Configures the query builder from a Pager parameter. - * - * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param - * A pager parameter from which to configure this query builder. - */ - protected function configurePager(JsonApiParamInterface $param) { - $this->buildTree([$this->newPagerOption($param->get())]); - } - - /** - * Returns a new ConditionOption. - * - * @param string $condition_id - * A unique id for the option. - * @param array $properties - * The condition properties. - * - * @return \Drupal\jsonapi\Query\ConditionOption - * The condition object. - */ - protected function newCondtionOption($condition_id, array $properties) { - $langcode_key = $this->getLangcodeKey(); - $langcode = isset($properties[$langcode_key]) ? $properties[$langcode_key] : NULL; - $membership = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL; - $field = isset($properties[Filter::PATH_KEY]) ? $properties[Filter::PATH_KEY] : NULL; - $value = isset($properties[Filter::VALUE_KEY]) ? $properties[Filter::VALUE_KEY] : NULL; - $operator = isset($properties[Filter::OPERATOR_KEY]) ? $properties[Filter::OPERATOR_KEY] : NULL; - return new ConditionOption( - $condition_id, - $this->fieldResolver->resolveInternal($field), - $value, - $operator, - $langcode, - $membership - ); - } - - /** - * Returns a new GroupOption. - * - * @param string $identifier - * A unique id for the option. - * @param array $properties - * The group properties. - * - * @return \Drupal\jsonapi\Query\GroupOption - * The group object. - */ - protected function newGroupOption($identifier, array $properties) { - $parent_group = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL; - $conjunction = isset($properties[Filter::CONJUNCTION_KEY]) ? $properties[Filter::CONJUNCTION_KEY] : NULL; - return new GroupOption($identifier, $conjunction, $parent_group); - } - - /** - * Returns a new SortOption. - * - * @param string $identifier - * A unique id for the option. - * @param array $properties - * The sort properties. - * - * @return \Drupal\jsonapi\Query\SortOption - * The sort object. - */ - protected function newSortOption($identifier, array $properties) { - $field = isset($properties[Sort::FIELD_KEY]) ? $properties[Sort::FIELD_KEY] : NULL; - $direction = isset($properties[Sort::DIRECTION_KEY]) ? $properties[Sort::DIRECTION_KEY] : NULL; - $langcode = isset($properties[Sort::LANGUAGE_KEY]) ? $properties[Sort::LANGUAGE_KEY] : NULL; - return new SortOption( - $identifier, - $this->fieldResolver->resolveInternal($field), - $direction, - $langcode - ); - } - - /** - * Returns a new SortOption. - * - * @param array $properties - * The pager properties. - * - * @return \Drupal\jsonapi\Query\OffsetPagerOption - * The sort object. - */ - protected function newPagerOption(array $properties) { - // Add defaults to avoid unset warnings. - $properties += [ - 'limit' => NULL, - 'offset' => 0, - ]; - return new OffsetPagerOption($properties['limit'], $properties['offset']); - } - - /** - * Builds a tree of QueryOptions. - * - * @param \Drupal\jsonapi\Query\QueryOptionInterface[] $options - * An array of QueryOptions. - */ - protected function buildTree(array $options) { - $remaining = $options; - while (!empty($remaining)) { - $insert = array_pop($remaining); - if (method_exists($insert, 'parentId') && $parent_id = $insert->parentId()) { - if (!$this->insert($parent_id, $insert)) { - array_unshift($remaining, $insert); - } - } - else { - $this->options[$insert->id()] = $insert; - } - } - } - - /** - * Inserts a QueryOption into the appropriate child QueryOption. - * - * @param string $target_id - * Unique ID of the intended QueryOption parent. - * @param \Drupal\jsonapi\Query\QueryOptionInterface $option - * The QueryOption to insert. - * - * @return bool - * Whether the option could be inserted or not. - */ - protected function insert($target_id, QueryOptionInterface $option) { - if (!empty($this->options)) { - $find_target_child = function ($child, QueryOptionInterface $my_option) use ($target_id) { - if ($child) { - return $child; - } - if ( - $my_option->id() == $target_id || - (method_exists($my_option, 'hasChild') && $my_option->hasChild($target_id)) - ) { - return $my_option->id(); - } - return FALSE; - }; - - if ($appropriate_child = array_reduce($this->options, $find_target_child, NULL)) { - return $this->options[$appropriate_child]->insert($target_id, $option); - } - } - - return FALSE; - } - - /** - * Get the language code key. - * - * @return string - * The key. - */ - protected function getLangcodeKey() { - $entity_type_id = $this->currentContext->getResourceType() - ->getEntityTypeId(); - return $this->entityTypeManager - ->getDefinition($entity_type_id) - ->getKey('langcode'); - } - -} diff --git a/src/Query/QueryOptionInterface.php b/src/Query/QueryOptionInterface.php deleted file mode 100644 index 91110f0..0000000 --- a/src/Query/QueryOptionInterface.php +++ /dev/null @@ -1,29 +0,0 @@ -]. + * + * @var string + */ + const PATH_KEY = 'path'; + + /** + * The direction key in the sort parameter: sort[lorem][]. + * + * @var string + */ + const DIRECTION_KEY = 'direction'; + + /** + * The langcode key in the sort parameter: sort[lorem][]. + * + * @var string + */ + const LANGUAGE_KEY = 'langcode'; + + /** + * The fields on which to sort. + * + * @var string + */ + protected $fields; + + /** + * Constructs a new Sort object. + * + * Takes an array of sort fields. Example: + * [ + * [ + * 'path' => 'changed', + * 'direction' => 'DESC', + * ], + * [ + * 'path' => 'title', + * 'direction' => 'ASC', + * 'langcode' => 'en-US', + * ], + * ] + * + * @param array $fields + * The the entity query sort fields. + */ + public function __construct(array $fields) { + $this->fields = $fields; + } + + /** + * Gets the root condition group. + */ + public function fields() { + return $this->fields; + } + +} diff --git a/src/Query/SortOption.php b/src/Query/SortOption.php deleted file mode 100644 index 54901f8..0000000 --- a/src/Query/SortOption.php +++ /dev/null @@ -1,71 +0,0 @@ -id = $id; - $this->field = $field; - $this->direction = $direction; - $this->langcode = $langcode; - } - - /** - * {@inheritdoc} - */ - public function id() { - return $this->id; - } - - /** - * {@inheritdoc} - */ - public function apply($query) { - return $query->sort($this->field, $this->direction); - } - -} diff --git a/src/Routing/JsonApiParamEnhancer.php b/src/Routing/JsonApiParamEnhancer.php index b0274d9..c43bfbb 100644 --- a/src/Routing/JsonApiParamEnhancer.php +++ b/src/Routing/JsonApiParamEnhancer.php @@ -2,14 +2,14 @@ namespace Drupal\jsonapi\Routing; -use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Routing\Enhancer\RouteEnhancerInterface; -use Drupal\jsonapi\Routing\Param\OffsetPage; -use Drupal\jsonapi\Routing\Param\Filter; -use Drupal\jsonapi\Routing\Param\Sort; +use Drupal\jsonapi\Query\OffsetPage; +use Drupal\jsonapi\Query\Filter; +use Drupal\jsonapi\Query\Sort; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Route; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** * @internal @@ -17,20 +17,33 @@ use Symfony\Component\Routing\Route; class JsonApiParamEnhancer implements RouteEnhancerInterface { /** - * The field manager. + * The filter normalizer. * - * @var \Drupal\Core\Entity\EntityFieldManagerInterface + * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface */ - protected $fieldManager; + protected $filterNormalizer; /** - * Instantiates a JsonApiParamEnhancer object. + * The sort normalizer. * - * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager - * The field manager. + * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface */ - public function __construct(EntityFieldManagerInterface $field_manager) { - $this->fieldManager = $field_manager; + protected $sortNormalizer; + + /** + * The page normalizer. + * + * @var Symfony\Component\Serializer\Normalizer\DenormalizerInterface + */ + protected $pageNormalizer; + + /** + * {@inheritdoc} + */ + public function __construct(DenormalizerInterface $filter_normalizer, DenormalizerInterface $sort_normalizer, DenormalizerInterface $page_normalizer) { + $this->filterNormalizer = $filter_normalizer; + $this->sortNormalizer = $sort_normalizer; + $this->pageNormalizer = $page_normalizer; } /** @@ -46,20 +59,28 @@ class JsonApiParamEnhancer implements RouteEnhancerInterface { */ public function enhance(array $defaults, Request $request) { $options = []; + + $route = $defaults[RouteObjectInterface::ROUTE_OBJECT]; + $context = [ + 'entity_type_id' => $route->getRequirement('_entity_type'), + 'bundle' => $route->getRequirement('_bundle'), + ]; + if ($request->query->has('filter')) { - $entity_type_id = $defaults[RouteObjectInterface::ROUTE_OBJECT]->getRequirement('_entity_type'); - $options['filter'] = new Filter($request->query->get('filter'), $entity_type_id, $this->fieldManager); + $filter = $request->query->get('filter'); + $options['filter'] = $this->filterNormalizer->denormalize($filter, Filter::class, NULL, $context); } + if ($request->query->has('sort')) { - $options['sort'] = new Sort($request->query->get('sort')); - } - if ($request->query->has('page')) { - $options['page'] = new OffsetPage($request->query->get('page'), OffsetPage::$maxSize); - } - else { - $options['page'] = new OffsetPage(['start' => 0, 'limit' => OffsetPage::$maxSize], OffsetPage::$maxSize); + $sort = $request->query->get('sort'); + $options['sort'] = $this->sortNormalizer->denormalize($sort, Sort::class); } + + $page = ($request->query->has('page')) ? $request->query->get('page') : []; + $options['page'] = $this->pageNormalizer->denormalize($page, OffsetPage::class); + $defaults['_json_api_params'] = $options; + return $defaults; } diff --git a/src/Routing/Param/Filter.php b/src/Routing/Param/Filter.php deleted file mode 100644 index 1fd14dd..0000000 --- a/src/Routing/Param/Filter.php +++ /dev/null @@ -1,310 +0,0 @@ -] parameter for conditions. - * - * @var string - */ - const CONDITION_KEY = 'condition'; - - /** - * Key in the filter[] parameter for groups. - * - * @var string - */ - const GROUP_KEY = 'group'; - - /** - * Key in the filter[][] parameter for group membership. - * - * @var string - */ - const MEMBER_KEY = 'memberOf'; - - /** - * The field key in the filter condition: filter[lorem][condition][]. - * - * @var string - */ - const PATH_KEY = 'path'; - - /** - * The value key in the filter condition: filter[lorem][condition][]. - * - * @var string - */ - const VALUE_KEY = 'value'; - - /** - * The operator key in the condition: filter[lorem][condition][]. - * - * @var string - */ - const OPERATOR_KEY = 'operator'; - - /** - * The conjunction key in the condition: filter[lorem][group][]. - * - * @var string - */ - const CONJUNCTION_KEY = 'conjunction'; - - /** - * {@inheritdoc} - */ - protected function expand() { - // We should always get an array for the filter. - if (!is_array($this->original)) { - throw new BadRequestHttpException('Incorrect value passed to the filter parameter.'); - } - - $expanded = []; - foreach ($this->original as $filter_index => $filter_item) { - $expanded_item = $this->expandItem($filter_index, $filter_item); - $this->validateItem($filter_index, $expanded_item); - $expanded[$filter_index] = $expanded_item; - } - return $expanded; - } - - /** - * Expands a filter item in case a shortcut was used. - * - * Possible cases for the conditions: - * 1. filter[uuid][value]=1234. - * 2. filter[0][condition][path]=uuid&filter[0][condition][value]=1234. - * 3. filter[uuid][value]=1234&filter[uuid][path]=uuid& - * filter[uuid][operator]==&filter[uuid][memberOf]=my_group. - * - * @param string $filter_index - * The index. - * @param mixed $filter_item - * The raw filter item. - * - * @return array - * The expanded filter item. - */ - protected function expandItem($filter_index, $filter_item) { - // Expand shorthand. - if (isset($filter_item[static::VALUE_KEY])) { - if (!isset($filter_item[static::PATH_KEY])) { - $filter_item[static::PATH_KEY] = $filter_index; - } - $filter_item = [ - static::CONDITION_KEY => $filter_item, - ]; - - if (!isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY])) { - $filter_item[static::CONDITION_KEY][static::OPERATOR_KEY] = '='; - } - } - // Expand IS (NOT) NULL items. - elseif ( - isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY]) && - in_array($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY], ['IS NULL', 'IS NOT NULL']) - ) { - // This is not strictly necessary, but it simplifies validation. - $filter_item[static::CONDITION_KEY][static::VALUE_KEY] = NULL; - } - - return $filter_item; - } - - /** - * Makes sure every filter item contains valid data. - * - * @param string $filter_index - * The index. - * @param mixed $filter_item - * The expanded filter item. - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the filter is malformed. - */ - protected function validateItem($filter_index, $filter_item) { - // Make sure the current filter item is an array. So far we don't allow - // filter queries like filter[nid]=1. - if (!is_array($filter_item)) { - $message = new FormattableMarkup( - 'Filter query for "@index" must be array.', - ['@index' => $filter_index] - ); - throw new BadRequestHttpException($message); - } - - // We do not allow having both "condition" and "group" for the same filter - // item. So for example this condition should fail: - // - filter[id][condition][path]=nid - // - filter[id][condition][value]=123 - // - filter[id][condition][operator]== - // - filter[id][group][conjunction]=AND. - $filter_keys = array_keys($filter_item); - $allowed_filter_keys = [static::CONDITION_KEY, static::GROUP_KEY]; - if (count($filter_keys) > 1 || !in_array($filter_keys[0], $allowed_filter_keys)) { - $message = new FormattableMarkup( - 'Filter query for "@index" should only contain either "@condkey" or "@groupkey" as a top-level key, but not both.', - [ - '@index' => $filter_index, - '@condkey' => static::CONDITION_KEY, - '@groupkey' => static::GROUP_KEY, - ] - ); - throw new BadRequestHttpException($message); - } - - // Handle full canonical form: - // - filter[id][condition][path]=nid - // - filter[id][condition][value]=123 - // - filter[id][condition][operator]== - // - filter[id][condition][memberOf]=some-group. - if (isset($filter_item[static::CONDITION_KEY])) { - $this->validateConditionItem($filter_index, $filter_item); - } - // Validating filter groups. Example of group: - // filter[and-group][group][conjunction]=AND - // filter[and-group][group][memberOf]=another-group. - elseif (isset($filter_item[static::GROUP_KEY])) { - $this->validateGroupItem($filter_index, $filter_item); - } - - } - - /** - * Makes sure a condition in a filter item contains valid data. - * - * @param string $filter_index - * The index. - * @param mixed $filter_item - * The expanded filter item. - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the filter is malformed. - */ - protected function validateConditionItem($filter_index, $filter_item) { - // List of allowed keys for adding a new condition to the query. - $expected_keys = [ - static::PATH_KEY, - static::VALUE_KEY, - static::OPERATOR_KEY, - static::MEMBER_KEY, - ]; - - // Get keys sent by the client. - $item_keys = array_keys($filter_item[static::CONDITION_KEY]); - - // If the client sent any keys outside of allowed, we should return an - // error to indicate that the desired set of params is not going to work. - $unexpected_keys = array_diff($item_keys, $expected_keys); - if (!empty($unexpected_keys)) { - $message = new FormattableMarkup( - 'Filter query for "@index" contains not expected arguments: @invalid_args. Valid arguments: @valid_args', - [ - '@index' => $filter_index, - '@invalid_args' => implode(',', $unexpected_keys), - '@valid_args' => implode(',', $expected_keys), - ] - ); - throw new BadRequestHttpException($message); - } - - // It is required to set the next keys for full canonical filter - // representation. - $mandatory_keys = [static::PATH_KEY, static::VALUE_KEY]; - - // If any mandatory key is missing - report back to the client. - $missing_mandatory_keys = array_diff($mandatory_keys, $item_keys); - if (!empty($missing_mandatory_keys)) { - $message = new FormattableMarkup( - 'Filter query for "@index" missing mandatory params: @missing_params.', - [ - '@index' => $filter_index, - '@missing_params' => implode(',', $missing_mandatory_keys), - ] - ); - throw new BadRequestHttpException($message); - } - } - - /** - * Makes sure a condition in a filter item contains valid data. - * - * @param string $filter_index - * The index. - * @param mixed $filter_item - * The expanded filter item. - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the filter is malformed. - */ - protected function validateGroupItem($filter_index, $filter_item) { - // List of allowed keys for adding a new filter group. - $expected_keys = [static::CONJUNCTION_KEY, static::MEMBER_KEY]; - - // If the client sent any keys outside of allowed, we should return an - // error to indicate that the desired set of params is not going to work. - $item_keys = array_keys($filter_item[static::GROUP_KEY]); - $unexpected_keys = array_diff($item_keys, $expected_keys); - if (!empty($unexpected_keys)) { - $message = new FormattableMarkup( - 'Filter query for "@index" contains unexpected arguments: @invalid_args. Valid arguments: @valid_args', - [ - '@index' => $filter_index, - '@invalid_args' => implode(',', $unexpected_keys), - '@valid_args' => implode(',', $expected_keys), - ] - ); - throw new BadRequestHttpException($message); - } - - // If any mandatory key is missing - report back to the client. - $mandatory_keys = [static::CONJUNCTION_KEY]; - $missing_mandatory_keys = array_diff($mandatory_keys, $item_keys); - if (!empty($missing_mandatory_keys)) { - $message = new FormattableMarkup( - 'Filter query for "@index" missing mandatory params: @missing_params.', - [ - '@index' => $filter_index, - '@missing_params' => implode(',', $missing_mandatory_keys), - ] - ); - throw new BadRequestHttpException($message); - } - - // Make sure the conjunction value is correct. - $allowed_conjunction = ['AND', 'OR', 'and', 'or']; - if (!in_array($filter_item[static::GROUP_KEY][static::CONJUNCTION_KEY], $allowed_conjunction)) { - $message = new FormattableMarkup( - 'Filter query for "@index" contains invalid conjunction operator. Allowed values: AND, OR.', - ['@index' => $filter_index] - ); - throw new BadRequestHttpException($message); - } - - // We do not allow having anything other than "group" in the filter query. - // So for example this condition should fail: - // - filter[][group][conjunction]=AND - // - filter[][nid][value]=123. - if (count($filter_item) > 1) { - $message = new FormattableMarkup( - 'Filter query for "@index" should contain only "@key" as a top-level key.', - ['@index' => $filter_index, '@key' => static::GROUP_KEY]); - throw new BadRequestHttpException($message); - } - } - -} diff --git a/src/Routing/Param/JsonApiParamBase.php b/src/Routing/Param/JsonApiParamBase.php deleted file mode 100644 index 21cb503..0000000 --- a/src/Routing/Param/JsonApiParamBase.php +++ /dev/null @@ -1,69 +0,0 @@ -original = $original; - } - - /** - * {@inheritdoc} - */ - public function get() { - if (!$this->data) { - $this->data = $this->expand(); - } - return $this->data; - } - - /** - * {@inheritdoc} - */ - public function getOriginal() { - return $this->original; - } - - /** - * {@inheritdoc} - */ - public function getKey() { - return static::KEY_NAME; - } - - /** - * Apply all necessary defaults and transformations to the parameter. - * - * @return string|string[] - * The expanded data. - */ - protected function expand() { - // The base implementation does no expansion. - return $this->original; - } - -} diff --git a/src/Routing/Param/JsonApiParamInterface.php b/src/Routing/Param/JsonApiParamInterface.php deleted file mode 100644 index 695aa37..0000000 --- a/src/Routing/Param/JsonApiParamInterface.php +++ /dev/null @@ -1,44 +0,0 @@ -original)) { - throw new BadRequestHttpException('The page parameter needs to be an array.'); - } - $output = $this->original + ['limit' => static::$maxSize]; - $output['limit'] = $output['limit'] > static::$maxSize ? - static::$maxSize : - $output['limit']; - return $output; - } - - /** - * Returns the current offset. - * - * @return int - */ - public function getOffset() { - $data = $this->get(); - return isset($data['offset']) ? $data['offset'] : 0; - } - - /** - * Returns the page size. - * - * @return int - */ - public function getSize() { - $data = $this->get(); - $size = isset($data['limit']) ? $data['limit'] : static::$maxSize; - return $size > static::$maxSize ? static::$maxSize : $size; - } - -} diff --git a/tests/src/Functional/JsonApiFunctionalTest.php b/tests/src/Functional/JsonApiFunctionalTest.php index 4236086..a0e38f5 100644 --- a/tests/src/Functional/JsonApiFunctionalTest.php +++ b/tests/src/Functional/JsonApiFunctionalTest.php @@ -4,7 +4,7 @@ namespace Drupal\Tests\jsonapi\Functional; use Drupal\Component\Serialization\Json; use Drupal\Core\Url; -use Drupal\jsonapi\Routing\Param\OffsetPage; +use Drupal\jsonapi\Query\OffsetPage; /** * @group jsonapi @@ -22,7 +22,7 @@ class JsonApiFunctionalTest extends JsonApiFunctionalTestBase { // 1. Load all articles (1st page). $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article')); $this->assertSession()->statusCodeEquals(200); - $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data'])); + $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data'])); $this->assertSession() ->responseHeaderEquals('Content-Type', 'application/vnd.api+json'); // 2. Load all articles (Offset 3). @@ -30,7 +30,7 @@ class JsonApiFunctionalTest extends JsonApiFunctionalTestBase { 'query' => ['page' => ['offset' => 3]], ])); $this->assertSession()->statusCodeEquals(200); - $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data'])); + $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data'])); $this->assertContains('page%5Boffset%5D=53', $collection_output['links']['next']); // 3. Load all articles (1st page, 2 items) $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ diff --git a/tests/src/Kernel/Context/FieldResolverTest.php b/tests/src/Kernel/Context/FieldResolverTest.php index 441119e..a5ba4b3 100644 --- a/tests/src/Kernel/Context/FieldResolverTest.php +++ b/tests/src/Kernel/Context/FieldResolverTest.php @@ -8,6 +8,7 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * @coversDefaultClass \Drupal\jsonapi\Context\FieldResolver @@ -145,30 +146,26 @@ class FieldResolverTest extends JsonapiKernelTestBase { } public function testResolveInternal() { - $request = Request::create('/jsonapi/entity_test_with_bundle/bundle1'); - $route = \Drupal::service('router.route_provider')->getRouteByName('jsonapi.entity_test_with_bundle--bundle1.collection'); - $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'jsonapi.entity_test_with_bundle--bundle1.collection'); - $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route); - - \Drupal::requestStack()->push($request); - - $this->assertEquals('field_test1', $this->sut->resolveInternal('field_test1')); - $this->assertEquals('field_test2', $this->sut->resolveInternal('field_test2')); - $this->assertEquals('field_test3', $this->sut->resolveInternal('field_test3')); - - $this->assertEquals('field_test_ref1.entity.field_test1', $this->sut->resolveInternal('field_test_ref1.field_test1')); - $this->assertEquals('field_test_ref1.entity.field_test2', $this->sut->resolveInternal('field_test_ref1.field_test2')); - $this->assertEquals('field_test_ref2.entity.field_test1', $this->sut->resolveInternal('field_test_ref2.field_test1')); - $this->assertEquals('field_test_ref2.entity.field_test2', $this->sut->resolveInternal('field_test_ref2.field_test2')); - $this->assertEquals('field_test_ref3.entity.field_test1', $this->sut->resolveInternal('field_test_ref3.field_test1')); - $this->assertEquals('field_test_ref3.entity.field_test2', $this->sut->resolveInternal('field_test_ref3.field_test2')); - - $this->assertEquals('field_test_ref1.entity.field_test_text', $this->sut->resolveInternal('field_test_ref1.field_test_text')); - $this->assertEquals('field_test_ref1.entity.field_test_text.value', $this->sut->resolveInternal('field_test_ref1.field_test_text.value')); - $this->assertEquals('field_test_ref1.entity.field_test_text.format', $this->sut->resolveInternal('field_test_ref1.field_test_text.format')); - $this->assertEquals('field_test_ref2.entity.field_test_text', $this->sut->resolveInternal('field_test_ref2.field_test_text')); - $this->assertEquals('field_test_ref2.entity.field_test_text.value', $this->sut->resolveInternal('field_test_ref2.field_test_text.value')); - $this->assertEquals('field_test_ref2.entity.field_test_text.format', $this->sut->resolveInternal('field_test_ref2.field_test_text.format')); + $entity_type_id = 'entity_test_with_bundle'; + $bundle = 'bundle1'; + + $this->assertEquals('field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test1')); + $this->assertEquals('field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test2')); + $this->assertEquals('field_test3', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test3')); + + $this->assertEquals('field_test_ref1.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test1')); + $this->assertEquals('field_test_ref1.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test2')); + $this->assertEquals('field_test_ref2.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test1')); + $this->assertEquals('field_test_ref2.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test2')); + $this->assertEquals('field_test_ref3.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref3.field_test1')); + $this->assertEquals('field_test_ref3.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref3.field_test2')); + + $this->assertEquals('field_test_ref1.entity.field_test_text', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text')); + $this->assertEquals('field_test_ref1.entity.field_test_text.value', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text.value')); + $this->assertEquals('field_test_ref1.entity.field_test_text.format', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text.format')); + $this->assertEquals('field_test_ref2.entity.field_test_text', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text')); + $this->assertEquals('field_test_ref2.entity.field_test_text.value', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text.value')); + $this->assertEquals('field_test_ref2.entity.field_test_text.format', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text.format')); } /** @@ -179,21 +176,10 @@ class FieldResolverTest extends JsonapiKernelTestBase { * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException */ public function testResolveInternalError() { - $request = Request::create('/jsonapi/entity_test_with_bundle/bundle1'); - $route = \Drupal::service('router.route_provider') - ->getRouteByName('jsonapi.entity_test_with_bundle--bundle1.collection'); - $request->attributes->set( - RouteObjectInterface::ROUTE_NAME, - 'jsonapi.entity_test_with_bundle--bundle1.collection' - ); - $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route); - - \Drupal::requestStack()->push($request); - $original = 'host.fail!!.deep'; $not_expected = 'host.entity.fail!!.entity.deep'; - $this->assertEquals($not_expected, $this->sut->resolveInternal($original)); + $this->assertEquals($not_expected, $this->sut->resolveInternal('entity_test_with_bundle', 'bundle1', $original)); } } diff --git a/tests/src/Kernel/Controller/EntityResourceTest.php b/tests/src/Kernel/Controller/EntityResourceTest.php index ae8aeef..0690d2e 100644 --- a/tests/src/Kernel/Controller/EntityResourceTest.php +++ b/tests/src/Kernel/Controller/EntityResourceTest.php @@ -11,9 +11,11 @@ use Drupal\jsonapi\Context\CurrentContext; use Drupal\jsonapi\Controller\EntityResource; use Drupal\jsonapi\Resource\EntityCollection; use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel; -use Drupal\jsonapi\Routing\Param\Filter; -use Drupal\jsonapi\Routing\Param\Sort; -use Drupal\jsonapi\Routing\Param\OffsetPage; +use Drupal\jsonapi\Query\EntityCondition; +use Drupal\jsonapi\Query\EntityConditionGroup; +use Drupal\jsonapi\Query\Filter; +use Drupal\jsonapi\Query\Sort; +use Drupal\jsonapi\Query\OffsetPage; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase; @@ -106,6 +108,7 @@ class EntityResourceTest extends JsonapiKernelTestBase { ]); $this->createEntityReferenceField('node', 'article', 'field_relationships', 'Relationship', 'node', 'default', ['target_bundles' => ['article']], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); $this->user->save(); + $this->node = Node::create([ 'title' => 'dummy_title', 'type' => 'article', @@ -206,7 +209,7 @@ class EntityResourceTest extends JsonapiKernelTestBase { */ public function testGetFilteredCollection() { $field_manager = $this->container->get('entity_field.manager'); - $filter = new Filter(['type' => ['value' => 'article']]); + $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('type', 'article')])); // The fake route. $route = new Route(NULL, [], [ '_entity_type' => 'node', @@ -237,7 +240,6 @@ class EntityResourceTest extends JsonapiKernelTestBase { $entity_resource = new EntityResource( $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'), $this->container->get('entity_type.manager'), - $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, $this->container->get('plugin.manager.field.field_type'), @@ -265,7 +267,7 @@ class EntityResourceTest extends JsonapiKernelTestBase { '_entity_type' => 'node', '_bundle' => 'article', ]); - $sort = new Sort('-type'); + $sort = new Sort([['path' => 'type', 'direction' => 'DESC']]); // The request. $request = new Request([], [], [ '_route_params' => [ @@ -291,7 +293,6 @@ class EntityResourceTest extends JsonapiKernelTestBase { $entity_resource = new EntityResource( $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'), $this->container->get('entity_type.manager'), - $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, $this->container->get('plugin.manager.field.field_type'), @@ -320,7 +321,7 @@ class EntityResourceTest extends JsonapiKernelTestBase { '_entity_type' => 'node', '_bundle' => 'article', ]); - $pager = new OffsetPage(['offset' => 1, 'limit' => 1]); + $pager = new OffsetPage(1, 1); // The request. $request = new Request([], [], [ '_route_params' => [ @@ -346,7 +347,6 @@ class EntityResourceTest extends JsonapiKernelTestBase { $entity_resource = new EntityResource( $this->container->get('jsonapi.resource_type.repository')->get('node', 'article'), $this->container->get('entity_type.manager'), - $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, $this->container->get('plugin.manager.field.field_type'), @@ -369,7 +369,7 @@ class EntityResourceTest extends JsonapiKernelTestBase { * @covers ::getCollection */ public function testGetEmptyCollection() { - $filter = new Filter(['uuid' => ['value' => 'invalid']]); + $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('uuid', 'invalid')])); $request = new Request([], [], [ '_route_params' => [ '_json_api_params' => [ @@ -869,7 +869,6 @@ class EntityResourceTest extends JsonapiKernelTestBase { return new EntityResource( new ResourceType($entity_type_id, $bundle, NULL), $this->container->get('entity_type.manager'), - $this->container->get('jsonapi.query_builder'), $this->container->get('entity_field.manager'), $current_context, $this->container->get('plugin.manager.field.field_type'), diff --git a/tests/src/Kernel/JsonapiKernelTestBase.php b/tests/src/Kernel/JsonapiKernelTestBase.php index fd39cc1..4b220b7 100644 --- a/tests/src/Kernel/JsonapiKernelTestBase.php +++ b/tests/src/Kernel/JsonapiKernelTestBase.php @@ -67,4 +67,41 @@ abstract class JsonapiKernelTestBase extends KernelTestBase { } } + /** + * Creates a field of an entity reference field storage on the bundle. + * + * @param string $entity_type + * The type of entity the field will be attached to. + * @param string $bundle + * The bundle name of the entity the field will be attached to. + * @param string $field_name + * The name of the field; if it exists, a new instance of the existing. + * field will be created. + * @param string $field_label + * The label of the field. + * @param int $cardinality + * The cardinality of the field. + * + * @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm() + */ + protected function createTextField($entity_type, $bundle, $field_name, $field_label, $cardinality = 1) { + // Look for or add the specified field to the requested entity bundle. + if (!FieldStorageConfig::loadByName($entity_type, $field_name)) { + FieldStorageConfig::create([ + 'field_name' => $field_name, + 'type' => 'text', + 'entity_type' => $entity_type, + 'cardinality' => $cardinality, + ])->save(); + } + if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) { + FieldConfig::create([ + 'field_name' => $field_name, + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'label' => $field_label, + ])->save(); + } + } + } diff --git a/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php b/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php new file mode 100644 index 0000000..8b50336 --- /dev/null +++ b/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php @@ -0,0 +1,59 @@ +container->get('serializer.normalizer.entity_condition_group.jsonapi'); + + $normalized = $normalizer->denormalize($case, EntityConditionGroup::class); + + $this->assertEquals($case['conjunction'], $normalized->conjunction()); + + foreach ($normalized->members() as $key => $condition) { + $this->assertEquals($case['members'][$key]['path'], $condition->field()); + $this->assertEquals($case['members'][$key]['value'], $condition->value()); + } + } + + /** + * @covers ::denormalize + * @expectedException InvalidArgumentException + */ + public function testDenormalize_exception() { + $normalizer = $this->container->get('serializer.normalizer.entity_condition_group.jsonapi'); + $data = ['conjunction' => 'NOT_ALLOWED', 'members' => []]; + $normalized = $normalizer->denormalize($data, EntityConditionGroup::class); + } + + public function denormalizeProvider() { + return [ + [['conjunction' => 'AND', 'members' => []]], + [['conjunction' => 'OR', 'members' => []]], + ]; + } + +} diff --git a/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php b/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php new file mode 100644 index 0000000..ee2a928 --- /dev/null +++ b/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php @@ -0,0 +1,82 @@ +normalizer = $this->container->get('serializer.normalizer.entity_condition.jsonapi'); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($case) { + $normalized = $this->normalizer->denormalize($case, EntityCondition::class); + $this->assertEquals($case['path'], $normalized->field()); + $this->assertEquals($case['value'], $normalized->value()); + if (isset($case['operator'])) { + $this->assertEquals($case['operator'], $normalized->operator()); + } + } + + public function denormalizeProvider() { + return [ + [['path' => 'some_field', 'value' => NULL, 'operator' => '=']], + [['path' => 'some_field', 'operator' => '=', 'value' => 'some_string']], + [['path' => 'some_field', 'operator' => '<>', 'value' => 'some_string']], + [['path' => 'some_field', 'operator' => 'NOT BETWEEN', 'value' => 'some_string']], + [['path' => 'some_field', 'operator' => 'BETWEEN', 'value' => ['some_string']]], + ]; + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeValidationProvider + */ + public function testDenormalize_validation($input, $exception) { + if ($exception) { + $this->setExpectedException(get_class($exception), $exception->getMessage()); + } + $this->normalizer->denormalize($input, EntityCondition::class); + } + + public function denormalizeValidationProvider() { + return [ + [['path' => 'some_field', 'value' => 'some_value'], NULL], + [['path' => 'some_field', 'value' => 'some_value', 'operator' => '='], NULL], + [['path' => 'some_field', 'operator' => 'IS NULL'], NULL], + [['path' => 'some_field', 'operator' => 'IS NOT NULL'], NULL], + [['path' => 'some_field', 'operator' => 'NOT_ALLOWED', 'value' => 'some_value'], new BadRequestHttpException("The 'NOT_ALLOWED' operator is not allowed in a filter parameter.")], + [['path' => 'some_field', 'operator' => 'IS NULL', 'value' => 'should_not_be_here'], new BadRequestHttpException("Filters using the 'IS NULL' operator should not provide a value.")], + [['path' => 'some_field', 'operator' => 'IS NOT NULL', 'value' => 'should_not_be_here'], new BadRequestHttpException("Filters using the 'IS NOT NULL' operator should not provide a value.")], + [['path' => 'path_only'], new BadRequestHttpException("Filter parameter is missing a '" . EntityConditionNormalizer::VALUE_KEY . "' key.")], + [['value' => 'value_only'], new BadRequestHttpException("Filter parameter is missing a '" . EntityConditionNormalizer::PATH_KEY . "' key.")], + ]; + } + +} diff --git a/tests/src/Kernel/Normalizer/FilterNormalizerTest.php b/tests/src/Kernel/Normalizer/FilterNormalizerTest.php new file mode 100644 index 0000000..fdc276c --- /dev/null +++ b/tests/src/Kernel/Normalizer/FilterNormalizerTest.php @@ -0,0 +1,117 @@ +container->set('jsonapi.field_resolver', $this->getFieldResolver('foo', 'bar')); + $this->normalizer = $this->container->get('serializer.normalizer.filter.jsonapi'); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($normalized, $expected) { + $actual = $this->normalizer->denormalize($normalized, Filter::class, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']); + $conditions = $actual->root()->members(); + for ($i = 0; $i < count($normalized); $i++) { + $this->assertEquals($expected[$i]['path'], $conditions[$i]->field()); + $this->assertEquals($expected[$i]['value'], $conditions[$i]->value()); + $this->assertEquals($expected[$i]['operator'], $conditions[$i]->operator()); + } + } + + /** + * Data provider for testDenormalize. + */ + public function denormalizeProvider() { + return [ + [['uid' => ['value' => 1]], [['path' => 'uid', 'value' => 1, 'operator' => '=']]], + ]; + } + + /** + * @covers ::denormalize + */ + public function testDenormalize_nested() { + $normalized = [ + 'or-group' => ['group' => ['conjunction' => 'OR']], + 'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']], + 'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']], + 'condition-0' => ['condition' => ['path' => 'field0', 'value' => 'value0', 'memberOf' => 'nested-or-group']], + 'condition-1' => ['condition' => ['path' => 'field1', 'value' => 'value1', 'memberOf' => 'nested-or-group']], + 'condition-2' => ['condition' => ['path' => 'field2', 'value' => 'value2', 'memberOf' => 'nested-and-group']], + 'condition-3' => ['condition' => ['path' => 'field3', 'value' => 'value3', 'memberOf' => 'nested-and-group']], + ]; + $filter = $this->normalizer->denormalize($normalized, Filter::class, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']); + $root = $filter->root(); + + // Make sure the implicit root group was added. + $this->assertEquals($root->conjunction(), 'AND'); + + // Ensure the or-group and the and-group were added correctly. + $members = $root->members(); + + // Ensure the OR group was added. + $or_group = $members[0]; + $this->assertEquals($or_group->conjunction(), 'OR'); + $or_group_members = $or_group->members(); + + // Make sure the nested OR group was added with the right conditions. + $nested_or_group = $or_group_members[0]; + $this->assertEquals($nested_or_group->conjunction(), 'OR'); + $nested_or_group_members = $nested_or_group->members(); + $this->assertEquals($nested_or_group_members[0]->field(), 'field0'); + $this->assertEquals($nested_or_group_members[1]->field(), 'field1'); + + // Make sure the nested AND group was added with the right conditions. + $nested_and_group = $or_group_members[1]; + $this->assertEquals($nested_and_group->conjunction(), 'AND'); + $nested_and_group_members = $nested_and_group->members(); + $this->assertEquals($nested_and_group_members[0]->field(), 'field2'); + $this->assertEquals($nested_and_group_members[1]->field(), 'field3'); + } + + /** + * Provides a mock field resolver. + */ + protected function getFieldResolver($entity_type_id, $bundle) { + $field_resolver = $this->prophesize(FieldResolver::class); + $field_resolver->resolveInternal('foo', 'bar', Argument::any())->willReturnArgument(2); + return $field_resolver->reveal(); + } + +} diff --git a/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php new file mode 100644 index 0000000..cc2ff38 --- /dev/null +++ b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php @@ -0,0 +1,71 @@ +normalizer = $this->container->get('serializer.normalizer.offset_page.jsonapi'); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($original, $expected) { + $actual = $this->normalizer->denormalize($original, OffsetPage::class); + $this->assertEquals($expected['offset'], $actual->offset()); + $this->assertEquals($expected['limit'], $actual->size()); + } + + /** + * Data provider for testGet. + */ + public function denormalizeProvider() { + return [ + [['offset' => 12, 'limit' => 20], ['offset' => 12, 'limit' => 20]], + [['offset' => 12, 'limit' => 60], ['offset' => 12, 'limit' => 50]], + [['offset' => 12], ['offset' => 12, 'limit' => 50]], + [['offset' => 0], ['offset' => 0, 'limit' => 50]], + [[], ['offset' => 0, 'limit' => 50]], + ]; + } + + /** + * @covers ::denormalize + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + */ + public function testDenormalizeFail() { + $this->normalizer->denormalize('lorem', OffsetPage::class); + } + + +} diff --git a/tests/src/Kernel/Normalizer/SortNormalizerTest.php b/tests/src/Kernel/Normalizer/SortNormalizerTest.php new file mode 100644 index 0000000..7491bb1 --- /dev/null +++ b/tests/src/Kernel/Normalizer/SortNormalizerTest.php @@ -0,0 +1,103 @@ +normalizer = $this->container->get('serializer.normalizer.sort.jsonapi'); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($input, $expected) { + $sort = $this->normalizer->denormalize($input, Sort::class); + foreach ($sort->fields() as $index => $sort_field) { + $this->assertEquals($expected[$index]['path'], $sort_field['path']); + $this->assertEquals($expected[$index]['direction'], $sort_field['direction']); + $this->assertEquals($expected[$index]['langcode'], $sort_field['langcode']); + } + } + + /** + * Provides a suite of shortcut sort pamaters and their expected expansions. + */ + public function denormalizeProvider() { + return [ + ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]], + ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]], + ['-lorem,ipsum', [ + ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL], + ], + ], + ['-lorem,-ipsum', [ + ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL], + ], + ], + [[ + ['path' => 'lorem', 'langcode' => NULL], + ['path' => 'ipsum', 'langcode' => 'ca'], + ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], + ], [ + ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], + ], + ], + ]; + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeFailProvider + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + */ + public function testDenormalizeFail($input) { + $sort = $this->normalizer->denormalize($input, Sort::class); + } + + /** + * Data provider for testDenormalizeFail. + */ + public function denormalizeFailProvider() { + return [ + [[['lorem']]], + [''], + ]; + } + +} diff --git a/tests/src/Kernel/Query/FilterTest.php b/tests/src/Kernel/Query/FilterTest.php new file mode 100644 index 0000000..4d23773 --- /dev/null +++ b/tests/src/Kernel/Query/FilterTest.php @@ -0,0 +1,170 @@ +setUpSchemas(); + + $this->savePaintingType(); + + // ((RED or CIRCLE) or (YELLOW and SQUARE)) + $this->savePaintings([ + ['colors' => ['red'], 'shapes' => ['triangle'], 'title' => 'FIND'], + ['colors' => ['orange'], 'shapes' => ['circle'], 'title' => 'FIND'], + ['colors' => ['orange'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'], + ['colors' => ['yellow'], 'shapes' => ['square'], 'title' => 'FIND'], + ['colors' => ['yellow'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'], + ['colors' => ['orange'], 'shapes' => ['square'], 'title' => 'DONT_FIND'], + ]); + + $this->normalizer = $this->container->get('serializer.normalizer.filter.jsonapi'); + $this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node'); + } + + /** + * @covers ::queryCondition + */ + public function testQueryCondition() { + // Can't use a data provider because we need access to the container. + $data = $this->queryConditionData(); + + foreach ($data as $case) { + $normalized = $case[0]; + $expected_query = $case[1]; + // Denormalize the test filter into the object we want to test. + $filter = $this->normalizer->denormalize($normalized, Filter::class, NULL, [ + 'entity_type_id' => 'node', + 'bundle' => 'painting', + ]); + + $query = $this->nodeStorage->getQuery(); + + // Get the query condition parsed from the input. + $condition = $filter->queryCondition($query); + + // Apply it to the query. + $query->condition($condition); + + // Compare the results. + $this->assertEquals($expected_query->execute(), $query->execute()); + } + } + + /** + * Simply provides test data to keep the actual test method tidy. + */ + protected function queryConditionData() { + // ((RED or CIRCLE) or (YELLOW and SQUARE)) + $query = $this->nodeStorage->getQuery(); + + $or_group = $query->orConditionGroup(); + + $nested_or_group = $query->orConditionGroup(); + $nested_or_group->condition('colors', 'red', 'CONTAINS'); + $nested_or_group->condition('shapes', 'circle', 'CONTAINS'); + $or_group->condition($nested_or_group); + + $nested_and_group = $query->andConditionGroup(); + $nested_and_group->condition('colors', 'yellow', 'CONTAINS'); + $nested_and_group->condition('shapes', 'square', 'CONTAINS'); + $or_group->condition($nested_and_group); + + $query->condition($or_group); + + return [ + [ + [ + 'or-group' => ['group' => ['conjunction' => 'OR']], + 'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']], + 'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']], + 'condition-0' => ['condition' => ['path' => 'colors', 'value' => 'red', 'operator' => 'CONTAINS', 'memberOf' => 'nested-or-group']], + 'condition-1' => ['condition' => ['path' => 'shapes', 'value' => 'circle', 'operator' => 'CONTAINS', 'memberOf' => 'nested-or-group']], + 'condition-2' => ['condition' => ['path' => 'colors', 'value' => 'yellow', 'operator' => 'CONTAINS', 'memberOf' => 'nested-and-group']], + 'condition-3' => ['condition' => ['path' => 'shapes', 'value' => 'square', 'operator' => 'CONTAINS', 'memberOf' => 'nested-and-group']], + ], + $query + ], + ]; + } + + protected function setUpSchemas() { + $this->installSchema('system', ['sequences']); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + + $this->installSchema('user', []); + foreach (['user', 'node'] as $entity_type_id) { + $this->installEntitySchema($entity_type_id); + } + } + + protected function savePaintingType() { + NodeType::create([ + 'type' => 'painting', + ])->save(); + $this->createTextField( + 'node', 'painting', + 'colors', 'Colors', + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + $this->createTextField( + 'node', 'painting', + 'shapes', 'Shapes', + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + } + + protected function savePaintings($paintings) { + foreach ($paintings as $painting) { + Node::create(array_merge([ + 'type' => 'painting', + ], $painting))->save(); + } + } + +} diff --git a/tests/src/Unit/Context/CurrentContextTest.php b/tests/src/Unit/Context/CurrentContextTest.php index 3579c5c..0e2b545 100644 --- a/tests/src/Unit/Context/CurrentContextTest.php +++ b/tests/src/Unit/Context/CurrentContextTest.php @@ -6,9 +6,10 @@ use Drupal\Core\Routing\CurrentRouteMatch; use Drupal\jsonapi\Context\CurrentContext; use Drupal\jsonapi\ResourceType\ResourceType; use Drupal\jsonapi\ResourceType\ResourceTypeRepository; -use Drupal\jsonapi\Routing\Param\Filter; -use Drupal\jsonapi\Routing\Param\Sort; -use Drupal\jsonapi\Routing\Param\OffsetPage; +use Drupal\jsonapi\Query\EntityConditionGroup; +use Drupal\jsonapi\Query\Filter; +use Drupal\jsonapi\Query\Sort; +use Drupal\jsonapi\Query\OffsetPage; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\node\NodeInterface; use Drupal\Tests\UnitTestCase; @@ -38,13 +39,6 @@ class CurrentContextTest extends UnitTestCase { protected $resourceTypeRepository; /** - * A mock for the entity field manager. - * - * @var \Drupal\Core\Entity\EntityFieldManagerInterface - */ - protected $fieldManager; - - /** * A request stack. * * @var \Symfony\Component\HttpFoundation\RequestStack @@ -79,9 +73,9 @@ class CurrentContextTest extends UnitTestCase { $this->requestStack = new RequestStack(); $this->requestStack->push(new Request([], [], [ '_json_api_params' => [ - 'filter' => new Filter([], 'node', $this->fieldManager), + 'filter' => new Filter(new EntityConditionGroup('AND', [])), 'sort' => new Sort([]), - 'page' => new OffsetPage([]), + 'page' => new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX), // 'include' => new IncludeParam([]), // 'fields' => new Fields([]),. ], @@ -109,10 +103,9 @@ class CurrentContextTest extends UnitTestCase { public function testGetJsonApiParameter() { $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); - $expected = new Sort([]); $actual = $request_context->getJsonApiParameter('sort'); - $this->assertEquals($expected, $actual); + $this->assertTrue($actual instanceof Sort); } /** diff --git a/tests/src/Unit/LinkManager/LinkManagerTest.php b/tests/src/Unit/LinkManager/LinkManagerTest.php index 7f0f416..c456413 100644 --- a/tests/src/Unit/LinkManager/LinkManagerTest.php +++ b/tests/src/Unit/LinkManager/LinkManagerTest.php @@ -4,13 +4,14 @@ namespace Drupal\Tests\jsonapi\Unit\LinkManager; use Drupal\Core\Routing\UrlGeneratorInterface; use Drupal\jsonapi\LinkManager\LinkManager; -use Drupal\jsonapi\Routing\Param\OffsetPage; +use Drupal\jsonapi\Query\OffsetPage; use Drupal\Tests\UnitTestCase; use Prophecy\Argument; use Symfony\Cmf\Component\Routing\ChainRouterInterface; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * @coversDefaultClass \Drupal\jsonapi\LinkManager\LinkManager @@ -54,8 +55,8 @@ class LinkManagerTest extends UnitTestCase { $request = $this->prophesize(Request::class); // Have the request return the desired page parameter. $page_param = $this->prophesize(OffsetPage::class); - $page_param->getOffset()->willReturn($offset); - $page_param->getSize()->willReturn($size); + $page_param->offset()->willReturn($offset); + $page_param->size()->willReturn($size); $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]); $request->query = new ParameterBag(); @@ -166,8 +167,8 @@ class LinkManagerTest extends UnitTestCase { $request = $this->prophesize(Request::class); // Have the request return the desired page parameter. $page_param = $this->prophesize(OffsetPage::class); - $page_param->getOffset()->willReturn(NULL); - $page_param->getSize()->willReturn(NULL); + $page_param->offset()->willReturn(NULL); + $page_param->size()->willReturn(NULL); $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]); $request->query = new ParameterBag(['amet' => 'pax']); diff --git a/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php b/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php index 060d40a..8ad3748 100644 --- a/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php +++ b/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php @@ -4,9 +4,9 @@ namespace Drupal\Tests\jsonapi\Unit\Routing; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\jsonapi\Routing\JsonApiParamEnhancer; -use Drupal\jsonapi\Routing\Param\OffsetPage; -use Drupal\jsonapi\Routing\Param\Filter; -use Drupal\jsonapi\Routing\Param\Sort; +use Drupal\jsonapi\Query\OffsetPage; +use Drupal\jsonapi\Query\Filter; +use Drupal\jsonapi\Query\Sort; use Drupal\jsonapi\Routing\Routes; use Drupal\Tests\UnitTestCase; use Prophecy\Argument; @@ -15,6 +15,7 @@ use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Route; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** * @coversDefaultClass \Drupal\jsonapi\Routing\JsonApiParamEnhancer @@ -27,7 +28,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase { * @covers ::applies */ public function testApplies() { - $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers(); + $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer); $route = $this->prophesize(Route::class); $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)->will(new ReturnPromise([Routes::FRONT_CONTROLLER, 'lorem'])); @@ -39,7 +41,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase { * @covers ::enhance */ public function testEnhanceFilter() { - $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers(); + $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer); $request = $this->prophesize(Request::class); $query = $this->prophesize(ParameterBag::class); $query->get('filter')->willReturn(['filed1' => 'lorem']); @@ -49,6 +52,7 @@ class JsonApiParamEnhancerTest extends UnitTestCase { $route = $this->prophesize(Route::class); $route->getRequirement('_entity_type')->willReturn('dolor'); + $route->getRequirement('_bundle')->willReturn('sit'); $defaults = $object->enhance([ RouteObjectInterface::ROUTE_OBJECT => $route->reveal(), ], $request->reveal()); @@ -61,7 +65,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase { * @covers ::enhance */ public function testEnhancePage() { - $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers(); + $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer); $request = $this->prophesize(Request::class); $query = $this->prophesize(ParameterBag::class); $query->get('page')->willReturn(['cursor' => 'lorem']); @@ -69,7 +74,12 @@ class JsonApiParamEnhancerTest extends UnitTestCase { $query->has('page')->willReturn(TRUE); $request->query = $query->reveal(); - $defaults = $object->enhance([], $request->reveal()); + $route = $this->prophesize(Route::class); + $route->getRequirement('_entity_type')->willReturn('dolor'); + $route->getRequirement('_bundle')->willReturn('sit'); + $defaults = $object->enhance([ + RouteObjectInterface::ROUTE_OBJECT => $route->reveal(), + ], $request->reveal()); $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']); $this->assertTrue(empty($defaults['_json_api_params']['filter'])); $this->assertTrue(empty($defaults['_json_api_params']['sort'])); @@ -79,7 +89,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase { * @covers ::enhance */ public function testEnhanceSort() { - $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers(); + $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer); $request = $this->prophesize(Request::class); $query = $this->prophesize(ParameterBag::class); $query->get('sort')->willReturn('-lorem'); @@ -87,10 +98,40 @@ class JsonApiParamEnhancerTest extends UnitTestCase { $query->has('sort')->willReturn(TRUE); $request->query = $query->reveal(); - $defaults = $object->enhance([], $request->reveal()); + $route = $this->prophesize(Route::class); + $route->getRequirement('_entity_type')->willReturn('dolor'); + $route->getRequirement('_bundle')->willReturn('sit'); + $defaults = $object->enhance([ + RouteObjectInterface::ROUTE_OBJECT => $route->reveal(), + ], $request->reveal()); $this->assertInstanceOf(Sort::class, $defaults['_json_api_params']['sort']); $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']); $this->assertTrue(empty($defaults['_json_api_params']['filter'])); } + /** + * Builds mock normalizers. + */ + public function getMockNormalizers() { + $filter_normalizer = $this->prophesize(DenormalizerInterface::class); + $filter_normalizer->denormalize( + Argument::any(), + Filter::class, + Argument::any(), + Argument::any() + )->willReturn($this->prophesize(Filter::class)->reveal()); + + $sort_normalizer = $this->prophesize(DenormalizerInterface::class); + $sort_normalizer->denormalize(Argument::any(), Sort::class)->willReturn($this->prophesize(Sort::class)->reveal()); + + $page_normalizer = $this->prophesize(DenormalizerInterface::class); + $page_normalizer->denormalize(Argument::any(), OffsetPage::class)->willReturn($this->prophesize(OffsetPage::class)->reveal()); + + return [ + $filter_normalizer->reveal(), + $sort_normalizer->reveal(), + $page_normalizer->reveal(), + ]; + } + } diff --git a/tests/src/Unit/Routing/Param/FilterTest.php b/tests/src/Unit/Routing/Param/FilterTest.php deleted file mode 100644 index 40d85a3..0000000 --- a/tests/src/Unit/Routing/Param/FilterTest.php +++ /dev/null @@ -1,349 +0,0 @@ -assertEquals($expected, $filter->get()); - } - - /** - * @covers ::get - * @covers ::expand - * @covers ::expandItem - * @covers ::validateItem - * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * @dataProvider invalidFiltersDataProvider - */ - public function testInvalidFilters($original) { - $filter = new Filter($original); - $filter->get(); - } - - /** - * Data provider for testValidFilters(). - */ - public function validFiltersDataProvider() { - return [ - // Test case: - // filter[foo][value]=bar. - [ - ['foo' => ['value' => 'bar']], - ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]], - ], - // Test case: - // filter[0][path]=foo - // filter[0][value]=bar. - [ - [0 => ['path' => 'foo', 'value' => 'bar']], - [0 => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]], - ], - // Test case: - // filter[foo][value]=bar - // filter[foo][operator]=>. - [ - ['foo' => ['value' => 'bar', 'operator' => '>']], - ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '>']]], - ], - // Test case: - // filter[0][path]=foo - // filter[0][value]=1 - // filter[0][operator]=>. - [ - [0 => ['path' => 'foo', 'value' => '1', 'operator' => '>']], - [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]], - ], - // Test case: - // filter[foo][value][]=1 - // filter[foo][value][]=2 - // filter[foo][value][]=3 - // filter[foo][operator]="NOT IN". - [ - ['foo' => ['value' => ['1', '2', '3'], 'operator' => 'NOT IN']], - ['foo' => ['condition' => ['path' => 'foo', 'value' => ['1', '2', '3'], 'operator' => 'NOT IN']]], - ], - // Test case: - // filter[foo][value][]=1 - // filter[foo][value][]=10 - // filter[foo][operator]=BETWEEN. - [ - ['foo' => ['value' => ['1', '10'], 'operator' => 'BETWEEN']], - ['foo' => ['condition' => ['path' => 'foo', 'value' => ['1', '10'], 'operator' => 'BETWEEN']]], - ], - // Test case: - // filter[0][condition][path]=foo - // filter[0][condition][value]=1 - // filter[0][condition][operator]=>. - [ - [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]], - [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]], - ], - // Test case: - // filter[0][path]=foo - // filter[0][value][]=bar - // filter[0][value][]=baz. - [ - [0 => ['path' => 'foo', 'value' => ['bar', 'baz']]], - [0 => ['condition' => ['path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=']]], - ], - // Test case: - // filter[0][path]=foo - // filter[0][value][]=bar - // filter[0][value][]=baz - // filter[0][memberOf]=or-group - // filter[or-group][group][conjunction]=OR. - [ - [0 => ['path' => 'foo', 'value' => ['bar', 'baz'], 'memberOf' => 'or-group'], 'or-group' => ['group' => ['conjunction' => 'OR']]], - [0 => ['condition' => ['path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=', 'memberOf' => 'or-group']], 'or-group' => ['group' => ['conjunction' => 'OR']]], - ], - // Test case: - // filter[0][path]=foo - // filter[0][value]=bar - // filter[1][condition][path]=baz - // filter[1][condition][value]=zab - // filter[1][condition][operator]=<>. - [ - [ - 0 => ['path' => 'foo', 'value' => 'bar'], - 1 => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']], - ], - [ - 0 => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']], - 1 => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']], - ], - ], - // Test case: - // filter[zero][path]=foo - // filter[zero][value]=bar - // filter[one][condition][path]=baz - // filter[one][condition][value]=zab - // filter[one][condition][operator]=<>. - [ - [ - 'zero' => ['path' => 'foo', 'value' => 'bar'], - 'one' => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']], - ], - [ - 'zero' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']], - 'one' => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']], - ], - ], - // Test case: - // filter[and-group][group][conjunction]=AND - // filter[or-group][group][conjunction]=OR - // filter[or-group][group][memberOf]=and-group - // filter[admin-filter][path]=uid.name - // filter[admin-filter][value]=admin - // filter[admin-filter][memberOf]=and-group - // filter[sticky-filter][path]=sticky - // filter[sticky-filter][value]=1 - // filter[sticky-filter][memberOf]=or-group - // filter[promote-filter][path]=promote - // filter[promote-filter][value]=1 - // filter[promote-filter][memberOf]=or-group. - [ - [ - 'and-group' => ['group' => ['conjunction' => 'AND']], - 'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']], - 'admin-filter' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group'], - 'sticky-filter' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group'], - 'promote-filter' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group'], - ], - [ - 'and-group' => ['group' => ['conjunction' => 'AND']], - 'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']], - 'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'operator' => '=', 'memberOf' => 'and-group']], - 'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'operator' => '=', 'memberOf' => 'or-group']], - 'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'operator' => '=', 'memberOf' => 'or-group']], - ], - ], - // Test case: - // filter[and-group][group][conjunction]=AND - // filter[or-group][group][conjunction]=OR - // filter[or-group][group][memberOf]=and-group - // filter[admin-filter][condition][path]=uid.name - // filter[admin-filter][condition][value]=admin - // filter[admin-filter][condition][memberOf]=and-group - // filter[sticky-filter][condition][path]=sticky - // filter[sticky-filter][condition][value]=1 - // filter[sticky-filter][condition][memberOf]=or-group - // filter[promote-filter][condition][path]=promote - // filter[promote-filter][condition][value]=1 - // filter[promote-filter][condition][memberOf]=or-group. - [ - [ - 'and-group' => ['group' => ['conjunction' => 'AND']], - 'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']], - 'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group']], - 'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group']], - 'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group']], - ], - [ - 'and-group' => ['group' => ['conjunction' => 'AND']], - 'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']], - 'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group']], - 'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group']], - 'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group']], - ], - ], - - // filter[has-sticky][path]=sticky - // filter[has-sticky[operator]='IS NOT NULL' - [ - ['has-sticky' => ['condition' => ['path' => 'sticky', 'operator' => 'IS NOT NULL']]], - ['has-sticky' => [ - 'condition' => ['path' => 'sticky', 'operator' => 'IS NOT NULL', 'value' => NULL] - ]], - ] - ]; - } - - /** - * Data provider for testInvalidFilters(). - */ - public function invalidFiltersDataProvider() { - return [ - // Filter suppors only arrays. - // # Test case: - // filter[foo]=bar - // # Reason to fail: - // "bar" is a string and not an array. - [ - ['foo' => 'bar'], - ], - // Filter supports only certain keys. - // # Test case: - // filter[foo][nid]=1 - // # Reason to fail: - // Filter expects "group", "condition" or "value" key in the filter root. - [ - ['foo' => ['nid' => 1]], - ], - // Shorthand filter supports only allowed list of params. - // # Test case: - // filter[foo][value]=1 - // filter[foo][bar]=baz - // # Reason to fail: - // "bar" is not expected key for filtering. - [ - ['foo' => ['value' => 1, 'bar' => 'baz']], - ], - // Shorthand filter supports only allowed list of params. - // # Test case: - // filter[foo][value]=1 - // filter[foo][group]=bar - // # Reason to fail: - // "group" is a legacy and not supported anymore group key. - [ - ['foo' => ['value' => 1, 'group' => 'bar']], - ], - // Full canonical filter has mandatory params. - // # Test case: - // filter[foo][condition][value]=1 - // # Reason to fail: - // Missing mandatory "path" key. - [ - ['foo' => ['condition' => ['value' => 1]]], - ], - // Full canonical filter has mandatory params. - // # Test case: - // filter[foo][condition][path]=nid - // # Reason to fail: - // Missing mandatory "value" key. - [ - ['foo' => ['condition' => ['path' => 'nid']]], - ], - // Full canonical filter supports only allowed list of params. - // # Test case: - // filter[foo][condition][value]=1 - // filter[foo][condition][path]=nid - // filter[foo][condition][bar]=baz. - // # Reason to fail: - // "bar" is not expected filtering key. - [ - ['foo' => ['condition' => ['value' => 1, 'path' => 'nid', 'bar' => 'baz']]], - ], - // Full canonical filter allows only one top level key "condition". - // # Test case: - // filter[foo][condition][value]=1 - // filter[foo][condition][path]=nid. - // filter[foo][value]=baz. - // # Reason to fail: - // "value" => "bar" is not expected next to the filter[condition] query. - [ - ['foo' => ['condition' => ['value' => 1, 'path' => 'nid'], ['value' => 'baz']]], - ], - // Group query supports only allowed list of params. - // # Test case: - // filter[foo][group][conjunction]=AND - // filter[foo][group][bar]=baz - // # Reason to fail: - // "bar" is not supported group key. - [ - ['foo' => ['group' => ['conjunction' => 'AND', 'bar' => 'baz']]], - ], - // Group query supports only allowed list of params. - // # Test case: - // filter[foo][group][conjunction]=AND - // filter[foo][group][group]=bar - // # Reason to fail: - // "group" is a legacy and not supported anymore group key. - [ - ['foo' => ['group' => ['conjunction' => 'AND', 'group' => 'bar']]], - ], - // Group query supports only allowed list of params. - // # Test case: - // filter[foo][group][bar]=baz - // filter[foo][group][conjunction]=AND - // # Reason to fail: - // "bar" is not supported group key. - [ - ['foo' => ['group' => ['bar' => 'baz', 'conjunction' => 'AND']]], - ], - // Group query has mandatory field "conjunction". - // # Test case: - // filter[foo][group][memberOf]=bar - // # Reason to fail: - // "conjunction" key is missing. - [ - ['foo' => ['group' => ['memberOf' => 'bar']]], - ], - // Group query has only certain correct values for "conjunction" key. - // # Test case: - // filter[foo][group][conjunction]=NOR - // # Reason to fail: - // "conjunction" key has wrong value. - [ - ['foo' => ['group' => ['conjunction' => 'NOR']]], - ], - // Group query allows only one top level key "group". - // # Test case: - // filter[foo][group][conjunction]=AND - // filter[foo][group][memberOf]=bar - // filter[foo][value]=baz. - // # Reason to fail: - // "value" => "bar" is not expected next to the filter[condition] query. - [ - ['foo' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'bar'], ['value' => 'baz']]], - ], - ]; - } - -} diff --git a/tests/src/Unit/Routing/Param/OffsetPageTest.php b/tests/src/Unit/Routing/Param/OffsetPageTest.php deleted file mode 100644 index c4afea5..0000000 --- a/tests/src/Unit/Routing/Param/OffsetPageTest.php +++ /dev/null @@ -1,45 +0,0 @@ -assertEquals($expected, $pager->get()); - } - - /** - * Data provider for testGet. - */ - public function getProvider() { - return [ - [['offset' => 12, 'limit' => 20], 50, ['offset' => 12, 'limit' => 20]], - [['offset' => 12, 'limit' => 60], 50, ['offset' => 12, 'limit' => 50]], - [['offset' => 12], 50, ['offset' => 12, 'limit' => 50]], - [['offset' => 0], 50, ['offset' => 0, 'limit' => 50]], - [[], 50, ['limit' => 50]], - ]; - } - - /** - * @covers ::get - * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - */ - public function testGetFail() { - $pager = new OffsetPage('lorem'); - $pager->get(); - } - -} diff --git a/tests/src/Unit/Routing/Param/SortTest.php b/tests/src/Unit/Routing/Param/SortTest.php deleted file mode 100644 index 845811d..0000000 --- a/tests/src/Unit/Routing/Param/SortTest.php +++ /dev/null @@ -1,75 +0,0 @@ -assertEquals($expected, $sort->get()); - } - - /** - * Data provider for testGet. - */ - public function getProvider() { - return [ - ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]], - ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]], - ['-lorem,ipsum', [ - ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], - ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL], - ], - ], - ['-lorem,-ipsum', [ - ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], - ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL], - ], - ], - [[ - ['path' => 'lorem', 'langcode' => NULL], - ['path' => 'ipsum', 'langcode' => 'ca'], - ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], - ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], - ], [ - ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL], - ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'], - ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], - ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], - ], - ], - ]; - } - - /** - * @covers ::get - * @dataProvider getFailProvider - * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - */ - public function testGetFail($input) { - $sort = new Sort($input); - $sort->get(); - } - - /** - * Data provider for testGetFail. - */ - public function getFailProvider() { - return [ - [[['lorem']]], - [''], - ]; - } - -}