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

api-platform / core / 13586767090

28 Feb 2025 11:01AM UTC coverage: 8.519% (+0.003%) from 8.516%
13586767090

Pull #6990

github

web-flow
Merge 3b0a00330 into 79fd1eb7e
Pull Request #6990: Merge 4.0

12 of 49 new or added lines in 6 files covered. (24.49%)

2 existing lines in 2 files now uncovered.

13377 of 157029 relevant lines covered (8.52%)

22.89 hits per line

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

80.67
/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\Type;
61
use Symfony\Component\Routing\RouteCollection;
62
use Symfony\Component\Routing\RouterInterface;
63

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

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

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

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

124
        foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
47✔
125
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
47✔
126
            foreach ($resourceMetadataCollection as $resourceMetadata) {
47✔
127
                $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context);
47✔
128
            }
129
        }
130

131
        $securitySchemes = $this->getSecuritySchemes();
47✔
132
        $securityRequirements = [];
47✔
133

134
        foreach (array_keys($securitySchemes) as $key) {
47✔
135
            $securityRequirements[] = [$key => []];
47✔
136
        }
137

138
        $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: [];
47✔
139

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

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

167
        $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class);
47✔
168
        $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity');
47✔
169

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

176
        foreach ($resource->getOperations() as $operationName => $operation) {
47✔
177
            $resourceShortName = $operation->getShortName();
47✔
178
            // No path to return
179
            if (null === $operation->getUriTemplate() && null === $operation->getRouteName()) {
47✔
180
                continue;
×
181
            }
182

183
            $openapiAttribute = $operation->getOpenapi();
47✔
184

185
            // Operation ignored from OpenApi
186
            if ($operation instanceof HttpOperation && false === $openapiAttribute) {
47✔
187
                continue;
47✔
188
            }
189

190
            $operationTag = ($openapiAttribute?->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []);
47✔
191
            if (!\is_array($operationTag)) {
47✔
192
                $operationTag = [$operationTag];
41✔
193
            }
194

195
            if ($filteredTags && $filteredTags !== array_intersect($filteredTags, $operationTag)) {
47✔
196
                continue;
4✔
197
            }
198

199
            $resourceClass = $operation->getClass() ?? $resource->getClass();
47✔
200
            $routeName = $operation->getRouteName() ?? $operation->getName();
47✔
201

202
            if (!$this->routeCollection && $this->router) {
47✔
203
                $this->routeCollection = $this->router->getRouteCollection();
47✔
204
            }
205

206
            if ($this->routeCollection && $routeName && $route = $this->routeCollection->get($routeName)) {
47✔
207
                $path = $route->getPath();
47✔
208
            } else {
NEW
209
                $path = rtrim($operation->getRoutePrefix() ?? '', '/').'/'.ltrim($operation->getUriTemplate(), '/');
×
210
            }
211

212
            $path = $this->getPath($path);
47✔
213
            $method = $operation->getMethod() ?? 'GET';
47✔
214

215
            if (!\in_array($method, PathItem::$methods, true)) {
47✔
216
                continue;
×
217
            }
218

219
            $pathItem = null;
47✔
220

221
            if ($openapiAttribute instanceof Webhook) {
47✔
222
                $pathItem = $openapiAttribute->getPathItem() ?: new PathItem();
27✔
223
                $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation();
27✔
224
            } elseif (!\is_object($openapiAttribute)) {
47✔
225
                $openapiOperation = new Operation();
43✔
226
            } else {
227
                $openapiOperation = $openapiAttribute;
41✔
228
            }
229

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

247
            foreach ($openapiOperation->getTags() as $v) {
47✔
248
                $tags[$v] = new Tag(name: $v, description: $resource->getDescription());
47✔
249
            }
250

251
            [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation);
47✔
252

253
            if ($path) {
47✔
254
                $pathItem = $paths->getPath($path) ?: new PathItem();
47✔
255
            } elseif (!$pathItem) {
×
256
                $pathItem = new PathItem();
×
257
            }
258

259
            $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method;
47✔
260
            $schema = new Schema('openapi');
47✔
261
            $schema->setDefinitions($schemas);
47✔
262

263
            $operationOutputSchemas = [];
47✔
264

265
            foreach ($responseMimeTypes as $operationFormat) {
47✔
266
                $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection);
47✔
267
                $operationOutputSchemas[$operationFormat] = $operationOutputSchema;
47✔
268
                $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions());
47✔
269
            }
270

271
            // Set up parameters
272
            $openapiParameters = $openapiOperation->getParameters();
47✔
273
            foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) {
47✔
274
                if ($uriVariable->getExpandedValue() ?? false) {
45✔
275
                    continue;
31✔
276
                }
277

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

280
                if ($linkParameter = $uriVariable->getOpenApi()) {
45✔
281
                    $parameter = $this->mergeParameter($parameter, $linkParameter);
×
282
                }
283

284
                if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
45✔
285
                    $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
286
                    continue;
×
287
                }
288

289
                $openapiParameters[] = $parameter;
45✔
290
            }
291

292
            $openapiOperation = $openapiOperation->withParameters($openapiParameters);
47✔
293

294
            if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) {
47✔
295
                foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) {
47✔
296
                    if ($operationParameter = $this->hasParameter($openapiOperation, $parameter)) {
47✔
297
                        continue;
×
298
                    }
299

300
                    $openapiOperation = $openapiOperation->withParameter($parameter);
47✔
301
                }
302
            }
303

304
            $entityClass = $this->getFilterClass($operation);
47✔
305
            $openapiParameters = $openapiOperation->getParameters();
47✔
306
            foreach ($operation->getParameters() ?? [] as $key => $p) {
47✔
307
                if (false === $p->getOpenApi()) {
33✔
308
                    continue;
33✔
309
                }
310

311
                if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) {
29✔
312
                    $filter = $this->filterLocator->get($f);
29✔
313

314
                    if ($d = $filter->getDescription($entityClass)) {
29✔
315
                        foreach ($d as $name => $description) {
29✔
316
                            if ($prop = $p->getProperty()) {
29✔
317
                                $name = str_replace($prop, $key, $name);
27✔
318
                            }
319

320
                            $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f);
29✔
321
                        }
322

323
                        continue;
29✔
324
                    }
325
                }
326

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

330
                $linkParameter = $p->getOpenApi();
29✔
331
                if (null === $linkParameter) {
29✔
332
                    if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) {
29✔
333
                        $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter);
×
334
                    } else {
335
                        $openapiParameters[] = $defaultParameter;
29✔
336
                    }
337

338
                    continue;
29✔
339
                }
340

341
                if (\is_array($linkParameter)) {
29✔
342
                    foreach ($linkParameter as $lp) {
27✔
343
                        $parameter = $this->mergeParameter($defaultParameter, $lp);
27✔
344
                        if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
27✔
345
                            $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
346
                            continue;
×
347
                        }
348

349
                        $openapiParameters[] = $parameter;
27✔
350
                    }
351
                    continue;
27✔
352
                }
353

354
                $parameter = $this->mergeParameter($defaultParameter, $linkParameter);
29✔
355
                if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
29✔
356
                    $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
×
357
                    continue;
×
358
                }
359
                $openapiParameters[] = $parameter;
29✔
360
            }
361

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

373
                $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation);
27✔
374
            }
375

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

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

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

413
            if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) {
47✔
414
                $openapiOperation = $this->addOperationErrors($openapiOperation, [
27✔
415
                    $defaultError->withStatus(403)->withDescription('Forbidden'),
27✔
416
                ], $resourceMetadataCollection, $schema, $schemas, $operation);
27✔
417
            }
418

419
            if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) {
47✔
420
                $openapiOperation = $this->addOperationErrors($openapiOperation, [
45✔
421
                    $defaultError->withStatus(404)->withDescription('Not found'),
45✔
422
                ], $resourceMetadataCollection, $schema, $schemas, $operation);
45✔
423
            }
424

425
            if (!$openapiOperation->getResponses()) {
47✔
426
                $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error'));
×
427
            }
428

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

444
                $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(
43✔
445
                    description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName),
43✔
446
                    content: $content,
43✔
447
                    required: $openapiOperation->getRequestBody()?->getRequired() ?? true,
43✔
448
                ));
43✔
449
            }
450

451
            if ($openapiAttribute instanceof Webhook) {
47✔
452
                $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation);
27✔
453
                continue;
27✔
454
            }
455

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

461
                foreach ($errorResponse->getContent() as $mime => $content) {
×
462
                    $currentResponseContent[$mime] = $content;
×
463
                }
464

465
                $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent));
×
466
            }
467

468
            $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation));
47✔
469
        }
470
    }
471

472
    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
473
    {
474
        if (isset($existingResponses[$status])) {
47✔
475
            return $openapiOperation;
×
476
        }
477
        $responseLinks = $responseContent = null;
47✔
478
        if ($responseMimeTypes && $operationOutputSchemas) {
47✔
479
            $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas);
47✔
480
        }
481
        if ($resourceMetadataCollection && $operation) {
47✔
482
            $responseLinks = $this->getLinks($resourceMetadataCollection, $operation);
45✔
483
        }
484

485
        return $openapiOperation->withResponse($status, new Response($description, $responseContent, null, $responseLinks));
47✔
486
    }
487

488
    /**
489
     * @param array<string, string> $responseMimeTypes
490
     * @param array<string, Schema> $operationSchemas
491
     *
492
     * @return \ArrayObject<MediaType>
493
     */
494
    private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject
495
    {
496
        /** @var \ArrayObject<MediaType> $content */
497
        $content = new \ArrayObject();
47✔
498

499
        foreach ($responseMimeTypes as $mimeType => $format) {
47✔
500
            $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false)));
47✔
501
        }
502

503
        return $content;
47✔
504
    }
505

506
    /**
507
     * @return array[array<string, string>, array<string, string>]
508
     */
509
    private function getMimeTypes(HttpOperation $operation): array
510
    {
511
        $requestFormats = $operation->getInputFormats() ?: [];
47✔
512
        $responseFormats = $operation->getOutputFormats() ?: [];
47✔
513

514
        $requestMimeTypes = $this->flattenMimeTypes($requestFormats);
47✔
515
        $responseMimeTypes = $this->flattenMimeTypes($responseFormats);
47✔
516

517
        return [$requestMimeTypes, $responseMimeTypes];
47✔
518
    }
519

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

534
        return $responseMimeTypes;
47✔
535
    }
536

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

552
        return str_starts_with($path, '/') ? $path : '/'.$path;
47✔
553
    }
554

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

577
        return \sprintf($pathSummary, $resourceShortName);
47✔
578
    }
579

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

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

604
                // Operation ignored from OpenApi
605
                if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) {
×
606
                    continue;
×
607
                }
608

609
                $operationUriVariables = $operation->getUriVariables();
×
610
                foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) {
×
611
                    if (!isset($operationUriVariables[$parameterName])) {
×
612
                        continue;
×
613
                    }
614

615
                    if ($operationUriVariables[$parameterName]->getIdentifiers() === $uriVariableDefinition->getIdentifiers() && $operationUriVariables[$parameterName]->getFromClass() === $uriVariableDefinition->getFromClass()) {
×
616
                        $parameters[$parameterName] = '$request.path.'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id');
×
617
                    }
618
                }
619

620
                foreach ($operationUriVariables ?? [] as $parameterName => $uriVariableDefinition) {
×
621
                    if (isset($parameters[$parameterName])) {
×
622
                        continue;
×
623
                    }
624

625
                    if ($uriVariableDefinition->getFromClass() === $currentOperation->getClass()) {
×
626
                        $parameters[$parameterName] = '$response.body#/'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id');
×
627
                    }
628
                }
629

630
                $links[$operationName] = new Link(
×
631
                    $operationName,
×
632
                    new \ArrayObject($parameters),
×
633
                    null,
×
634
                    $operation->getDescription() ?? ''
×
635
                );
×
636
            }
637
        }
638

639
        return $links;
45✔
640
    }
641

642
    /**
643
     * Gets parameters corresponding to enabled filters.
644
     */
645
    private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array
646
    {
647
        $parameters = [];
47✔
648
        $resourceFilters = $operation->getFilters();
47✔
649
        $entityClass = $this->getFilterClass($operation);
47✔
650

651
        foreach ($resourceFilters ?? [] as $filterId) {
47✔
652
            if (!$this->filterLocator->has($filterId)) {
31✔
653
                continue;
×
654
            }
655

656
            $filter = $this->filterLocator->get($filterId);
31✔
657
            foreach ($filter->getDescription($entityClass) as $name => $description) {
31✔
658
                $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId);
31✔
659
            }
660
        }
661

662
        return $parameters;
47✔
663
    }
664

665
    private function getFilterClass(HttpOperation $operation): ?string
666
    {
667
        $entityClass = $operation->getClass();
47✔
668
        if ($options = $operation->getStateOptions()) {
47✔
669
            if ($options instanceof DoctrineOptions && $options->getEntityClass()) {
31✔
670
                return $options->getEntityClass();
27✔
671
            }
672

673
            if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) {
31✔
674
                return $options->getDocumentClass();
27✔
675
            }
676
        }
677

678
        return $entityClass;
47✔
679
    }
680

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

690
        if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) {
33✔
691
            $schema = $description['schema'] ?? [];
33✔
692

693
            if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) {
33✔
694
                $schema += $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false));
33✔
695
            }
696

697
            if (!isset($schema['type'])) {
33✔
698
                $schema['type'] = 'string';
31✔
699
            }
700

701
            $style = 'array' === ($schema['type'] ?? null) && \in_array(
33✔
702
                $description['type'],
33✔
703
                [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT],
33✔
704
                true
33✔
705
            ) ? 'deepObject' : 'form';
33✔
706

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

709
            if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) {
33✔
710
                $parameter = $parameter->withDescription($str);
×
711
            }
712

713
            if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) {
33✔
714
                $parameter = $parameter->withRequired($required);
27✔
715
            }
716

717
            return $parameter->withSchema($schema);
33✔
718
        }
719

720
        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));
×
721
        $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']);
×
722

723
        return new Parameter(
×
724
            $name,
×
725
            'query',
×
726
            $description['description'] ?? '',
×
727
            $description['required'] ?? false,
×
728
            $description['openapi']['deprecated'] ?? false,
×
729
            $description['openapi']['allowEmptyValue'] ?? true,
×
730
            $schema,
×
731
            'array' === $schema['type'] && \in_array(
×
732
                $description['type'],
×
733
                [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT],
×
734
                true
×
735
            ) ? 'deepObject' : 'form',
×
736
            $description['openapi']['explode'] ?? ('array' === $schema['type']),
×
737
            $description['openapi']['allowReserved'] ?? false,
×
738
            $description['openapi']['example'] ?? null,
×
739
            isset(
×
740
                $description['openapi']['examples']
×
741
            ) ? new \ArrayObject($description['openapi']['examples']) : null
×
742
        );
×
743
    }
744

745
    private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array
746
    {
747
        if (!$this->paginationOptions->isPaginationEnabled()) {
47✔
748
            return [];
×
749
        }
750

751
        $parameters = [];
47✔
752

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

756
            if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) {
47✔
757
                $schema = [
47✔
758
                    'type' => 'integer',
47✔
759
                    'default' => $operation->getPaginationItemsPerPage() ?? $this->paginationOptions->getItemsPerPage(),
47✔
760
                    'minimum' => 0,
47✔
761
                ];
47✔
762

763
                if (null !== $maxItemsPerPage = ($operation->getPaginationMaximumItemsPerPage() ?? $this->paginationOptions->getMaximumItemsPerPage())) {
47✔
764
                    $schema['maximum'] = $maxItemsPerPage;
15✔
765
                }
766

767
                $parameters[] = new Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema);
47✔
768
            }
769
        }
770

771
        if ($operation->getPaginationClientEnabled() ?? $this->paginationOptions->isPaginationClientEnabled()) {
47✔
772
            $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']);
47✔
773
        }
774

775
        return $parameters;
47✔
776
    }
777

778
    private function getOauthSecurityScheme(): SecurityScheme
779
    {
780
        $oauthFlow = new OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl() ?: null, $this->openApiOptions->getOAuthRefreshUrl() ?: null, new \ArrayObject($this->openApiOptions->getOAuthScopes()));
47✔
781
        $description = \sprintf(
47✔
782
            'OAuth 2.0 %s Grant',
47✔
783
            strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow())))
47✔
784
        );
47✔
785
        $implicit = $password = $clientCredentials = $authorizationCode = null;
47✔
786

787
        switch ($this->openApiOptions->getOAuthFlow()) {
47✔
788
            case 'implicit':
47✔
789
                $implicit = $oauthFlow;
47✔
790
                break;
47✔
791
            case 'password':
×
792
                $password = $oauthFlow;
×
793
                break;
×
794
            case 'application':
×
795
            case 'clientCredentials':
×
796
                $clientCredentials = $oauthFlow;
×
797
                break;
×
798
            case 'accessCode':
×
799
            case 'authorizationCode':
×
800
                $authorizationCode = $oauthFlow;
×
801
                break;
×
802
            default:
803
                throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode');
×
804
        }
805

806
        return new SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, null, null, new OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null);
47✔
807
    }
808

809
    private function getSecuritySchemes(): array
810
    {
811
        $securitySchemes = [];
47✔
812

813
        if ($this->openApiOptions->getOAuthEnabled()) {
47✔
814
            $securitySchemes['oauth'] = $this->getOauthSecurityScheme();
47✔
815
        }
816

817
        foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) {
47✔
818
            $description = \sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']);
47✔
819
            $securitySchemes[$key] = new SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type']);
47✔
820
        }
821

822
        foreach ($this->openApiOptions->getHttpAuth() as $key => $httpAuth) {
47✔
823
            $description = \sprintf('Value for the http %s parameter.', $httpAuth['scheme']);
×
824
            $securitySchemes[$key] = new SecurityScheme('http', $description, null, null, $httpAuth['scheme'], $httpAuth['bearerFormat'] ?? null);
×
825
        }
826

827
        return $securitySchemes;
47✔
828
    }
829

830
    /**
831
     * @param \ArrayObject<string, mixed> $schemas
832
     * @param \ArrayObject<string, mixed> $definitions
833
     */
834
    private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void
835
    {
836
        foreach ($definitions as $key => $value) {
47✔
837
            $schemas[$key] = $value;
47✔
838
        }
839
    }
840

841
    /**
842
     * @return array{0: int, 1: Parameter}|null
843
     */
844
    private function hasParameter(Operation $operation, Parameter $parameter): ?array
845
    {
846
        foreach ($operation->getParameters() as $key => $existingParameter) {
47✔
847
            if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) {
47✔
848
                return [$key, $existingParameter];
×
849
            }
850
        }
851

852
        return null;
47✔
853
    }
854

855
    private function mergeParameter(Parameter $actual, Parameter $defined): Parameter
856
    {
857
        foreach (
858
            [
29✔
859
                'name',
29✔
860
                'in',
29✔
861
                'description',
29✔
862
                'required',
29✔
863
                'deprecated',
29✔
864
                'allowEmptyValue',
29✔
865
                'style',
29✔
866
                'explode',
29✔
867
                'allowReserved',
29✔
868
                'example',
29✔
869
            ] as $method
29✔
870
        ) {
871
            $newValue = $defined->{"get$method"}();
29✔
872
            if (null !== $newValue && $actual->{"get$method"}() !== $newValue) {
29✔
873
                $actual = $actual->{"with$method"}($newValue);
29✔
874
            }
875
        }
876

877
        foreach (['examples', 'content', 'schema'] as $method) {
29✔
878
            $newValue = $defined->{"get$method"}();
29✔
879
            if ($newValue && \count($newValue) > 0 && $actual->{"get$method"}() !== $newValue) {
29✔
880
                $actual = $actual->{"with$method"}($newValue);
×
881
            }
882
        }
883

884
        return $actual;
29✔
885
    }
886

887
    /**
888
     * @param ErrorResource[]              $errors
889
     * @param \ArrayObject<string, Schema> $schemas
890
     */
891
    private function addOperationErrors(
892
        Operation $operation,
893
        array $errors,
894
        ResourceMetadataCollection $resourceMetadataCollection,
895
        Schema $schema,
896
        \ArrayObject $schemas,
897
        HttpOperation $originalOperation,
898
    ): Operation {
899
        foreach ($errors as $errorResource) {
45✔
900
            $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats);
45✔
901
            foreach ($errorResource->getOperations() as $errorOperation) {
45✔
902
                if (false === $errorOperation->getOpenApi()) {
45✔
903
                    continue;
45✔
904
                }
905

906
                $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $this->errorFormats);
27✔
907
            }
908

909
            foreach ($responseMimeTypes as $mime => $format) {
45✔
910
                if (!isset($this->errorFormats[$format])) {
45✔
911
                    unset($responseMimeTypes[$mime]);
×
912
                }
913
            }
914

915
            $operationErrorSchemas = [];
45✔
916
            foreach ($responseMimeTypes as $operationFormat) {
45✔
917
                $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema);
45✔
918
                $operationErrorSchemas[$operationFormat] = $operationErrorSchema;
45✔
919
                $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions());
45✔
920
            }
921

922
            if (!$status = $errorResource->getStatus()) {
45✔
923
                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()));
×
924
            }
925

926
            $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection);
45✔
927
        }
928

929
        return $operation;
45✔
930
    }
931

932
    /**
933
     * @param string|class-string $error
934
     */
935
    private function getErrorResource(string $error, ?int $status = null, ?string $description = null): ErrorResource
936
    {
937
        if ($this->localErrorResourceCache[$error] ?? null) {
47✔
938
            return $this->localErrorResourceCache[$error];
47✔
939
        }
940

941
        if (is_a($error, ProblemExceptionInterface::class, true)) {
47✔
942
            try {
943
                /** @var ProblemExceptionInterface $exception */
944
                $exception = new $error();
47✔
945
                $status = $exception->getStatus();
47✔
946
                $description = $exception->getTitle();
47✔
947
            } catch (\TypeError) {
47✔
948
            }
949
        } elseif (class_exists($error)) {
×
950
            throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class));
×
951
        }
952

953
        $defaultErrorResourceClass = $this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class;
47✔
954

955
        try {
956
            $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass);
47✔
957
            if (!($errorResource instanceof ErrorResource)) {
47✔
958
                throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error));
×
959
            }
960

961
            // Here we want the exception status and expression to override the resource one when available
962
            if ($status) {
47✔
963
                $errorResource = $errorResource->withStatus($status);
47✔
964
            }
965

966
            if ($description) {
47✔
967
                $errorResource = $errorResource->withDescription($description);
47✔
968
            }
969
        } catch (ResourceClassNotFoundException|OperationNotFoundException) {
×
970
            $errorResource = new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass);
×
971
        }
972

973
        if (!$errorResource->getClass()) {
47✔
974
            $errorResource = $errorResource->withClass($error);
×
975
        }
976

977
        return $this->localErrorResourceCache[$error] = $errorResource;
47✔
978
    }
979
}
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