From 910b54e29c8ea2b22de496ad2d235b5cbfcb4ee1 Mon Sep 17 00:00:00 2001 From: Bartek Wajda Date: Wed, 20 Aug 2025 10:07:29 +0200 Subject: [PATCH 1/3] IBX-XXXX: Content type search API --- composer.json | 2 +- .../IbexaRestExtension.php | 2 + src/bundle/Resources/config/criteria.yaml | 40 +++++ src/bundle/Resources/config/input_parsers.yml | 13 ++ src/bundle/Resources/config/routing.yml | 7 + src/bundle/Resources/config/sort_clauses.yaml | 24 +++ src/lib/Server/Controller/ContentType.php | 21 ++- .../Criterion/ContainsFieldDefinitionId.php | 40 +++++ .../Criterion/ContentTypeCriteriaRegistry.php | 31 ++++ .../ContentTypeCriterionInterface.php | 14 ++ .../Criterion/ContentTypeGroupId.php | 40 +++++ .../ContentType/Criterion/ContentTypeId.php | 40 +++++ .../Criterion/ContentTypeIdentifier.php | 40 +++++ .../Criterion/CriterionProcessor.php | 37 +++++ .../Parser/ContentType/Criterion/IsSystem.php | 40 +++++ .../ContentType/Query/ContentTypeQuery.php | 151 ++++++++++++++++++ .../Parser/ContentType/RestViewInput.php | 40 +++++ .../ContentTypeSortClausesRegistry.php | 31 ++++ .../SortClause/SortClauseProcessor.php | 37 +++++ .../ContentTypeQueryBuilderInterface.php | 20 +++ .../FromGetQueryContentTypeQueryBuilder.php | 46 ++++++ src/lib/Server/Values/ContentTypeList.php | 13 +- .../Server/Values/ContentTypeQueryInput.php | 17 ++ .../Values/ContentTypeRestViewInput.php | 19 +++ 24 files changed, 753 insertions(+), 12 deletions(-) create mode 100644 src/bundle/Resources/config/criteria.yaml create mode 100644 src/bundle/Resources/config/sort_clauses.yaml create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionId.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriteriaRegistry.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriterionInterface.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupId.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeId.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifier.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/CriterionProcessor.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Criterion/IsSystem.php create mode 100644 src/lib/Server/Input/Parser/ContentType/Query/ContentTypeQuery.php create mode 100644 src/lib/Server/Input/Parser/ContentType/RestViewInput.php create mode 100644 src/lib/Server/Input/Parser/ContentType/SortClause/ContentTypeSortClausesRegistry.php create mode 100644 src/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessor.php create mode 100644 src/lib/Server/Input/Parser/QueryBuilder/ContentTypeQueryBuilderInterface.php create mode 100644 src/lib/Server/Input/Parser/QueryBuilder/FromGetQueryContentTypeQueryBuilder.php create mode 100644 src/lib/Server/Values/ContentTypeQueryInput.php create mode 100644 src/lib/Server/Values/ContentTypeRestViewInput.php diff --git a/composer.json b/composer.json index 4b9e8699e..ba57df0f2 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "ext-libxml": "*", "ext-simplexml": "*", "ext-xmlwriter": "*", - "ibexa/core": "~4.6.0@dev", + "ibexa/core": "dev-ibx-10458-content-type-search-papi as 4.6.0@dev", "symfony/http-kernel": "^5.3", "symfony/dependency-injection": "^5.3", "symfony/routing": "^5.3", diff --git a/src/bundle/DependencyInjection/IbexaRestExtension.php b/src/bundle/DependencyInjection/IbexaRestExtension.php index 331db144f..95940ec2a 100644 --- a/src/bundle/DependencyInjection/IbexaRestExtension.php +++ b/src/bundle/DependencyInjection/IbexaRestExtension.php @@ -42,6 +42,8 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $loader->load('input_parsers.yml'); $loader->load('security.yml'); $loader->load('default_settings.yml'); + $loader->load('criteria.yaml'); + $loader->load('sort_clauses.yaml'); $processor = new ConfigurationProcessor($container, 'ibexa.site_access.config'); $processor->mapConfigArray('rest_root_resources', $mergedConfig); diff --git a/src/bundle/Resources/config/criteria.yaml b/src/bundle/Resources/config/criteria.yaml new file mode 100644 index 000000000..1c4e659ad --- /dev/null +++ b/src/bundle/Resources/config/criteria.yaml @@ -0,0 +1,40 @@ +services: + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\CriterionProcessor: + parent: Ibexa\Contracts\Rest\Input\Parser\Query\Criterion\BaseCriterionProcessor + + Ibexa\Rest\Server\Input\Parser\ContentType\SortClause\SortClauseProcessor: + parent: Ibexa\Contracts\Rest\Input\Parser\Query\SortClause\BaseSortClauseProcessor + + _instanceof: + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeCriterionInterface: + tags: + - 'ibexa.rest.content_type.criterion' + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeCriteriaRegistry: + arguments: + - !tagged_iterator ibexa.rest.content_type.criterion + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeId: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.ContentTypeId } + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeIdentifier: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.ContentTypeIdentifier } + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\IsSystem: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.IsSystem } + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeGroupId: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.ContentTypeGroupId } + + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContainsFieldDefinitionId: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.ContainsFieldDefinitionId } diff --git a/src/bundle/Resources/config/input_parsers.yml b/src/bundle/Resources/config/input_parsers.yml index 14dbca84f..06207dbf6 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -62,6 +62,19 @@ services: tags: - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.ContentTypeUpdate } + Ibexa\Rest\Server\Input\Parser\ContentType\RestViewInput: + parent: Ibexa\Rest\Server\Common\Parser + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.ContentTypeViewInput } + + Ibexa\Rest\Server\Input\Parser\ContentType\Query\ContentTypeQuery: + parent: Ibexa\Rest\Server\Common\Parser + arguments: + $criterionProcessor: '@Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\CriterionProcessor' + $sortClauseProcessor: '@Ibexa\Rest\Server\Input\Parser\ContentType\SortClause\SortClauseProcessor' + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.ContentTypeQuery } + Ibexa\Rest\Server\Input\Parser\FieldDefinitionCreate: parent: Ibexa\Rest\Server\Common\Parser class: Ibexa\Rest\Server\Input\Parser\FieldDefinitionCreate diff --git a/src/bundle/Resources/config/routing.yml b/src/bundle/Resources/config/routing.yml index f41451e16..f9ef57e85 100644 --- a/src/bundle/Resources/config/routing.yml +++ b/src/bundle/Resources/config/routing.yml @@ -539,6 +539,13 @@ ibexa.rest.list_content_types: _controller: Ibexa\Rest\Server\Controller\ContentType:listContentTypes methods: [GET] +ibexa.rest.content_types.view: + path: /content/types/view + methods: [ POST ] + controller: Ibexa\Rest\Server\Controller\ContentType::createView + options: + expose: true + ibexa.rest.copy_content_type: path: /content/types/{contentTypeId} defaults: diff --git a/src/bundle/Resources/config/sort_clauses.yaml b/src/bundle/Resources/config/sort_clauses.yaml new file mode 100644 index 000000000..4a5e156ba --- /dev/null +++ b/src/bundle/Resources/config/sort_clauses.yaml @@ -0,0 +1,24 @@ +services: + Ibexa\Rest\Server\Input\Parser\ContentType\SortClause\ContentTypeSortClausesRegistry: + arguments: + - !tagged_iterator ibexa.rest.content_type.sort_clause + + ibexa.rest.input.parser.internal.sortclause.id: + parent: Ibexa\Rest\Server\Common\Parser + class: Ibexa\Rest\Server\Input\Parser\SortClause\DataKeyValueObjectClass + arguments: + $dataKey: 'Id' + $valueObjectClass: 'Ibexa\Contracts\Core\Repository\Values\ContentType\Query\SortClause\Id' + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.sortclause.Id } + - { name: ibexa.rest.content_type.sort_clause } + + ibexa.rest.input.parser.internal.sortclause.identifier: + parent: Ibexa\Rest\Server\Common\Parser + class: Ibexa\Rest\Server\Input\Parser\SortClause\DataKeyValueObjectClass + arguments: + $dataKey: 'Identifier' + $valueObjectClass: 'Ibexa\Contracts\Core\Repository\Values\ContentType\Query\SortClause\Identifier' + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.sortclause.Identifier } + - { name: ibexa.rest.content_type.sort_clause } diff --git a/src/lib/Server/Controller/ContentType.php b/src/lib/Server/Controller/ContentType.php index d6e93cf03..576b25e60 100644 --- a/src/lib/Server/Controller/ContentType.php +++ b/src/lib/Server/Controller/ContentType.php @@ -4,6 +4,8 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ +declare(strict_types=1); + namespace Ibexa\Rest\Server\Controller; use Ibexa\Contracts\Core\Repository\ContentTypeService; @@ -21,7 +23,6 @@ use Ibexa\Rest\Server\Exceptions\BadRequestException; use Ibexa\Rest\Server\Exceptions\ForbiddenException; use Ibexa\Rest\Server\Values; -use JMS\TranslationBundle\Annotation\Ignore; use Symfony\Component\HttpFoundation\Request; /** @@ -905,6 +906,24 @@ public function unlinkContentTypeFromGroup($contentTypeId, $contentTypeGroupId) ); } + public function createView(Request $request): Values\ContentTypeList + { + /** @var \Ibexa\Rest\Server\Values\ContentTypeRestViewInput $viewInput */ + $viewInput = $this->inputDispatcher->parse( + new Message( + ['Content-Type' => $request->headers->get('Content-Type')], + $request->getContent() + ) + ); + + $contentTypes = $this->contentTypeService->findContentTypes($viewInput->query); + + return new Values\ContentTypeList( + $contentTypes->getContentTypes(), + '', + ); + } + /** * Converts the provided ContentTypeGroupCreateStruct to ContentTypeGroupUpdateStruct. * diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionId.php b/src/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionId.php new file mode 100644 index 000000000..ea3228e83 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionId.php @@ -0,0 +1,40 @@ + $data + * + * @throws \Ibexa\Contracts\Rest\Exceptions\Parser + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): ContainsFieldDefinitionIdCriterion + { + if (!array_key_exists(self::ID_CRITERION, $data)) { + throw new Exceptions\Parser('Invalid <' . self::ID_CRITERION . '> format'); + } + + $ids = $data[self::ID_CRITERION]; + + return new ContainsFieldDefinitionIdCriterion($ids); + } + + public function getName(): string + { + return self::ID_CRITERION; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriteriaRegistry.php b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriteriaRegistry.php new file mode 100644 index 000000000..a5d43a93b --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriteriaRegistry.php @@ -0,0 +1,31 @@ + */ + private iterable $criteria; + + /** + * @param iterable<\Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeCriterionInterface> $criteria + */ + public function __construct(iterable $criteria) + { + $this->criteria = $criteria; + } + + /** + * @return iterable<\Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeCriterionInterface> + */ + public function getCriteria(): iterable + { + return $this->criteria; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriterionInterface.php b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriterionInterface.php new file mode 100644 index 000000000..ac8ce68b7 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeCriterionInterface.php @@ -0,0 +1,14 @@ + $data + * + * @throws \Ibexa\Contracts\Rest\Exceptions\Parser + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): ContentTypeGroupIdCriterion + { + if (!array_key_exists(self::GROUP_ID, $data)) { + throw new Exceptions\Parser('Invalid <' . self::GROUP_ID . '> format'); + } + + $ids = $data[self::GROUP_ID]; + + return new ContentTypeGroupIdCriterion($ids); + } + + public function getName(): string + { + return self::GROUP_ID; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeId.php b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeId.php new file mode 100644 index 000000000..6a40a9d28 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeId.php @@ -0,0 +1,40 @@ + $data + * + * @throws \Ibexa\Contracts\Rest\Exceptions\Parser + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): ContentTypeIdCriterion + { + if (!array_key_exists(self::ID_CRITERION, $data)) { + throw new Exceptions\Parser('Invalid <' . self::ID_CRITERION . '> format'); + } + + $ids = $data[self::ID_CRITERION]; + + return new ContentTypeIdCriterion($ids); + } + + public function getName(): string + { + return self::ID_CRITERION; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifier.php b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifier.php new file mode 100644 index 000000000..7e277dfe3 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifier.php @@ -0,0 +1,40 @@ + $data + * + * @throws \Ibexa\Contracts\Rest\Exceptions\Parser + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): ContentTypeIdentifierCriterion + { + if (!array_key_exists(self::IDENTIFIER_CRITERION, $data)) { + throw new Exceptions\Parser('Invalid <' . self::IDENTIFIER_CRITERION . '> format'); + } + + $ids = $data[self::IDENTIFIER_CRITERION]; + + return new ContentTypeIdentifierCriterion($ids); + } + + public function getName(): string + { + return self::IDENTIFIER_CRITERION; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/CriterionProcessor.php b/src/lib/Server/Input/Parser/ContentType/Criterion/CriterionProcessor.php new file mode 100644 index 000000000..9f669f039 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/CriterionProcessor.php @@ -0,0 +1,37 @@ + + * + * @extends \Ibexa\Contracts\Rest\Input\Parser\Query\Criterion\BaseCriterionProcessor< + * TCriterion + * > + */ +final class CriterionProcessor extends BaseCriterionProcessor +{ + protected function getMediaTypePrefix(): string + { + return 'application/vnd.ibexa.api.internal.criterion'; + } + + protected function getParserInvalidCriterionMessage(string $criterionName): string + { + return "Invalid Criterion id <$criterionName> in "; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Criterion/IsSystem.php b/src/lib/Server/Input/Parser/ContentType/Criterion/IsSystem.php new file mode 100644 index 000000000..79dd871a4 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Criterion/IsSystem.php @@ -0,0 +1,40 @@ + $data + * + * @throws \Ibexa\Contracts\Rest\Exceptions\Parser + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): IsSystemCriterion + { + if (!array_key_exists(self::IS_SYSTEM_CRITERION, $data)) { + throw new Exceptions\Parser('Invalid <' . self::IS_SYSTEM_CRITERION . '> format'); + } + + $ids = $data[self::IS_SYSTEM_CRITERION]; + + return new IsSystemCriterion($ids); + } + + public function getName(): string + { + return self::IS_SYSTEM_CRITERION; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/Query/ContentTypeQuery.php b/src/lib/Server/Input/Parser/ContentType/Query/ContentTypeQuery.php new file mode 100644 index 000000000..fde75de04 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Query/ContentTypeQuery.php @@ -0,0 +1,151 @@ +criterionProcessor = $criterionProcessor; + $this->sortClauseProcessor = $sortClauseProcessor; + } + + /** + * @return list + */ + private function getAllowedKeys(): array + { + return [ + self::QUERY, + self::SORT_CLAUSES, + self::AGGREGATIONS, + ]; + } + + /** + * @param array $data + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + public function parse(array $data, ParsingDispatcher $parsingDispatcher): object + { + if (!empty($redundantKeys = $this->checkRedundantKeys(array_keys($data)))) { + throw new Parser( + sprintf( + 'The following properties are redundant: %s.', + implode(', ', $redundantKeys) + ) + ); + } + + $query = $this->buildQuery($data); + + if (array_key_exists('limit', $data)) { + $query->setLimit((int)$data['limit']); + } + + if (array_key_exists('offset', $data)) { + $query->setOffset((int)$data['offset']); + } + + return $query; + } + + /** + * @param array $data + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + private function buildQuery(array $data): ContentTypeQueryValueObject + { + $query = new ContentTypeQueryValueObject(); + + if (array_key_exists(self::QUERY, $data) && is_array($data[self::QUERY])) { + $criteria = $this->processCriteriaArray($data[self::QUERY]); + if (count($criteria) > 0) { + /** @var list<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\CriterionInterface> $criteria */ + $query->setCriterion(new LogicalAnd($criteria)); + } + } + + if (array_key_exists(self::SORT_CLAUSES, $data)) { + $sortClauses = $this->processSortClauses($data[self::SORT_CLAUSES]); + foreach ($sortClauses as $sortClause) { + $query->addSortClause($sortClause); + } + } + + return $query; + } + + /** + * @param array> $criteriaArray + * + * @phpstan-return array + */ + private function processCriteriaArray(array $criteriaArray): array + { + $processedCriteria = $this->criterionProcessor->processCriteria($criteriaArray); + + return iterator_to_array($processedCriteria); + } + + /** + * @param array $sortClausesArray + * + * @phpstan-return array + */ + private function processSortClauses(array $sortClausesArray): array + { + $processedSortClauses = $this->sortClauseProcessor->processSortClauses($sortClausesArray); + + return iterator_to_array($processedSortClauses); + } + + /** + * @param list $providedKeys + * + * @return array, string> + */ + private function checkRedundantKeys(array $providedKeys): array + { + $allowedKeys = array_merge( + $this->getAllowedKeys(), + ['limit', 'offset'] + ); + + return array_diff($providedKeys, $allowedKeys); + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/RestViewInput.php b/src/lib/Server/Input/Parser/ContentType/RestViewInput.php new file mode 100644 index 000000000..135f8617d --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/RestViewInput.php @@ -0,0 +1,40 @@ +languageCode = $data['languageCode'] ?? null; + + $viewInputIdentifier = self::VIEW_INPUT_IDENTIFIER; + if (!array_key_exists($viewInputIdentifier, $data)) { + throw new Exceptions\Parser('Missing ' . $viewInputIdentifier . ' attribute for .'); + } + + if (!is_array($data[$viewInputIdentifier])) { + throw new Exceptions\Parser($viewInputIdentifier . ' attribute for contains invalid data.'); + } + + $queryData = $data[$viewInputIdentifier]; + $queryMediaType = 'application/vnd.ibexa.api.internal.' . $viewInputIdentifier; + $restViewInput->query = $parsingDispatcher->parse($queryData, $queryMediaType); + + return $restViewInput; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/SortClause/ContentTypeSortClausesRegistry.php b/src/lib/Server/Input/Parser/ContentType/SortClause/ContentTypeSortClausesRegistry.php new file mode 100644 index 000000000..7458a373b --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/SortClause/ContentTypeSortClausesRegistry.php @@ -0,0 +1,31 @@ + */ + private iterable $sortClauses; + + /** + * @param iterable<\Ibexa\Rest\Server\Input\Parser\SortClause\DataKeyValueObjectClass> $sortClauses + */ + public function __construct(iterable $sortClauses) + { + $this->sortClauses = $sortClauses; + } + + /** + * @return iterable<\Ibexa\Rest\Server\Input\Parser\SortClause\DataKeyValueObjectClass> + */ + public function getSortClauses(): iterable + { + return $this->sortClauses; + } +} diff --git a/src/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessor.php b/src/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessor.php new file mode 100644 index 000000000..d325fe147 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessor.php @@ -0,0 +1,37 @@ + + * + * @extends \Ibexa\Contracts\Rest\Input\Parser\Query\SortClause\BaseSortClauseProcessor< + * TSortClause + * > + */ +final class SortClauseProcessor extends BaseSortClauseProcessor +{ + protected function getMediaTypePrefix(): string + { + return 'application/vnd.ibexa.api.internal.sortclause'; + } + + protected function getParserInvalidSortClauseMessage(string $sortClauseName): string + { + return "Invalid Sort Clause <$sortClauseName> in "; + } +} diff --git a/src/lib/Server/Input/Parser/QueryBuilder/ContentTypeQueryBuilderInterface.php b/src/lib/Server/Input/Parser/QueryBuilder/ContentTypeQueryBuilderInterface.php new file mode 100644 index 000000000..b36292c1c --- /dev/null +++ b/src/lib/Server/Input/Parser/QueryBuilder/ContentTypeQueryBuilderInterface.php @@ -0,0 +1,20 @@ +parsingDispatcher = $parsingDispatcher; + } + + public function buildQuery(Request $request, int $defaultLimit): ContentTypeQuery + { + $limit = (int)($request->get('limit') ?? $defaultLimit); + $offset = (int)($request->get('offset') ?? 0); + $filter = $request->get('filter'); + $sort = $request->get('sort'); + + return $this->parsingDispatcher->parse( + [ + 'Filter' => $filter, + 'SortClauses' => $sort ?? [], + 'limit' => $limit, + 'offset' => $offset, + ], + self::CONTENT_TYPE_QUERY_MEDIA_TYPE + ); + } +} diff --git a/src/lib/Server/Values/ContentTypeList.php b/src/lib/Server/Values/ContentTypeList.php index d13e37e5d..287ec5265 100644 --- a/src/lib/Server/Values/ContentTypeList.php +++ b/src/lib/Server/Values/ContentTypeList.php @@ -14,26 +14,19 @@ class ContentTypeList extends RestValue { /** - * Content types. - * * @var \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType[] */ - public $contentTypes; + public array $contentTypes; /** * Path which was used to fetch the list of content types. - * - * @var string */ - public $path; + public string $path; /** - * Construct. - * * @param \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType[] $contentTypes - * @param string $path */ - public function __construct(array $contentTypes, $path) + public function __construct(array $contentTypes, string $path) { $this->contentTypes = $contentTypes; $this->path = $path; diff --git a/src/lib/Server/Values/ContentTypeQueryInput.php b/src/lib/Server/Values/ContentTypeQueryInput.php new file mode 100644 index 000000000..ccbed34da --- /dev/null +++ b/src/lib/Server/Values/ContentTypeQueryInput.php @@ -0,0 +1,17 @@ + Date: Thu, 21 Aug 2025 09:40:05 +0200 Subject: [PATCH 2/3] IBX-XXXX: Add tests --- tests/bundle/Functional/ContentTypeTest.php | 32 +++++- .../ContentType/ContentTypeQueryTest.php | 100 ++++++++++++++++++ .../ContainsFieldDefinitionIdTest.php | 73 +++++++++++++ .../Criterion/ContentTypeGroupIdTest.php | 73 +++++++++++++ .../Criterion/ContentTypeIdTest.php | 73 +++++++++++++ .../Criterion/ContentTypeIdentifierTest.php | 73 +++++++++++++ .../ContentType/Criterion/IsSystemTest.php | 73 +++++++++++++ .../SortClause/SortClauseProcessorTest.php | 99 +++++++++++++++++ 8 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 tests/lib/Server/Input/Parser/ContentType/ContentTypeQueryTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionIdTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupIdTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifierTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/Criterion/IsSystemTest.php create mode 100644 tests/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessorTest.php diff --git a/tests/bundle/Functional/ContentTypeTest.php b/tests/bundle/Functional/ContentTypeTest.php index cde77a0db..272c4f741 100644 --- a/tests/bundle/Functional/ContentTypeTest.php +++ b/tests/bundle/Functional/ContentTypeTest.php @@ -10,6 +10,36 @@ class ContentTypeTest extends RESTFunctionalTestCase { + public function testCreateView(): void + { + $body = <<< XML + + + ContentTypeView + + + folder + + 10 + 0 + + +XML; + $request = $this->createHttpRequest( + 'POST', + '/api/ibexa/v2/content/types/view', + 'ContentTypeViewInput+xml', + 'ContentTypeView+json', + $body + ); + + $response = $this->sendHttpRequest($request); + $responseData = json_decode($response->getBody(), true); + + self::assertArrayHasKey('ContentTypeList', $responseData); + self::assertSame('folder', $responseData['ContentTypeList']['ContentType'][0]['identifier']); + } + /** * Covers POST /content/typegroups. */ @@ -123,7 +153,7 @@ public function testCreateContentType($contentTypeGroupHref) ); $response = $this->sendHttpRequest($request); - self::assertHttpResponseCodeEquals($response, 201); + self::assertHttpResponseCodeEquals($response, 200); self::assertHttpResponseHasHeader($response, 'Location'); $this->addCreatedElement($response->getHeader('Location')[0]); diff --git a/tests/lib/Server/Input/Parser/ContentType/ContentTypeQueryTest.php b/tests/lib/Server/Input/Parser/ContentType/ContentTypeQueryTest.php new file mode 100644 index 000000000..4193f8d91 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/ContentTypeQueryTest.php @@ -0,0 +1,100 @@ + 1, + 'offset' => 0, + 'Query' => [ + 'ContentTypeIdCriterion' => [1, 2], + 'ContentTypeIdentifierCriterion' => 'folder', + 'IsSystemCriterion' => true, + 'ContentTypeGroupIdCriterion' => 1, + 'ContainsFieldDefinitionIdCriterion' => 2, + ], + 'SortClauses' => [ + 'Identifier' => 'descending', + ], + ]; + + $parsingDispatcherMock = $this->getParsingDispatcherMock(); + self::assertInstanceOf(MockObject::class, $parsingDispatcherMock); + + $parsingDispatcherMock + ->expects(self::at(0)) + ->method('parse') + ->willReturn(new ContentTypeId([1, 2])); + + $parsingDispatcherMock + ->expects(self::at(1)) + ->method('parse') + ->willReturn(new ContentTypeIdentifier('folder')); + + $parsingDispatcherMock + ->expects(self::at(2)) + ->method('parse') + ->willReturn(new IsSystem(true)); + + $parsingDispatcherMock + ->expects(self::at(3)) + ->method('parse') + ->willReturn(new ContentTypeGroupId(1)); + + $parsingDispatcherMock + ->expects(self::at(4)) + ->method('parse') + ->willReturn(new ContainsFieldDefinitionId(1)); + + $parsingDispatcherMock + ->expects(self::at(5)) + ->method('parse') + ->willReturn(new Identifier(SortClause::SORT_DESC)); + + $result = $this->getParser()->parse($data, $this->getParsingDispatcherMock()); + + self::assertInstanceOf(ContentTypeQueryValueObject::class, $result); + self::assertSame(1, $result->getLimit()); + self::assertSame(0, $result->getOffset()); + self::assertInstanceOf(Identifier::class, $result->getSortClauses()[0]); + + $criterion = $result->getCriterion(); + self::assertInstanceOf(LogicalAnd::class, $criterion); + self::assertCount(5, $criterion->getCriteria()); + } + + protected function internalGetParser(): ContentTypeQuery + { + $criterionProcessor = new CriterionProcessor($this->getParsingDispatcherMock()); + $sortClause = new SortClauseProcessor($this->getParsingDispatcherMock()); + + return new ContentTypeQuery( + $criterionProcessor, + $sortClause, + ); + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionIdTest.php b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionIdTest.php new file mode 100644 index 000000000..7ec67635f --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionIdTest.php @@ -0,0 +1,73 @@ +parser = new ContainsFieldDefinitionId(); + } + + public function testValidInput(): void + { + self::assertEquals( + new ContainsFieldDefinitionIdCriterion([1, 5]), + $this->parser->parse( + ['ContainsFieldDefinitionIdCriterion' => [1, 5]], + $this->createMock(ParsingDispatcher::class) + ) + ); + } + + /** + * @dataProvider provideForTestInvalidInput + * + * @phpstan-param string $exceptionMessage + * @phpstan-param array{ + * array + * } $input + */ + public function testInvalidInput(string $exceptionMessage, array $input): void + { + $this->expectException(Parser::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->parser->parse( + $input, + $this->createMock(ParsingDispatcher::class) + ); + } + + /** + * @phpstan-return iterable< + * array{ + * string, + * array, + * }, + * > + */ + public function provideForTestInvalidInput(): iterable + { + yield [ + 'Invalid ', + [ + 'bar' => 'foo', + ], + ]; + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupIdTest.php b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupIdTest.php new file mode 100644 index 000000000..32b438f51 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupIdTest.php @@ -0,0 +1,73 @@ +parser = new ContentTypeGroupId(); + } + + public function testValidInput(): void + { + self::assertEquals( + new ContentTypeGroupIdCriterion([1, 5]), + $this->parser->parse( + ['ContentTypeGroupIdCriterion' => [1, 5]], + $this->createMock(ParsingDispatcher::class) + ) + ); + } + + /** + * @dataProvider provideForTestInvalidInput + * + * @phpstan-param string $exceptionMessage + * @phpstan-param array{ + * array + * } $input + */ + public function testInvalidInput(string $exceptionMessage, array $input): void + { + $this->expectException(Parser::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->parser->parse( + $input, + $this->createMock(ParsingDispatcher::class) + ); + } + + /** + * @phpstan-return iterable< + * array{ + * string, + * array, + * }, + * > + */ + public function provideForTestInvalidInput(): iterable + { + yield [ + 'Invalid ', + [ + 'bar' => 'foo', + ], + ]; + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdTest.php b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdTest.php new file mode 100644 index 000000000..b5ca9436c --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdTest.php @@ -0,0 +1,73 @@ +parser = new ContentTypeId(); + } + + public function testValidInput(): void + { + self::assertEquals( + new ContentTypeIdCriterion([1, 5]), + $this->parser->parse( + ['ContentTypeIdCriterion' => [1, 5]], + $this->createMock(ParsingDispatcher::class) + ) + ); + } + + /** + * @dataProvider provideForTestInvalidInput + * + * @phpstan-param string $exceptionMessage + * @phpstan-param array{ + * array + * } $input + */ + public function testInvalidInput(string $exceptionMessage, array $input): void + { + $this->expectException(Parser::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->parser->parse( + $input, + $this->createMock(ParsingDispatcher::class) + ); + } + + /** + * @phpstan-return iterable< + * array{ + * string, + * array, + * }, + * > + */ + public function provideForTestInvalidInput(): iterable + { + yield [ + 'Invalid ', + [ + 'bar' => 'foo', + ], + ]; + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifierTest.php b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifierTest.php new file mode 100644 index 000000000..7d8fa0721 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifierTest.php @@ -0,0 +1,73 @@ +parser = new ContentTypeIdentifier(); + } + + public function testValidInput(): void + { + self::assertEquals( + new ContentTypeIdentifierCriterion(['article', 'blog_post']), + $this->parser->parse( + ['ContentTypeIdentifierCriterion' => ['article', 'blog_post']], + $this->createMock(ParsingDispatcher::class) + ) + ); + } + + /** + * @dataProvider provideForTestInvalidInput + * + * @phpstan-param string $exceptionMessage + * @phpstan-param array{ + * array + * } $input + */ + public function testInvalidInput(string $exceptionMessage, array $input): void + { + $this->expectException(Parser::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->parser->parse( + $input, + $this->createMock(ParsingDispatcher::class) + ); + } + + /** + * @phpstan-return iterable< + * array{ + * string, + * array, + * }, + * > + */ + public function provideForTestInvalidInput(): iterable + { + yield [ + 'Invalid ', + [ + 'bar' => 'foo', + ], + ]; + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/Criterion/IsSystemTest.php b/tests/lib/Server/Input/Parser/ContentType/Criterion/IsSystemTest.php new file mode 100644 index 000000000..72ff17ed5 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/IsSystemTest.php @@ -0,0 +1,73 @@ +parser = new IsSystem(); + } + + public function testValidInput(): void + { + self::assertEquals( + new IsSystemCriterion(true), + $this->parser->parse( + ['IsSystemCriterion' => true], + $this->createMock(ParsingDispatcher::class) + ) + ); + } + + /** + * @dataProvider provideForTestInvalidInput + * + * @phpstan-param string $exceptionMessage + * @phpstan-param array{ + * array + * } $input + */ + public function testInvalidInput(string $exceptionMessage, array $input): void + { + $this->expectException(Parser::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->parser->parse( + $input, + $this->createMock(ParsingDispatcher::class) + ); + } + + /** + * @phpstan-return iterable< + * array{ + * string, + * array, + * }, + * > + */ + public function provideForTestInvalidInput(): iterable + { + yield [ + 'Invalid ', + [ + 'bar' => 'foo', + ], + ]; + } +} diff --git a/tests/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessorTest.php b/tests/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessorTest.php new file mode 100644 index 000000000..fa542977d --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/SortClause/SortClauseProcessorTest.php @@ -0,0 +1,99 @@ + */ + private SortClauseProcessorInterface $sortClauseProcessor; + + protected function setUp(): void + { + $this->sortClauseProcessor = new SortClauseProcessor( + $this->getParsingDispatcher() + ); + } + + /** + * @dataProvider provideForTestProcessSortClauses + * + * @param array $inputClauses + * @param array<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\SortClause> $expectedOutput + */ + public function testProcessSortClauses( + array $inputClauses, + array $expectedOutput + ): void { + $generator = $this->sortClauseProcessor->processSortClauses($inputClauses); + + self::assertInstanceOf( + Generator::class, + $generator + ); + + self::assertEquals( + $expectedOutput, + iterator_to_array($generator) + ); + } + + /** + * @phpstan-return iterable< + * string, + * array{ + * array, + * array<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\SortClause>, + * }, + * > + */ + public function provideForTestProcessSortClauses(): iterable + { + yield 'Input containing properly formatted clauses' => [ + [ + 'Id' => 'ascending', + 'Identifier' => 'descending', + ], + [ + new Id(SortClause::SORT_ASC), + new Identifier(SortClause::SORT_DESC), + ], + ]; + } + + private function getParsingDispatcher(): ParsingDispatcher + { + return new ParsingDispatcher( + $this->createMock(EventDispatcherInterface::class), + [ + 'application/vnd.ibexa.api.internal.sortclause.Id' => new DataKeyValueObjectClass( + 'Id', + Id::class + ), + 'application/vnd.ibexa.api.internal.sortclause.Identifier' => new DataKeyValueObjectClass( + 'Identifier', + Identifier::class + ), + ] + ); + } +} From 2a89508b9f945ebc10a62ff5f953952e934bee80 Mon Sep 17 00:00:00 2001 From: Bartek Wajda Date: Mon, 22 Sep 2025 20:52:46 +0200 Subject: [PATCH 3/3] IBX-10527: Use `BaseInputParserCollectionValidatorBuilder` --- src/bundle/Resources/config/input_parsers.yml | 2 + .../Parser/ContentType/RestViewInput.php | 45 ++++++++++++++----- ...ntentTypeRestViewInputValidatorBuilder.php | 32 +++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/lib/Server/Validation/Builder/Input/Parser/Criterion/ContentTypeRestViewInputValidatorBuilder.php diff --git a/src/bundle/Resources/config/input_parsers.yml b/src/bundle/Resources/config/input_parsers.yml index 06207dbf6..d82147b97 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -64,6 +64,8 @@ services: Ibexa\Rest\Server\Input\Parser\ContentType\RestViewInput: parent: Ibexa\Rest\Server\Common\Parser + arguments: + $validator: '@validator' tags: - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.ContentTypeViewInput } diff --git a/src/lib/Server/Input/Parser/ContentType/RestViewInput.php b/src/lib/Server/Input/Parser/ContentType/RestViewInput.php index 135f8617d..7e6e63691 100644 --- a/src/lib/Server/Input/Parser/ContentType/RestViewInput.php +++ b/src/lib/Server/Input/Parser/ContentType/RestViewInput.php @@ -8,33 +8,54 @@ namespace Ibexa\Rest\Server\Input\Parser\ContentType; -use Ibexa\Contracts\Rest\Exceptions; use Ibexa\Contracts\Rest\Input\ParsingDispatcher; +use Ibexa\Rest\Server\Exceptions\ValidationFailedException; use Ibexa\Rest\Server\Input\Parser\Criterion as CriterionParser; +use Ibexa\Rest\Server\Validation\Builder\Input\Parser\Criterion\ContentTypeRestViewInputValidatorBuilder; use Ibexa\Rest\Server\Values\ContentTypeRestViewInput; +use Symfony\Component\Validator\Validator\ValidatorInterface; final class RestViewInput extends CriterionParser { - private const VIEW_INPUT_IDENTIFIER = 'ContentTypeQuery'; + public const VIEW_INPUT_IDENTIFIER = 'ContentTypeQuery'; + + public const IDENTIFIER = 'identifier'; + + private ValidatorInterface $validator; + + public function __construct(ValidatorInterface $validator) + { + $this->validator = $validator; + } public function parse(array $data, ParsingDispatcher $parsingDispatcher): ContentTypeRestViewInput { $restViewInput = new ContentTypeRestViewInput(); $restViewInput->languageCode = $data['languageCode'] ?? null; - $viewInputIdentifier = self::VIEW_INPUT_IDENTIFIER; - if (!array_key_exists($viewInputIdentifier, $data)) { - throw new Exceptions\Parser('Missing ' . $viewInputIdentifier . ' attribute for .'); - } - - if (!is_array($data[$viewInputIdentifier])) { - throw new Exceptions\Parser($viewInputIdentifier . ' attribute for contains invalid data.'); - } + $this->validateInputArray($data); - $queryData = $data[$viewInputIdentifier]; - $queryMediaType = 'application/vnd.ibexa.api.internal.' . $viewInputIdentifier; + $queryData = $data[self::VIEW_INPUT_IDENTIFIER]; + $queryMediaType = 'application/vnd.ibexa.api.internal.' . self::VIEW_INPUT_IDENTIFIER; $restViewInput->query = $parsingDispatcher->parse($queryData, $queryMediaType); return $restViewInput; } + + /** + * @param array $data + */ + private function validateInputArray(array $data): void + { + $validatorBuilder = new ContentTypeRestViewInputValidatorBuilder($this->validator); + $validatorBuilder->validateInputArray($data); + $violations = $validatorBuilder->build()->getViolations(); + + if ($violations->count() > 0) { + throw new ValidationFailedException( + self::VIEW_INPUT_IDENTIFIER, + $violations + ); + } + } } diff --git a/src/lib/Server/Validation/Builder/Input/Parser/Criterion/ContentTypeRestViewInputValidatorBuilder.php b/src/lib/Server/Validation/Builder/Input/Parser/Criterion/ContentTypeRestViewInputValidatorBuilder.php new file mode 100644 index 000000000..83ff617b7 --- /dev/null +++ b/src/lib/Server/Validation/Builder/Input/Parser/Criterion/ContentTypeRestViewInputValidatorBuilder.php @@ -0,0 +1,32 @@ + new Assert\Required( + [ + new Assert\Type('array'), + ] + ), + RestViewInput::IDENTIFIER => new Assert\Required( + [ + new Assert\Type('string'), + ] + ), + ]; + } +}