diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1157b458cb3..26fa1782621 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -90,7 +90,8 @@ public function buildSchema(string $className, string $format = 'json', string $ } // In case of FORCE_SUBSCHEMA an object can be writable through another class even though it has no POST operation - if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { + // QUERY (RFC 10008) is a safe read but still carries an input body (its criteria object). + if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT', 'QUERY'], true)) { return $schema; } @@ -104,7 +105,7 @@ public function buildSchema(string $className, string $format = 'json', string $ if (!isset($schema['$ref']) && !isset($schema['type'])) { $ref = $this->getSchemaUriPrefix($version).$definitionName; - if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { + if ($forceCollection || (!\in_array($method, ['POST', 'QUERY'], true) && $operation instanceof CollectionOperationInterface)) { $schema['type'] = 'array'; $schema['items'] = ['$ref' => $ref]; } else { diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index a8f28f22d83..8f5af4873cb 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -28,6 +28,7 @@ class HttpOperation extends Operation public const METHOD_DELETE = 'DELETE'; public const METHOD_HEAD = 'HEAD'; public const METHOD_OPTIONS = 'OPTIONS'; + public const METHOD_QUERY = 'QUERY'; /** @var array|null */ protected $formats; diff --git a/src/Metadata/Query.php b/src/Metadata/Query.php new file mode 100644 index 00000000000..e6eda6ba5d1 --- /dev/null +++ b/src/Metadata/Query.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\OpenApi\Attributes\Webhook; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\State\OptionsInterface; + +/** + * The HTTP QUERY method (RFC 10008): a safe, idempotent collection operation + * that carries its filtering criteria in the request body instead of the URI. + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class Query extends HttpOperation implements CollectionOperationInterface +{ + public function __construct( + ?string $uriTemplate = null, + ?array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + ?string $routePrefix = null, + ?string $routeName = null, + ?array $defaults = null, + ?array $requirements = null, + ?array $options = null, + ?bool $stateless = null, + ?string $sunset = null, + ?string $acceptPatch = null, + $status = null, + ?string $host = null, + ?array $schemes = null, + ?string $condition = null, + ?string $controller = null, + ?array $headers = null, + ?array $cacheHeaders = null, + ?array $paginationViaCursor = null, + ?array $hydraContext = null, + ?array $jsonldContext = null, + bool|OpenApiOperation|Webhook|null $openapi = null, + ?array $exceptionToStatus = null, + ?bool $queryParameterValidationEnabled = null, + ?array $links = null, + ?array $errors = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $order = null, + ?string $description = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?bool $collectDenormalizationErrors = null, + string|\Stringable|null $security = null, + ?string $securityMessage = null, + string|\Stringable|null $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + string|\Stringable|null $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + ?int $urlGenerationStrategy = null, + ?bool $read = null, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $contentNegotiation = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + ?string $name = null, + $provider = null, + $processor = null, + ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, + array|string|null $rules = null, + ?string $policy = null, + array|string|null $middleware = null, + ?bool $strictQueryParameterValidation = null, + protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, + array $extraProperties = [], + ?bool $throwOnNotFound = null, + private ?string $itemUriTemplate = null, + ?bool $map = null, + ) { + parent::__construct( + method: self::METHOD_QUERY, + uriTemplate: $uriTemplate, + types: $types, + formats: $formats, + inputFormats: $inputFormats, + outputFormats: $outputFormats, + uriVariables: $uriVariables, + routePrefix: $routePrefix, + routeName: $routeName, + defaults: $defaults, + requirements: $requirements, + options: $options, + stateless: $stateless, + sunset: $sunset, + acceptPatch: $acceptPatch, + status: $status, + host: $host, + schemes: $schemes, + condition: $condition, + controller: $controller, + headers: $headers, + cacheHeaders: $cacheHeaders, + paginationViaCursor: $paginationViaCursor, + hydraContext: $hydraContext, + jsonldContext: $jsonldContext, + openapi: $openapi, + exceptionToStatus: $exceptionToStatus, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, + errors: $errors, + shortName: $shortName, + class: $class, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + order: $order, + description: $description, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + deprecationReason: $deprecationReason, + filters: $filters, + validationContext: $validationContext, + input: $input, + output: $output, + mercure: $mercure, + messenger: $messenger, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read ?? true, + deserialize: $deserialize ?? false, + validate: $validate ?? false, + write: $write ?? false, + serialize: $serialize, + contentNegotiation: $contentNegotiation, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name, + provider: $provider, + processor: $processor, + parameters: $parameters, + jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, + extraProperties: $extraProperties, + rules: $rules, + policy: $policy, + middleware: $middleware, + strictQueryParameterValidation: $strictQueryParameterValidation, + hideHydraOperation: $hideHydraOperation, + stateOptions: $stateOptions, + map: $map + ); + } + + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(string $itemUriTemplate): self + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; + } +} diff --git a/src/Metadata/QueryParameter.php b/src/Metadata/QueryParameter.php index 56fcc4babe7..9f7e4395a44 100644 --- a/src/Metadata/QueryParameter.php +++ b/src/Metadata/QueryParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Metadata; -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] class QueryParameter extends Parameter implements QueryParameterInterface { } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index f55734196e5..23640a46250 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\PropertiesAwareInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; @@ -237,6 +238,32 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $propertyNames = $properties = []; $parameters = $operation->getParameters() ?? new Parameters(); + // Spike (RFC 10008 QUERY): when the operation has a dedicated input DTO, discover the + // #[QueryParameter] attributes declared on its properties so the object describes both the + // documented body (OpenApiFactory references its input schema) and the filtering criteria. + $input = $operation->getInput(); + $inputClass = \is_array($input) ? ($input['class'] ?? null) : (\is_string($input) ? $input : null); + if (null !== $inputClass && $inputClass !== $resourceClass && class_exists($inputClass)) { + foreach ((new \ReflectionClass($inputClass))->getProperties() as $reflectionProperty) { + foreach ($reflectionProperty->getAttributes(QueryParameter::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $name = $reflectionProperty->getName(); + if ($parameters->has($name, QueryParameter::class)) { + continue; + } + + $parameter = $attribute->newInstance(); + if (!$parameter->getKey()) { + $parameter = $parameter->withKey($name); + } + if (!$parameter->getProperty()) { + $parameter = $parameter->withProperty($name); + } + + $parameters->add($parameter->getKey(), $parameter); + } + } + } + // First loop we look for the :property placeholder and replace its key foreach ($parameters as $key => $parameter) { if (!str_contains($key, ':property')) { diff --git a/src/Metadata/Tests/Operation/QueryTest.php b/src/Metadata/Tests/Operation/QueryTest.php new file mode 100644 index 00000000000..95f6f16b7a1 --- /dev/null +++ b/src/Metadata/Tests/Operation/QueryTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Operation; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Query; +use PHPUnit\Framework\TestCase; + +final class QueryTest extends TestCase +{ + public function testItIsASafeCollectionOperation(): void + { + $operation = new Query(); + + $this->assertInstanceOf(CollectionOperationInterface::class, $operation); + $this->assertSame(HttpOperation::METHOD_QUERY, $operation->getMethod()); + $this->assertTrue($operation->canRead()); + $this->assertFalse($operation->canWrite()); + $this->assertFalse($operation->canValidate()); + $this->assertFalse($operation->canDeserialize()); + } + + public function testFlagsCanBeOverridden(): void + { + $operation = new Query(read: false, write: true, validate: true); + + $this->assertFalse($operation->canRead()); + $this->assertTrue($operation->canWrite()); + $this->assertTrue($operation->canValidate()); + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 6273ea8ba51..09f525294c8 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -266,7 +266,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $pathItem = $paths->getPath($path) ?? new PathItem(); } - $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method; + $forceSchemaCollection = $operation instanceof CollectionOperationInterface && \in_array($method, ['GET', 'QUERY'], true); $schema = new Schema('openapi'); $schema->setDefinitions($schemas); @@ -405,6 +405,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiOperation = $openapiOperation->withParameters($openapiParameters); + $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); $errors = null; @@ -425,6 +426,12 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); break; + case 'QUERY': + // RFC 10008: a safe collection read. It carries its criteria in the request + // body (handled below) but answers with a collection, like GET. + $successStatus = (string) $operation->getStatus() ?: 200; + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s collection', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); + break; case 'POST': $successStatus = (string) $operation->getStatus() ?: 201; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); @@ -495,6 +502,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection )); } + // RFC 10008: the HTTP QUERY method carries its criteria in the request body, not the URI + // query string. The "in: query" parameters therefore become a request body schema, while + // path and header parameters stay where they are. + if (HttpOperation::METHOD_QUERY === $method) { + $openapiOperation = $this->buildQueryRequestBody($openapiOperation, $operation, $resourceClass, $schema, $schemas, $schemaSerializerContext); + } + if ($openapiAttribute instanceof Webhook) { $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); continue; @@ -557,6 +571,46 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) return $content; } + /** + * Builds the request body of a QUERY operation (RFC 10008) and keeps only its path and header + * parameters. When the operation has an input class, the body is that object: its input schema + * is referenced like POST does. Otherwise the criteria are described as a flat object, one + * property per query parameter. Either schema is advertised for the two media types + * ParameterProvider negotiates from the body. + */ + private function buildQueryRequestBody(Operation $openapiOperation, HttpOperation $operation, string $resourceClass, Schema $schema, \ArrayObject $schemas, ?array $schemaSerializerContext): Operation + { + $queryParameters = array_filter($openapiOperation->getParameters() ?? [], static fn (Parameter $p): bool => 'query' === $p->getIn()); + $keptParameters = array_values(array_filter($openapiOperation->getParameters() ?? [], static fn (Parameter $p): bool => 'query' !== $p->getIn())); + + // A dedicated input DTO (distinct from the resource) describes the query as an object; its + // input schema is referenced. The default resource input is not a criteria object, so a + // filter-only QUERY falls back to a flat schema built from its query parameters. + $input = $operation->getInput(); + $inputClass = \is_array($input) ? ($input['class'] ?? null) : null; + if (null !== $inputClass && $inputClass !== $resourceClass) { + $inputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, 'json', Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext); + $this->appendSchemaDefinitions($schemas, $inputSchema->getDefinitions()); + $bodySchema = new \ArrayObject($inputSchema->getArrayCopy(false)); + } else { + $properties = []; + foreach ($queryParameters as $parameter) { + $properties[$parameter->getName()] = $parameter->getSchema() ?: ['type' => 'string']; + } + + $bodySchema = new \ArrayObject(['type' => 'object', 'properties' => $properties]); + } + + $content = new \ArrayObject([ + 'application/x-www-form-urlencoded' => new MediaType(schema: $bodySchema), + 'application/json' => new MediaType(schema: $bodySchema), + ]); + + return $openapiOperation + ->withParameters($keptParameters) + ->withRequestBody(new RequestBody(description: 'Query criteria carried in the request body.', content: $content, required: false)); + } + /** * @return array{array, array} */ diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index 8ff59cb3ffe..2030dcb8ca9 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -17,7 +17,7 @@ final class PathItem { use ExtensionTrait; - public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; + public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE', 'QUERY']; public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null, private ?Operation $query = null, private ?array $additionalOperations = null) { diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 05bd072cb55..342f8c12e52 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -26,6 +26,9 @@ use ApiPlatform\State\Util\ParameterParserTrait; use ApiPlatform\State\Util\RequestParser; use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; /** * Loops over parameters to: @@ -46,8 +49,14 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $this->stopwatch?->start('api_platform.provider.parameter'); $request = $context['request'] ?? null; if ($request && null === $request->attributes->get('_api_query_parameters')) { - $queryString = RequestParser::getQueryString($request); - $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); + // RFC 10008: the HTTP QUERY method carries its parameters in the request body + // rather than in the URI query string. + if ($operation instanceof HttpOperation && HttpOperation::METHOD_QUERY === $operation->getMethod()) { + $request->attributes->set('_api_query_parameters', $this->parseQueryParametersFromBody($request)); + } else { + $queryString = RequestParser::getQueryString($request); + $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); + } } if ($request && null === $request->attributes->get('_api_header_parameters')) { @@ -102,6 +111,43 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $this->decorated?->provide($operation, $uriVariables, $context); } + /** + * Reads the parameters of an HTTP QUERY request (RFC 10008) from its body, negotiated + * by content type: "application/x-www-form-urlencoded" shares the query-string grammar, + * any JSON-based media type decodes to an associative array. + * + * @return array + */ + private function parseQueryParametersFromBody(Request $request): array + { + $content = (string) $request->getContent(); + if ('' === $content) { + return []; + } + + $mimeType = trim(strtolower(explode(';', (string) $request->headers->get('Content-Type', ''))[0])); + + if ('application/x-www-form-urlencoded' === $mimeType) { + return RequestParser::parseRequestParams($content); + } + + if (str_contains($mimeType, 'json')) { + try { + $params = json_decode($content, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new BadRequestHttpException('The QUERY request body is not valid JSON.', $e); + } + + if (!\is_array($params)) { + throw new BadRequestHttpException('The QUERY request body must decode to a JSON object.'); + } + + return $params; + } + + throw new UnsupportedMediaTypeHttpException(\sprintf('The content type "%s" is not supported for the HTTP QUERY method. Use "application/x-www-form-urlencoded" or a JSON-based media type.', $mimeType ?: 'none'), headers: ['Accept-Query' => 'application/x-www-form-urlencoded, application/json']); + } + /** * TODO: uriVariables could be a Parameters instance, it'd make things easier. * diff --git a/tests/Fixtures/TestBundle/Dto/QueryMethodCriteria.php b/tests/Fixtures/TestBundle/Dto/QueryMethodCriteria.php new file mode 100644 index 00000000000..18f8b2d3ffb --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/QueryMethodCriteria.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\QueryParameter; + +/** + * Spike (RFC 10008 QUERY): a criteria DTO used as the input of a Query operation. The + * #[QueryParameter] attributes on its properties both describe the documented request body + * and drive the filtering of the underlying resource. + */ +final class QueryMethodCriteria +{ + #[QueryParameter(filter: new PartialSearchFilter())] + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Entity/QueryInputDummy.php b/tests/Fixtures/TestBundle/Entity/QueryInputDummy.php new file mode 100644 index 00000000000..c05fb98b959 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/QueryInputDummy.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Query; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\QueryMethodCriteria; +use Doctrine\ORM\Mapping as ORM; + +/** + * Spike (RFC 10008 QUERY): a resource whose Query operation declares its criteria through an input + * DTO (QueryMethodCriteria) rather than inline parameters. + */ +#[ApiResource(operations: [ + new Post(), + new Query(input: QueryMethodCriteria::class), +])] +#[ORM\Entity] +class QueryInputDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private string $name = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/QueryMethodDummy.php b/tests/Fixtures/TestBundle/Entity/QueryMethodDummy.php new file mode 100644 index 00000000000..2956e468da9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/QueryMethodDummy.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Query; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(operations: [ + new Get(), + new GetCollection(), + new Post(), + new Query( + strictQueryParameterValidation: true, + parameters: [ + 'name' => new QueryParameter(filter: new PartialSearchFilter(), property: 'name'), + ], + ), +])] +#[ORM\Entity] +class QueryMethodDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private string $name = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Functional/HttpQueryMethodInputDtoTest.php b/tests/Functional/HttpQueryMethodInputDtoTest.php new file mode 100644 index 00000000000..4b4d03683d1 --- /dev/null +++ b/tests/Functional/HttpQueryMethodInputDtoTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\QueryInputDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Spike (RFC 10008 QUERY): validates that a Query operation can declare its criteria through an + * input DTO whose properties carry #[QueryParameter], end-to-end. + */ +final class HttpQueryMethodInputDtoTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [QueryInputDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([QueryInputDummy::class]); + $this->createDummy('foo'); + $this->createDummy('bar'); + } + + private function createDummy(string $name): void + { + self::createClient()->request('POST', '/query_input_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name], + ]); + } + + public function testCriteriaFromInputDtoFiltersTheCollection(): void + { + $response = self::createClient()->request('QUERY', '/query_input_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + 'json' => ['name' => 'foo'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('foo', $data['hydra:member'][0]['name']); + } + + public function testQueryInputDtoSchemaIsReferencedInOpenApi(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $query = $json['paths']['/query_input_dummies']['query']; + $content = $query['requestBody']['content']; + $this->assertArrayHasKey('application/x-www-form-urlencoded', $content); + $this->assertArrayHasKey('application/json', $content); + + // The body schema is the input DTO schema, referencing the criteria object. + $ref = $content['application/json']['schema']['$ref'] ?? null; + $this->assertNotNull($ref, 'The QUERY request body must reference the input DTO schema.'); + $this->assertStringContainsString('QueryMethodCriteria', $ref); + } +} diff --git a/tests/Functional/HttpQueryMethodTest.php b/tests/Functional/HttpQueryMethodTest.php new file mode 100644 index 00000000000..bb09946714e --- /dev/null +++ b/tests/Functional/HttpQueryMethodTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\QueryMethodDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * The HTTP QUERY method (RFC 10008): a safe collection operation reading its + * parameters from the request body, fed to the Parameter API. + */ +final class HttpQueryMethodTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [QueryMethodDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([QueryMethodDummy::class]); + $this->createDummy('foo'); + $this->createDummy('bar'); + } + + private function createDummy(string $name): void + { + self::createClient()->request('POST', '/query_method_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name], + ]); + } + + public function testEmptyBodyReturnsFullCollection(): void + { + $response = self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(2, $data['hydra:totalItems']); + } + + public function testFiltersFromFormUrlencodedBody(): void + { + $response = self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'name=foo', + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('foo', $data['hydra:member'][0]['name']); + } + + public function testFiltersFromJsonBody(): void + { + $response = self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + 'json' => ['name' => 'foo'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('foo', $data['hydra:member'][0]['name']); + } + + public function testUnsupportedBodyContentTypeIsRejected(): void + { + self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'text/plain', + ], + 'body' => 'name=foo', + ]); + + $this->assertResponseStatusCodeSame(415); + $this->assertResponseHasHeader('Accept-Query'); + } + + public function testMalformedJsonBodyIsRejected(): void + { + self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'body' => '{"name":', + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testUnknownParameterIsRejectedByStrictValidation(): void + { + self::createClient()->request('QUERY', '/query_method_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'unknown=foo', + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testQueryOperationIsDocumentedInOpenApi(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $this->assertArrayHasKey('query', $json['paths']['/query_method_dummies'], 'The QUERY operation must be emitted under the OpenAPI 3.2 "query" path item field.'); + $query = $json['paths']['/query_method_dummies']['query']; + + // RFC 10008: parameters are carried in the request body, not the URI query string. + $this->assertArrayHasKey('requestBody', $query); + $content = $query['requestBody']['content']; + $this->assertArrayHasKey('application/x-www-form-urlencoded', $content); + $this->assertArrayHasKey('application/json', $content); + + $schema = $content['application/x-www-form-urlencoded']['schema']; + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertSame(['type' => 'string'], $schema['properties']['name']); + + // The filter must not leak into the URI query string as an "in: query" parameter. + foreach ($query['parameters'] ?? [] as $parameter) { + $this->assertNotSame('name', $parameter['name'] ?? null, 'QUERY filters must live in the request body, not as query parameters.'); + } + + // It is a safe collection read: a 200 collection response is documented. + $this->assertArrayHasKey('200', $query['responses']); + } +}