Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ 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)) {
if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT', 'QUERY'], true)) {
return $schema;
}

Expand All @@ -110,7 +110,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
210 changes: 210 additions & 0 deletions src/Metadata/Query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?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;

#[\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;
}
}
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());
}
}
48 changes: 47 additions & 1 deletion src/OpenApi/Factory/OpenApiFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -425,6 +425,10 @@ 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':
$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);
Expand Down Expand Up @@ -495,6 +499,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
));
}

if ('QUERY' === $method) {
$openapiOperation = $this->buildQueryRequestBody($openapiOperation, $operation, $resourceClass, $schema, $schemas, $schemaSerializerContext);
}

if ($openapiAttribute instanceof Webhook) {
$webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation);
continue;
Expand Down Expand Up @@ -557,6 +565,44 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas)
return $content;
}

private function buildQueryRequestBody(Operation $openapiOperation, HttpOperation $operation, string $resourceClass, Schema $schema, \ArrayObject $schemas, ?array $schemaSerializerContext): Operation
{
$queryParameters = $keptParameters = [];
foreach ($openapiOperation->getParameters() ?? [] as $parameter) {
if ('query' === $parameter->getIn()) {
$queryParameters[] = $parameter;

continue;
}

$keptParameters[] = $parameter;
}

$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<string, string>, array<string, string>}
*/
Expand Down
2 changes: 1 addition & 1 deletion src/OpenApi/Model/PathItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading