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
+ ),
+ ]
+ );
+ }
+}