• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 14635100171

24 Apr 2025 06:39AM UTC coverage: 8.271% (+0.02%) from 8.252%
14635100171

Pull #6904

github

web-flow
Merge c9cefd82e into a3e5e53ea
Pull Request #6904: feat(graphql): added support for graphql subscriptions to work for actions

0 of 73 new or added lines in 3 files covered. (0.0%)

1999 existing lines in 144 files now uncovered.

13129 of 158728 relevant lines covered (8.27%)

13.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.27
/src/OpenApi/Factory/OpenApiFactory.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\OpenApi\Factory;
15

16
use ApiPlatform\Doctrine\Odm\State\Options as DoctrineODMOptions;
17
use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions;
18
use ApiPlatform\JsonSchema\Schema;
19
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
20
use ApiPlatform\Metadata\ApiResource;
21
use ApiPlatform\Metadata\CollectionOperationInterface;
22
use ApiPlatform\Metadata\Error;
23
use ApiPlatform\Metadata\ErrorResource;
24
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
25
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
26
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
27
use ApiPlatform\Metadata\Exception\RuntimeException;
28
use ApiPlatform\Metadata\HeaderParameterInterface;
29
use ApiPlatform\Metadata\HttpOperation;
30
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
31
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
32
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
33
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
34
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
35
use ApiPlatform\OpenApi\Attributes\Webhook;
36
use ApiPlatform\OpenApi\Model\Components;
37
use ApiPlatform\OpenApi\Model\Contact;
38
use ApiPlatform\OpenApi\Model\Info;
39
use ApiPlatform\OpenApi\Model\License;
40
use ApiPlatform\OpenApi\Model\Link;
41
use ApiPlatform\OpenApi\Model\MediaType;
42
use ApiPlatform\OpenApi\Model\OAuthFlow;
43
use ApiPlatform\OpenApi\Model\OAuthFlows;
44
use ApiPlatform\OpenApi\Model\Operation;
45
use ApiPlatform\OpenApi\Model\Parameter;
46
use ApiPlatform\OpenApi\Model\PathItem;
47
use ApiPlatform\OpenApi\Model\Paths;
48
use ApiPlatform\OpenApi\Model\RequestBody;
49
use ApiPlatform\OpenApi\Model\Response;
50
use ApiPlatform\OpenApi\Model\SecurityScheme;
51
use ApiPlatform\OpenApi\Model\Server;
52
use ApiPlatform\OpenApi\Model\Tag;
53
use ApiPlatform\OpenApi\OpenApi;
54
use ApiPlatform\OpenApi\Options;
55
use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait;
56
use ApiPlatform\State\ApiResource\Error as ApiResourceError;
57
use ApiPlatform\State\Pagination\PaginationOptions;
58
use ApiPlatform\Validator\Exception\ValidationException;
59
use Psr\Container\ContainerInterface;
60
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
61
use Symfony\Component\PropertyInfo\Type as LegacyType;
62
use Symfony\Component\Routing\RouteCollection;
63
use Symfony\Component\Routing\RouterInterface;
64
use Symfony\Component\TypeInfo\Type;
65
use Symfony\Component\TypeInfo\TypeIdentifier;
66

67
/**
68
 * Generates an Open API v3 specification.
69
 */
70
final class OpenApiFactory implements OpenApiFactoryInterface
71
{
72
    use NormalizeOperationNameTrait;
73
    use TypeFactoryTrait;
74

75
    public const BASE_URL = 'base_url';
76
    public const API_PLATFORM_TAG = 'x-apiplatform-tag';
77
    public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses';
78
    private readonly Options $openApiOptions;
79
    private readonly PaginationOptions $paginationOptions;
80
    private ?RouteCollection $routeCollection = null;
81
    private ?ContainerInterface $filterLocator = null;
82
    /**
83
     * @var array<string|class-string, ErrorResource>
84
     */
85
    private array $localErrorResourceCache = [];
86

87
    /**
88
     * @param array<string, string[]> $formats
89
     */
90
    public function __construct(
91
        private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
92
        private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
93
        private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
94
        private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
95
        private readonly SchemaFactoryInterface $jsonSchemaFactory,
96
        ?ContainerInterface $filterLocator = null,
97
        private readonly array $formats = [],
98
        ?Options $openApiOptions = null,
99
        ?PaginationOptions $paginationOptions = null,
100
        private readonly ?RouterInterface $router = null,
101
        private readonly array $errorFormats = [],
102
    ) {
103
        $this->filterLocator = $filterLocator;
1,014✔
104
        $this->openApiOptions = $openApiOptions ?: new Options('API Platform');
1,014✔
105
        $this->paginationOptions = $paginationOptions ?: new PaginationOptions();
1,014✔
106
    }
107

108
    /**
109
     * {@inheritdoc}
110
     *
111
     * You can filter openapi operations with the `x-apiplatform-tag` on an OpenApi Operation using the `filter_tags`.
112
     *
113
     * @param array{base_url?: string, filter_tags?: string[]}&array<string, mixed> $context
114
     */
115
    public function __invoke(array $context = []): OpenApi
116
    {
117
        $baseUrl = $context[self::BASE_URL] ?? '/';
32✔
118
        $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail());
32✔
119
        $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl());
32✔
120
        $info = new Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license);
32✔
121
        $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)];
32✔
122
        $paths = new Paths();
32✔
123
        $schemas = new \ArrayObject();
32✔
124
        $webhooks = new \ArrayObject();
32✔
125
        $tags = [];
32✔
126

127
        foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
32✔
128
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
32✔
129
            foreach ($resourceMetadataCollection as $resourceMetadata) {
32✔
130
                $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context);
32✔
131
            }
132
        }
133

134
        $securitySchemes = $this->getSecuritySchemes();
32✔
135
        $securityRequirements = [];
32✔
136

137
        foreach (array_keys($securitySchemes) as $key) {
32✔
138
            $securityRequirements[] = [$key => []];
32✔
139
        }
140

141
        $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: [];
32✔
142

143
        return new OpenApi(
32✔
144
            $info,
32✔
145
            $servers,
32✔
146
            $paths,
32✔
147
            new Components(
32✔
148
                $schemas,
32✔
149
                new \ArrayObject(),
32✔
150
                new \ArrayObject(),
32✔
151
                new \ArrayObject(),
32✔
152
                new \ArrayObject(),
32✔
153
                new \ArrayObject(),
32✔
154
                new \ArrayObject($securitySchemes)
32✔
155
            ),
32✔
156
            $securityRequirements,
32✔
157
            $globalTags,
32✔
158
            null,
32✔
159
            null,
32✔
160
            $webhooks
32✔
161
        );
32✔
162
    }
163

164
    private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array &$tags, array $context = []): void
165
    {
166
        if (0 === $resource->getOperations()->count()) {
32✔
167
            return;
×
168
        }
169

170
        $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class);
32✔
171
        $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity');
32✔
172

173
        // This filters on our extension x-apiplatform-tag as the openapi operation tag is used for ordering operations
174
        $filteredTags = $context['filter_tags'] ?? [];
32✔
175
        if (!\is_array($filteredTags)) {
32✔
176
            $filteredTags = [$filteredTags];
2✔
177
        }
178

179
        foreach ($resource->getOperations() as $operationName => $operation) {
32✔
180
            $resourceShortName = $operation->getShortName() ?? $operation;
32✔
181
            // No path to return
182
            if (null === $operation->getUriTemplate() && null === $operation->getRouteName()) {
32✔
183
                continue;
×
184
            }
185

186
            $openapiAttribute = $operation->getOpenapi();
32✔
187

188
            // Operation ignored from OpenApi
189
            if ($operation instanceof HttpOperation && false === $openapiAttribute) {
32✔
190
                continue;
32✔
191
            }
192

193
            // See https://github.com/api-platform/core/issues/6993 we would like to allow only `false` but as we typed `bool` we have this check
194
            $operationTag = !\is_object($openapiAttribute) ? [] : ($openapiAttribute->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []);
32✔
195
            if (!\is_array($operationTag)) {
32✔
196
                $operationTag = [$operationTag];
26✔
197
            }
198

199
            if ($filteredTags && $filteredTags !== array_intersect($filteredTags, $operationTag)) {
32✔
200
                continue;
4✔
201
            }
202

203
            $resourceClass = $operation->getClass() ?? $resource->getClass();
32✔
204
            $routeName = $operation->getRouteName() ?? $operation->getName();
32✔
205

206
            if (!$this->routeCollection && $this->router) {
32✔
207
                $this->routeCollection = $this->router->getRouteCollection();
32✔
208
            }
209

210
            if ($this->routeCollection && $routeName && $route = $this->routeCollection->get($routeName)) {
32✔
211
                $path = $route->getPath();
32✔
212
            } else {
213
                $path = rtrim($operation->getRoutePrefix() ?? '', '/').'/'.ltrim($operation->getUriTemplate() ?? '', '/');
×
214
            }
215

216
            $path = $this->getPath($path);
32✔
217
            $method = $operation->getMethod() ?? 'GET';
32✔
218

219
            if (!\in_array($method, PathItem::$methods, true)) {
32✔
220
                continue;
×
221
            }
222

223
            $pathItem = null;
32✔
224

225
            if ($openapiAttribute instanceof Webhook) {
32✔
UNCOV
226
                $pathItem = $openapiAttribute->getPathItem() ?: new PathItem();
12✔
UNCOV
227
                $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation();
12✔
228
            } elseif (!\is_object($openapiAttribute)) {
32✔
229
                $openapiOperation = new Operation();
28✔
230
            } else {
231
                $openapiOperation = $openapiAttribute;
26✔
232
            }
233

234
            // Complete with defaults
235
            $openapiOperation = new Operation(
32✔
236
                operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName),
32✔
237
                tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName],
32✔
238
                responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [],
32✔
239
                summary: null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface),
32✔
240
                description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface),
32✔
241
                externalDocs: $openapiOperation->getExternalDocs(),
32✔
242
                parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [],
32✔
243
                requestBody: $openapiOperation->getRequestBody(),
32✔
244
                callbacks: $openapiOperation->getCallbacks(),
32✔
245
                deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(),
32✔
246
                security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null,
32✔
247
                servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null,
32✔
248
                extensionProperties: $openapiOperation->getExtensionProperties(),
32✔
249
            );
32✔
250

251
            foreach ($openapiOperation->getTags() as $v) {
32✔
252
                $tags[$v] = new Tag(name: $v, description: $resource->getDescription());
32✔
253
            }
254

255
            [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation);
32✔
256

257
            if ($path) {
32✔
258
                $pathItem = $paths->getPath($path) ?: new PathItem();
32✔
259
            } elseif (!$pathItem) {
×
260
                $pathItem = new PathItem();
×
261
            }
262

263
            $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method;
32✔
264
            $schema = new Schema('openapi');
32✔
265
            $schema->setDefinitions($schemas);
32✔
266

267
            $operationOutputSchemas = [];
32✔
268

269
            foreach ($responseMimeTypes as $operationFormat) {
32✔
270
                $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection);
32✔
271
                $operationOutputSchemas[$operationFormat] = $operationOutputSchema;
32✔
272
                $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions());
32✔
273
            }
274

275
            // Set up parameters
276
            $openapiParameters = $openapiOperation->getParameters();
32✔
277
            foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) {
32✔
278
                if ($uriVariable->getExpandedValue() ?? false) {
30✔
279
                    continue;
16✔
280
                }
281

282
                $parameter = new Parameter($parameterName, 'path', $uriVariable->getDescription() ?? "$resourceShortName identifier", $uriVariable->getRequired() ?? true, false, false, $uriVariable->getSchema() ?? ['type' => 'string']);
30✔
283

284
                if ($linkParameter = $uriVariable->getOpenApi()) {
30✔
285
                    $parameter = $this->mergeParameter($parameter, $linkParameter);
×
286
                }
287

288
                if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
30✔
289
                    $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
290
                    continue;
×
291
                }
292

293
                $openapiParameters[] = $parameter;
30✔
294
            }
295

296
            $openapiOperation = $openapiOperation->withParameters($openapiParameters);
32✔
297

298
            if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) {
32✔
299
                foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) {
32✔
300
                    if ($operationParameter = $this->hasParameter($openapiOperation, $parameter)) {
32✔
301
                        continue;
×
302
                    }
303

304
                    $openapiOperation = $openapiOperation->withParameter($parameter);
32✔
305
                }
306
            }
307

308
            $entityClass = $this->getFilterClass($operation);
32✔
309
            $openapiParameters = $openapiOperation->getParameters();
32✔
310
            foreach ($operation->getParameters() ?? [] as $key => $p) {
32✔
311
                if (false === $p->getOpenApi()) {
18✔
312
                    continue;
18✔
313
                }
314

315
                if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) {
14✔
316
                    $filter = $this->filterLocator->get($f);
14✔
317

318
                    if ($d = $filter->getDescription($entityClass)) {
14✔
319
                        foreach ($d as $name => $description) {
14✔
320
                            if ($prop = $p->getProperty()) {
14✔
UNCOV
321
                                $name = str_replace($prop, $key, $name);
12✔
322
                            }
323

324
                            $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f);
14✔
325
                        }
326

327
                        continue;
14✔
328
                    }
329
                }
330

331
                $in = $p instanceof HeaderParameterInterface ? 'header' : 'query';
14✔
332
                $defaultParameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']);
14✔
333

334
                $linkParameter = $p->getOpenApi();
14✔
335
                if (null === $linkParameter) {
14✔
336
                    if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) {
14✔
337
                        $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter);
×
338
                    } else {
339
                        $openapiParameters[] = $defaultParameter;
14✔
340
                    }
341

342
                    continue;
14✔
343
                }
344

345
                if (\is_array($linkParameter)) {
14✔
UNCOV
346
                    foreach ($linkParameter as $lp) {
12✔
UNCOV
347
                        $parameter = $this->mergeParameter($defaultParameter, $lp);
12✔
UNCOV
348
                        if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
12✔
349
                            $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
350
                            continue;
×
351
                        }
352

UNCOV
353
                        $openapiParameters[] = $parameter;
12✔
354
                    }
UNCOV
355
                    continue;
12✔
356
                }
357

358
                $parameter = $this->mergeParameter($defaultParameter, $linkParameter);
14✔
359
                if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
14✔
360
                    $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
361
                    continue;
×
362
                }
363
                $openapiParameters[] = $parameter;
14✔
364
            }
365

366
            $openapiOperation = $openapiOperation->withParameters($openapiParameters);
32✔
367
            $existingResponses = $openapiOperation->getResponses() ?: [];
32✔
368
            $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses();
32✔
369
            $errors = null;
32✔
370
            if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) {
32✔
371
                /** @var array<class-string|string, Error> */
UNCOV
372
                $errorOperations = [];
12✔
UNCOV
373
                foreach ($errors as $error) {
12✔
UNCOV
374
                    $errorOperations[$error] = $this->getErrorResource($error);
12✔
375
                }
376

UNCOV
377
                $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation);
12✔
378
            }
379

380
            if ($overrideResponses || !$existingResponses) {
32✔
381
                // Create responses
382
                switch ($method) {
383
                    case 'GET':
32✔
384
                        $successStatus = (string) $operation->getStatus() ?: 200;
32✔
385
                        $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas);
32✔
386
                        break;
32✔
387
                    case 'POST':
28✔
388
                        $successStatus = (string) $operation->getStatus() ?: 201;
28✔
389
                        $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection);
28✔
390

391
                        if (null === $errors) {
28✔
392
                            $openapiOperation = $this->addOperationErrors($openapiOperation, [
28✔
393
                                $defaultError->withStatus(400)->withDescription('Invalid input'),
28✔
394
                                $defaultValidationError,
28✔
395
                            ], $resourceMetadataCollection, $schema, $schemas, $operation);
28✔
396
                        }
397
                        break;
28✔
398
                    case 'PATCH':
26✔
399
                    case 'PUT':
26✔
400
                        $successStatus = (string) $operation->getStatus() ?: 200;
26✔
401
                        $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection);
26✔
402

403
                        if (null === $errors) {
26✔
404
                            $openapiOperation = $this->addOperationErrors($openapiOperation, [
26✔
405
                                $defaultError->withStatus(400)->withDescription('Invalid input'),
26✔
406
                                $defaultValidationError,
26✔
407
                            ], $resourceMetadataCollection, $schema, $schemas, $operation);
26✔
408
                        }
409
                        break;
26✔
410
                    case 'DELETE':
26✔
411
                        $successStatus = (string) $operation->getStatus() ?: 204;
26✔
412
                        $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation);
26✔
413
                        break;
26✔
414
                }
415
            }
416

417
            if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) {
32✔
UNCOV
418
                $openapiOperation = $this->addOperationErrors($openapiOperation, [
12✔
UNCOV
419
                    $defaultError->withStatus(403)->withDescription('Forbidden'),
12✔
UNCOV
420
                ], $resourceMetadataCollection, $schema, $schemas, $operation);
12✔
421
            }
422

423
            if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) {
32✔
424
                $openapiOperation = $this->addOperationErrors($openapiOperation, [
30✔
425
                    $defaultError->withStatus(404)->withDescription('Not found'),
30✔
426
                ], $resourceMetadataCollection, $schema, $schemas, $operation);
30✔
427
            }
428

429
            if (!$openapiOperation->getResponses()) {
32✔
430
                $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error'));
×
431
            }
432

433
            if (
434
                \in_array($method, ['PATCH', 'PUT', 'POST'], true)
32✔
435
                && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class']))
32✔
436
            ) {
437
                $content = $openapiOperation->getRequestBody()?->getContent();
28✔
438
                if (null === $content) {
28✔
439
                    $operationInputSchemas = [];
28✔
440
                    foreach ($requestMimeTypes as $operationFormat) {
28✔
441
                        $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection);
28✔
442
                        $operationInputSchemas[$operationFormat] = $operationInputSchema;
28✔
443
                        $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions());
28✔
444
                    }
445
                    $content = $this->buildContent($requestMimeTypes, $operationInputSchemas);
28✔
446
                }
447

448
                $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(
28✔
449
                    description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName),
28✔
450
                    content: $content,
28✔
451
                    required: $openapiOperation->getRequestBody()?->getRequired() ?? true,
28✔
452
                ));
28✔
453
            }
454

455
            if ($openapiAttribute instanceof Webhook) {
32✔
UNCOV
456
                $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation);
12✔
UNCOV
457
                continue;
12✔
458
            }
459

460
            // We merge content types for errors, maybe that this logic could be applied to every resources at some point
461
            if ($operation instanceof Error && ($existingPathItem = $paths->getPath($path)) && ($existingOperation = $existingPathItem->getGet()) && ($currentResponse = $openapiOperation->getResponses()[200] ?? null)) {
32✔
462
                $errorResponse = $existingOperation->getResponses()[200];
×
463
                $currentResponseContent = $currentResponse->getContent();
×
464

465
                foreach ($errorResponse->getContent() as $mime => $content) {
×
466
                    $currentResponseContent[$mime] = $content;
×
467
                }
468

469
                $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent));
×
470
            }
471

472
            $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation));
32✔
473
        }
474
    }
475

476
    private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation
477
    {
478
        if (isset($existingResponses[$status])) {
32✔
479
            return $openapiOperation;
×
480
        }
481
        $responseLinks = $responseContent = null;
32✔
482
        if ($responseMimeTypes && $operationOutputSchemas) {
32✔
483
            $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas);
32✔
484
        }
485
        if ($resourceMetadataCollection && $operation) {
32✔
486
            $responseLinks = $this->getLinks($resourceMetadataCollection, $operation);
30✔
487
        }
488

489
        return $openapiOperation->withResponse($status, new Response($description, $responseContent, null, $responseLinks));
32✔
490
    }
491

492
    /**
493
     * @param array<string, string> $responseMimeTypes
494
     * @param array<string, Schema> $operationSchemas
495
     *
496
     * @return \ArrayObject<MediaType>
497
     */
498
    private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject
499
    {
500
        /** @var \ArrayObject<MediaType> $content */
501
        $content = new \ArrayObject();
32✔
502

503
        foreach ($responseMimeTypes as $mimeType => $format) {
32✔
504
            $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false)));
32✔
505
        }
506

507
        return $content;
32✔
508
    }
509

510
    /**
511
     * @return array[array<string, string>, array<string, string>]
512
     */
513
    private function getMimeTypes(HttpOperation $operation): array
514
    {
515
        $requestFormats = $operation->getInputFormats() ?: [];
32✔
516
        $responseFormats = $operation->getOutputFormats() ?: [];
32✔
517

518
        $requestMimeTypes = $this->flattenMimeTypes($requestFormats);
32✔
519
        $responseMimeTypes = $this->flattenMimeTypes($responseFormats);
32✔
520

521
        return [$requestMimeTypes, $responseMimeTypes];
32✔
522
    }
523

524
    /**
525
     * @param array<string, string[]> $responseFormats
526
     *
527
     * @return array<string, string>
528
     */
529
    private function flattenMimeTypes(array $responseFormats): array
530
    {
531
        $responseMimeTypes = [];
32✔
532
        foreach ($responseFormats as $responseFormat => $mimeTypes) {
32✔
533
            foreach ($mimeTypes as $mimeType) {
32✔
534
                $responseMimeTypes[$mimeType] = $responseFormat;
32✔
535
            }
536
        }
537

538
        return $responseMimeTypes;
32✔
539
    }
540

541
    /**
542
     * Gets the path for an operation.
543
     *
544
     * If the path ends with the optional _format parameter, it is removed
545
     * as optional path parameters are not yet supported.
546
     *
547
     * @see https://github.com/OAI/OpenAPI-Specification/issues/93
548
     */
549
    private function getPath(string $path): string
550
    {
551
        // Handle either API Platform's URI Template (rfc6570) or Symfony's route
552
        if (str_ends_with($path, '{._format}') || str_ends_with($path, '.{_format}')) {
32✔
553
            $path = substr($path, 0, -10);
32✔
554
        }
555

556
        return str_starts_with($path, '/') ? $path : '/'.$path;
32✔
557
    }
558

559
    private function getPathDescription(string $resourceShortName, string $method, bool $isCollection): string
560
    {
561
        switch ($method) {
562
            case 'GET':
32✔
563
                $pathSummary = $isCollection ? 'Retrieves the collection of %s resources.' : 'Retrieves a %s resource.';
32✔
564
                break;
32✔
565
            case 'POST':
28✔
566
                $pathSummary = 'Creates a %s resource.';
28✔
567
                break;
28✔
568
            case 'PATCH':
26✔
569
                $pathSummary = 'Updates the %s resource.';
26✔
570
                break;
26✔
571
            case 'PUT':
26✔
572
                $pathSummary = 'Replaces the %s resource.';
24✔
573
                break;
24✔
574
            case 'DELETE':
26✔
575
                $pathSummary = 'Removes the %s resource.';
26✔
576
                break;
26✔
577
            default:
578
                return $resourceShortName;
×
579
        }
580

581
        return \sprintf($pathSummary, $resourceShortName);
32✔
582
    }
583

584
    /**
585
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject.
586
     *
587
     * @return \ArrayObject<Link>
588
     */
589
    private function getLinks(ResourceMetadataCollection $resourceMetadataCollection, HttpOperation $currentOperation): \ArrayObject
590
    {
591
        /** @var \ArrayObject<Link> $links */
592
        $links = new \ArrayObject();
30✔
593

594
        // Only compute get links for now
595
        foreach ($resourceMetadataCollection as $resource) {
30✔
596
            foreach ($resource->getOperations() as $operationName => $operation) {
30✔
597
                $parameters = [];
30✔
598
                $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
30✔
599
                if (
600
                    $operationName === $operation->getName()
30✔
601
                    || isset($links[$operationName])
30✔
602
                    || $operation instanceof CollectionOperationInterface
30✔
603
                    || 'GET' !== $method
30✔
604
                ) {
605
                    continue;
30✔
606
                }
607

608
                // Operation ignored from OpenApi
609
                if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) {
×
610
                    continue;
×
611
                }
612

613
                $operationUriVariables = $operation->getUriVariables();
×
614
                foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) {
×
615
                    if (!isset($operationUriVariables[$parameterName])) {
×
616
                        continue;
×
617
                    }
618

619
                    if ($operationUriVariables[$parameterName]->getIdentifiers() === $uriVariableDefinition->getIdentifiers() && $operationUriVariables[$parameterName]->getFromClass() === $uriVariableDefinition->getFromClass()) {
×
620
                        $parameters[$parameterName] = '$request.path.'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id');
×
621
                    }
622
                }
623

624
                foreach ($operationUriVariables ?? [] as $parameterName => $uriVariableDefinition) {
×
625
                    if (isset($parameters[$parameterName])) {
×
626
                        continue;
×
627
                    }
628

629
                    if ($uriVariableDefinition->getFromClass() === $currentOperation->getClass()) {
×
630
                        $parameters[$parameterName] = '$response.body#/'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id');
×
631
                    }
632
                }
633

634
                $links[$operationName] = new Link(
×
635
                    $operationName,
×
636
                    new \ArrayObject($parameters),
×
637
                    null,
×
638
                    $operation->getDescription() ?? ''
×
639
                );
×
640
            }
641
        }
642

643
        return $links;
30✔
644
    }
645

646
    /**
647
     * Gets parameters corresponding to enabled filters.
648
     */
649
    private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array
650
    {
651
        $parameters = [];
32✔
652
        $resourceFilters = $operation->getFilters();
32✔
653
        $entityClass = $this->getFilterClass($operation);
32✔
654

655
        foreach ($resourceFilters ?? [] as $filterId) {
32✔
656
            if (!$this->filterLocator->has($filterId)) {
16✔
657
                continue;
×
658
            }
659

660
            $filter = $this->filterLocator->get($filterId);
16✔
661
            foreach ($filter->getDescription($entityClass) as $name => $description) {
16✔
662
                $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId);
16✔
663
            }
664
        }
665

666
        return $parameters;
32✔
667
    }
668

669
    private function getFilterClass(HttpOperation $operation): ?string
670
    {
671
        $entityClass = $operation->getClass();
32✔
672
        if ($options = $operation->getStateOptions()) {
32✔
673
            if ($options instanceof DoctrineOptions && $options->getEntityClass()) {
16✔
UNCOV
674
                return $options->getEntityClass();
12✔
675
            }
676

677
            if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) {
16✔
UNCOV
678
                return $options->getDocumentClass();
12✔
679
            }
680
        }
681

682
        return $entityClass;
32✔
683
    }
684

685
    /**
686
     * @param array<string, mixed> $description
687
     */
688
    private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter
689
    {
690
        if (isset($description['swagger'])) {
18✔
691
            trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter, $shortName));
×
692
        }
693

694
        if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) {
18✔
695
            $schema = $description['schema'] ?? [];
18✔
696

697
            if (method_exists(PropertyInfoExtractor::class, 'getType')) {
18✔
698
                if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) {
18✔
699
                    $type = Type::builtin($description['type']);
18✔
700
                    if ($description['is_collection'] ?? false) {
18✔
701
                        $type = Type::array($type, Type::int());
18✔
702
                    }
703

704
                    $schema += $this->getType($type);
18✔
705
                }
706
            // TODO: remove in 5.x
707
            } else {
708
                if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) {
×
709
                    $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false));
×
710
                }
711
            }
712

713
            if (!isset($schema['type'])) {
18✔
714
                $schema['type'] = 'string';
16✔
715
            }
716

717
            $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY;
18✔
718
            $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT;
18✔
719

720
            $style = 'array' === ($schema['type'] ?? null) && \in_array(
18✔
721
                $description['type'],
18✔
722
                [$arrayValueType, $objectValueType],
18✔
723
                true
18✔
724
            ) ? 'deepObject' : 'form';
18✔
725

726
            $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false);
18✔
727

728
            if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) {
18✔
729
                $parameter = $parameter->withDescription($str);
×
730
            }
731

732
            if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) {
18✔
UNCOV
733
                $parameter = $parameter->withRequired($required);
12✔
734
            }
735

736
            return $parameter->withSchema($schema);
18✔
737
        }
738

739
        trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName));
×
740

741
        $schema = $description['schema'] ?? null;
×
742

743
        if (!$schema) {
×
744
            if (method_exists(PropertyInfoExtractor::class, 'getType')) {
×
745
                if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) {
×
746
                    $type = Type::builtin($description['type']);
×
747
                    if ($description['is_collection'] ?? false) {
×
748
                        $type = Type::array($type, key: Type::int());
×
749
                    }
750
                    $schema = $this->getType($type);
×
751
                } else {
752
                    $schema = ['type' => 'string'];
×
753
                }
754
            // TODO: remove in 5.x
755
            } else {
756
                $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true)
×
757
                    ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false))
×
758
                    : ['type' => 'string'];
×
759
            }
760
        }
761

762
        $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY;
×
763
        $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT;
×
764

765
        return new Parameter(
×
766
            $name,
×
767
            'query',
×
768
            $description['description'] ?? '',
×
769
            $description['required'] ?? false,
×
770
            $description['openapi']['deprecated'] ?? false,
×
771
            $description['openapi']['allowEmptyValue'] ?? true,
×
772
            $schema,
×
773
            'array' === $schema['type'] && \in_array(
×
774
                $description['type'],
×
775
                [$arrayValueType, $objectValueType],
×
776
                true
×
777
            ) ? 'deepObject' : 'form',
×
778
            $description['openapi']['explode'] ?? ('array' === $schema['type']),
×
779
            $description['openapi']['allowReserved'] ?? false,
×
780
            $description['openapi']['example'] ?? null,
×
781
            isset(
×
782
                $description['openapi']['examples']
×
783
            ) ? new \ArrayObject($description['openapi']['examples']) : null
×
784
        );
×
785
    }
786

787
    private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array
788
    {
789
        if (!$this->paginationOptions->isPaginationEnabled()) {
32✔
790
            return [];
×
791
        }
792

793
        $parameters = [];
32✔
794

795
        if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) {
32✔
796
            $parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]);
32✔
797

798
            if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) {
32✔
799
                $schema = [
32✔
800
                    'type' => 'integer',
32✔
801
                    'default' => $operation->getPaginationItemsPerPage() ?? $this->paginationOptions->getItemsPerPage(),
32✔
802
                    'minimum' => 0,
32✔
803
                ];
32✔
804

805
                if (null !== $maxItemsPerPage = ($operation->getPaginationMaximumItemsPerPage() ?? $this->paginationOptions->getMaximumItemsPerPage())) {
32✔
806
                    $schema['maximum'] = $maxItemsPerPage;
×
807
                }
808

809
                $parameters[] = new Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema);
32✔
810
            }
811
        }
812

813
        if ($operation->getPaginationClientEnabled() ?? $this->paginationOptions->isPaginationClientEnabled()) {
32✔
814
            $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']);
32✔
815
        }
816

817
        return $parameters;
32✔
818
    }
819

820
    private function getOauthSecurityScheme(): SecurityScheme
821
    {
822
        $oauthFlow = new OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl() ?: null, $this->openApiOptions->getOAuthRefreshUrl() ?: null, new \ArrayObject($this->openApiOptions->getOAuthScopes()));
32✔
823
        $description = \sprintf(
32✔
824
            'OAuth 2.0 %s Grant',
32✔
825
            strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow())))
32✔
826
        );
32✔
827
        $implicit = $password = $clientCredentials = $authorizationCode = null;
32✔
828

829
        switch ($this->openApiOptions->getOAuthFlow()) {
32✔
830
            case 'implicit':
32✔
831
                $implicit = $oauthFlow;
32✔
832
                break;
32✔
833
            case 'password':
×
834
                $password = $oauthFlow;
×
835
                break;
×
836
            case 'application':
×
837
            case 'clientCredentials':
×
838
                $clientCredentials = $oauthFlow;
×
839
                break;
×
840
            case 'accessCode':
×
841
            case 'authorizationCode':
×
842
                $authorizationCode = $oauthFlow;
×
843
                break;
×
844
            default:
845
                throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode');
×
846
        }
847

848
        return new SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, null, null, new OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null);
32✔
849
    }
850

851
    private function getSecuritySchemes(): array
852
    {
853
        $securitySchemes = [];
32✔
854

855
        if ($this->openApiOptions->getOAuthEnabled()) {
32✔
856
            $securitySchemes['oauth'] = $this->getOauthSecurityScheme();
32✔
857
        }
858

859
        foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) {
32✔
860
            $description = \sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']);
32✔
861
            $securitySchemes[$key] = new SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type']);
32✔
862
        }
863

864
        foreach ($this->openApiOptions->getHttpAuth() as $key => $httpAuth) {
32✔
865
            $description = \sprintf('Value for the http %s parameter.', $httpAuth['scheme']);
×
866
            $securitySchemes[$key] = new SecurityScheme('http', $description, null, null, $httpAuth['scheme'], $httpAuth['bearerFormat'] ?? null);
×
867
        }
868

869
        return $securitySchemes;
32✔
870
    }
871

872
    /**
873
     * @param \ArrayObject<string, mixed> $schemas
874
     * @param \ArrayObject<string, mixed> $definitions
875
     */
876
    private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void
877
    {
878
        foreach ($definitions as $key => $value) {
32✔
879
            $schemas[$key] = $value;
32✔
880
        }
881
    }
882

883
    /**
884
     * @return array{0: int, 1: Parameter}|null
885
     */
886
    private function hasParameter(Operation $operation, Parameter $parameter): ?array
887
    {
888
        foreach ($operation->getParameters() as $key => $existingParameter) {
32✔
889
            if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) {
32✔
890
                return [$key, $existingParameter];
×
891
            }
892
        }
893

894
        return null;
32✔
895
    }
896

897
    private function mergeParameter(Parameter $actual, Parameter $defined): Parameter
898
    {
899
        foreach (
900
            [
14✔
901
                'name',
14✔
902
                'in',
14✔
903
                'description',
14✔
904
                'required',
14✔
905
                'deprecated',
14✔
906
                'allowEmptyValue',
14✔
907
                'style',
14✔
908
                'explode',
14✔
909
                'allowReserved',
14✔
910
                'example',
14✔
911
            ] as $method
14✔
912
        ) {
913
            $newValue = $defined->{"get$method"}();
14✔
914
            if (null !== $newValue && $actual->{"get$method"}() !== $newValue) {
14✔
915
                $actual = $actual->{"with$method"}($newValue);
14✔
916
            }
917
        }
918

919
        foreach (['examples', 'content', 'schema'] as $method) {
14✔
920
            $newValue = $defined->{"get$method"}();
14✔
921
            if ($newValue && \count($newValue) > 0 && $actual->{"get$method"}() !== $newValue) {
14✔
922
                $actual = $actual->{"with$method"}($newValue);
×
923
            }
924
        }
925

926
        return $actual;
14✔
927
    }
928

929
    /**
930
     * @param ErrorResource[]              $errors
931
     * @param \ArrayObject<string, Schema> $schemas
932
     */
933
    private function addOperationErrors(
934
        Operation $operation,
935
        array $errors,
936
        ResourceMetadataCollection $resourceMetadataCollection,
937
        Schema $schema,
938
        \ArrayObject $schemas,
939
        HttpOperation $originalOperation,
940
    ): Operation {
941
        foreach ($errors as $errorResource) {
30✔
942
            $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats);
30✔
943
            foreach ($errorResource->getOperations() as $errorOperation) {
30✔
944
                if (false === $errorOperation->getOpenApi()) {
30✔
945
                    continue;
30✔
946
                }
947

UNCOV
948
                $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $this->errorFormats);
12✔
949
            }
950

951
            foreach ($responseMimeTypes as $mime => $format) {
30✔
952
                if (!isset($this->errorFormats[$format])) {
30✔
953
                    unset($responseMimeTypes[$mime]);
×
954
                }
955
            }
956

957
            $operationErrorSchemas = [];
30✔
958
            foreach ($responseMimeTypes as $operationFormat) {
30✔
959
                $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema);
30✔
960
                $operationErrorSchemas[$operationFormat] = $operationErrorSchema;
30✔
961
                $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions());
30✔
962
            }
963

964
            if (!$status = $errorResource->getStatus()) {
30✔
965
                throw new RuntimeException(\sprintf('The error class "%s" has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $errorResource->getClass()));
×
966
            }
967

968
            $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection);
30✔
969
        }
970

971
        return $operation;
30✔
972
    }
973

974
    /**
975
     * @param string|class-string $error
976
     */
977
    private function getErrorResource(string $error, ?int $status = null, ?string $description = null): ErrorResource
978
    {
979
        if ($this->localErrorResourceCache[$error] ?? null) {
32✔
980
            return $this->localErrorResourceCache[$error];
32✔
981
        }
982

983
        if (is_a($error, ProblemExceptionInterface::class, true)) {
32✔
984
            try {
985
                /** @var ProblemExceptionInterface $exception */
986
                $exception = new $error();
32✔
987
                $status = $exception->getStatus();
32✔
988
                $description = $exception->getTitle();
32✔
989
            } catch (\TypeError) {
32✔
990
            }
991
        } elseif (class_exists($error)) {
×
992
            throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class));
×
993
        }
994

995
        $defaultErrorResourceClass = $this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class;
32✔
996

997
        try {
998
            $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass);
32✔
999
            if (!($errorResource instanceof ErrorResource)) {
32✔
1000
                throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error));
×
1001
            }
1002

1003
            // Here we want the exception status and expression to override the resource one when available
1004
            if ($status) {
32✔
1005
                $errorResource = $errorResource->withStatus($status);
32✔
1006
            }
1007

1008
            if ($description) {
32✔
1009
                $errorResource = $errorResource->withDescription($description);
32✔
1010
            }
1011
        } catch (ResourceClassNotFoundException|OperationNotFoundException) {
×
1012
            $errorResource = new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass);
×
1013
        }
1014

1015
        if (!$errorResource->getClass()) {
32✔
1016
            $errorResource = $errorResource->withClass($error);
×
1017
        }
1018

1019
        return $this->localErrorResourceCache[$error] = $errorResource;
32✔
1020
    }
1021
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc