Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/Metadata/HttpOperation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int|string, string|string[]>|null */
protected $formats;
Expand Down
214 changes: 214 additions & 0 deletions src/Metadata/Query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
2 changes: 1 addition & 1 deletion src/Metadata/QueryParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')) {
Expand Down
43 changes: 43 additions & 0 deletions src/Metadata/Tests/Operation/QueryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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());
}
}
Loading
Loading