diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 37881374c..2bf376b33 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,10 +29,6 @@ jobs: - name: Run code style check run: composer run-script check-cs -- --format=checkstyle | cs2pr - rector: - name: Run rector - uses: ibexa/gh-workflows/.github/workflows/rector.yml@main - tests: name: Unit & integration tests runs-on: "ubuntu-24.04" diff --git a/.github/workflows/rector.yaml b/.github/workflows/rector.yaml new file mode 100644 index 000000000..0ff47bbcc --- /dev/null +++ b/.github/workflows/rector.yaml @@ -0,0 +1,27 @@ +name: Rector PHP + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+' + pull_request: ~ + +jobs: + rector: + name: Run rector + runs-on: "ubuntu-22.04" + strategy: + matrix: + php: + - '8.3' + steps: + - uses: actions/checkout@v5 + + - uses: ibexa/gh-workflows/actions/composer-install@main + with: + gh-client-id: ${{ secrets.AUTOMATION_CLIENT_ID }} + gh-client-secret: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + + - name: Run rector + run: vendor/bin/rector process --dry-run --ansi diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5f1c65d14..4061669b2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -150,12 +150,6 @@ parameters: count: 1 path: src/lib/Input/FieldTypeParser.php - - - message: '#^Method Ibexa\\Rest\\Input\\Handler\\Json\:\:convert\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Input/Handler/Json.php - - message: '#^Access to an undefined property DOMNode\:\:\$data\.$#' identifier: property.notFound diff --git a/src/bundle/DependencyInjection/IbexaRestExtension.php b/src/bundle/DependencyInjection/IbexaRestExtension.php index 7edf8ebfa..6a675be1a 100644 --- a/src/bundle/DependencyInjection/IbexaRestExtension.php +++ b/src/bundle/DependencyInjection/IbexaRestExtension.php @@ -46,6 +46,8 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $loader->load('default_settings.yml'); $loader->load('serializer.yaml'); $loader->load('twig.yaml'); + $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/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.json.example b/src/bundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.json.example new file mode 100644 index 000000000..d55d775c0 --- /dev/null +++ b/src/bundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.json.example @@ -0,0 +1,20 @@ +{ + "ViewInput": { + "identifier": "ContentTypeView", + "ContentTypeQuery": { + "limit": "10", + "offset": "1", + "Query": { + "ContentTypeIdCriterion": [1,2], + "ContentTypeIdentifierCriterion": "folder", + "IsSystemCriterion": true, + "ContentTypeGroupIdCriterion": 1, + "ContainsFieldDefinitionIdCriterion": 2 + }, + "SortClauses": { + "Identifier": "descending", + "Id": "ascending" + } + } + } +} diff --git a/src/bundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.xml.example b/src/bundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.xml.example new file mode 100644 index 000000000..a1a17c269 --- /dev/null +++ b/src/bundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.xml.example @@ -0,0 +1,20 @@ + + + ContentTypeView + + 10 + 1 + + 1 + 2 + folder + true + 1 + 2 + + + descending + ascending + + + diff --git a/src/bundle/Resources/api_platform/schemas/content_types_schemas.yml b/src/bundle/Resources/api_platform/schemas/content_types_schemas.yml index b6393ab7e..d5f6ac2a3 100644 --- a/src/bundle/Resources/api_platform/schemas/content_types_schemas.yml +++ b/src/bundle/Resources/api_platform/schemas/content_types_schemas.yml @@ -400,6 +400,77 @@ schemas: properties: ContentTypeList: $ref: "#/components/schemas/ContentTypeInfoList" + ContentTypeQuery: + description: This class represents a content type query. + type: object + properties: + limit: + description: The maximum number of results to return. + type: integer + offset: + description: The offset to start returning results from. + type: integer + Query: + description: An array of filters to apply to the query. + type: object + properties: + ContentTypeIdCriterion: + oneOf: + - type: array + items: + type: integer + - type: integer + ContentTypeIdentifierCriterion: + oneOf: + - type: array + items: + type: string + - type: string + IsSystemCriterion: + type: boolean + ContentTypeGroupIdCriterion: + oneOf: + - type: array + items: + type: integer + - type: integer + ContainsFieldDefinitionIdCriterion: + oneOf: + - type: array + items: + type: integer + - type: integer + SortClauses: + description: An array of sort clauses to apply to the query. + type: object + properties: + Id: + type: string + enum: [ ascending, descending ] + Identifier: + type: string + enum: [ ascending, descending ] + ContentTypeViewInput: + description: This class represents a content type view input. + xml: + name: ViewInput + type: object + required: + - identifier + - ContentTypeQuery + properties: + identifier: + description: The identifier of a view. + type: string + ContentTypeQuery: + $ref: "#/components/schemas/ContentTypeQuery" + ContentTypeViewInputWrapper: + type: object + required: + - ViewInput + properties: + ViewInput: + $ref: "#/components/schemas/ContentTypeViewInput" Field: description: This class represents a field of a content item. type: object diff --git a/src/bundle/Resources/config/criteria.yaml b/src/bundle/Resources/config/criteria.yaml new file mode 100644 index 000000000..2ff5174f4 --- /dev/null +++ b/src/bundle/Resources/config/criteria.yaml @@ -0,0 +1,40 @@ +services: + _instanceof: + Ibexa\Rest\Server\Input\Parser\ContentType\Criterion\ContentTypeCriterionInterface: + tags: + - 'ibexa.rest.content_type.criterion' + + 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 + + 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 a1fdca24b..525067744 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -62,6 +62,21 @@ 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 + arguments: + $validator: '@validator' + 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 c4ed46bec..7facd8ee6 100644 --- a/src/bundle/Resources/config/routing.yml +++ b/src/bundle/Resources/config/routing.yml @@ -575,6 +575,13 @@ ibexa.rest.list_content_types: controller: Ibexa\Rest\Server\Controller\ContentType\ContentTypeListController::listContentTypes methods: [GET] +ibexa.rest.content_types.view: + path: /content/types/view + methods: [ POST ] + controller: Ibexa\Rest\Server\Controller\ContentType\ContentTypeCreateViewController::createView + options: + expose: true + ibexa.rest.copy_content_type: path: /content/types/{contentTypeId} controller: Ibexa\Rest\Server\Controller\ContentType\ContentTypeCopyController::copyContentType 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/ContentTypeCreateViewController.php b/src/lib/Server/Controller/ContentType/ContentTypeCreateViewController.php new file mode 100644 index 000000000..f8f8e697d --- /dev/null +++ b/src/lib/Server/Controller/ContentType/ContentTypeCreateViewController.php @@ -0,0 +1,113 @@ + false], + openapi: new Model\Operation( + summary: 'Filter content types', + description: 'Executes a query and returns a View including the results. The View input reflects the criteria model of the public PHP API.', + tags: [ + 'Type', + ], + parameters: [ + new Model\Parameter( + name: 'Accept', + in: 'header', + required: true, + description: 'The view in XML or JSON format.', + schema: [ + 'type' => 'string', + ], + ), + new Model\Parameter( + name: 'Content-Type', + in: 'header', + required: true, + description: 'The view input in XML or JSON format.', + schema: [ + 'type' => 'string', + ], + ), + ], + requestBody: new Model\RequestBody( + content: new \ArrayObject([ + 'application/vnd.ibexa.api.ContentTypeViewInput+xml' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ContentTypeViewInput', + ], + 'x-ibexa-example-file' => '@IbexaRestBundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.xml.example', + ], + 'application/vnd.ibexa.api.ContentTypeViewInput+json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ContentTypeViewInputWrapper', + ], + 'x-ibexa-example-file' => '@IbexaRestBundle/Resources/api_platform/examples/content/types/content_type_id/POST/ContentTypeCreateView.xml.example', + ], + ]), + ), + responses: [ + Response::HTTP_OK => [ + 'content' => [ + 'application/vnd.ibexa.api.ContentTypeList+xml' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ContentTypeInfoList', + ], + 'x-ibexa-example-file' => '@IbexaRestBundle/Resources/api_platform/examples/content/types/GET/ContentTypeInfoList.xml.example', + ], + 'application/vnd.ibexa.api.ContentTypeList+json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ContentTypeInfoListWrapper', + ], + 'x-ibexa-example-file' => '@IbexaRestBundle/Resources/api_platform/examples/content/types/GET/ContentTypeInfoList.json.example', + ], + ], + ], + Response::HTTP_BAD_REQUEST => [ + 'description' => 'Error - the input does not match the input schema definition.', + ], + ], + ), +)] +final class ContentTypeCreateViewController extends RestController +{ + public function __construct( + protected readonly ContentTypeService $contentTypeService + ) { + } + + 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(), + '', + ); + } +} 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..025c4991c --- /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..d1c3ab218 --- /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..9000133b8 --- /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..d71878f03 --- /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..f59a88a9a --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/Query/ContentTypeQuery.php @@ -0,0 +1,145 @@ + + */ + 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..d65eb5d96 --- /dev/null +++ b/src/lib/Server/Input/Parser/ContentType/RestViewInput.php @@ -0,0 +1,59 @@ +languageCode = $data['languageCode'] ?? null; + + $this->validateInputArray($data); + + $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/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 @@ +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/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'), + ] + ), + ]; + } +} diff --git a/src/lib/Server/Values/ContentTypeList.php b/src/lib/Server/Values/ContentTypeList.php index 102b438cc..556c6c39b 100644 --- a/src/lib/Server/Values/ContentTypeList.php +++ b/src/lib/Server/Values/ContentTypeList.php @@ -15,8 +15,6 @@ class ContentTypeList extends RestValue { /** - * Content types. - * * @var \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType[] */ public array $contentTypes; 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 @@ + + + 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']); + } } 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..51eb95a75 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/ContentTypeQueryTest.php @@ -0,0 +1,98 @@ + 1, + 'offset' => 0, + 'Query' => [ + 'ContentTypeIdCriterion' => [1, 2], + 'ContentTypeIdentifierCriterion' => 'folder', + 'IsSystemCriterion' => true, + 'ContentTypeGroupIdCriterion' => 1, + 'ContainsFieldDefinitionIdCriterion' => 2, + ], + 'SortClauses' => [ + 'Identifier' => 'descending', + ], + ]; + + $parsingDispatcherMock = $this->getParsingDispatcherMock(); + + $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..4aed6e88b --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContainsFieldDefinitionIdTest.php @@ -0,0 +1,72 @@ +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 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..46cde20ed --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeGroupIdTest.php @@ -0,0 +1,72 @@ +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 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..eaed3ed66 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdTest.php @@ -0,0 +1,72 @@ +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 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..3bd6e42b8 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/ContentTypeIdentifierTest.php @@ -0,0 +1,72 @@ +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 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..c85420b35 --- /dev/null +++ b/tests/lib/Server/Input/Parser/ContentType/Criterion/IsSystemTest.php @@ -0,0 +1,72 @@ +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 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 + ), + ] + ); + } +}